從零寫一支 UE5 pak 讀取器:我怎麼從 38GB 遊戲封包挖出官方沒公開的資料
目錄
從零寫一支 UE5 pak 讀取器:我怎麼從 38GB 遊戲封包挖出官方沒公開的資料
這篇是一次真實的逆向過程記錄。起點很俗氣:我想知道一款遊戲裡「哪些單位放在基地會有隱藏效果」。官方沒文件,社群 wiki 寫著 "abilities still being investigated",中文圈完全沒資料。
於是我決定直接去問遊戲本體。結果是:兩小時、零依賴、一支 200 行的 Python,把 38GB 封包裡的官方資料表撈了出來,得到一份全網都還沒有的清單。
這篇講的不是那份遊戲清單(那是另一個受眾的事),而是方法:如果你也遇到「答案就在某個二進位檔裡,但沒有現成工具」的情況,這套流程可以直接套用。
先誠實講:什麼能做、什麼做不到
我不想把這件事講得像魔法,先把邊界劃清楚:
- ✅ 能做:讀出封包裡的檔案清單、把指定檔案解壓出來、把資料表裡的文字內容抽出來。
- ⚠️ 有條件:UE5 的資產(
.uasset/.uexp)預設用「unversioned properties」序列化——沒有 usmap 對照表,你無法完整還原結構化欄位。這是最大的坑,後面講怎麼繞。 - ❌ 不做:這篇不談修改遊戲、不談散布遊戲資產。全程唯讀。挖出來的「事實」(數值、機制)可以拿去寫攻略;整包資產不要散布,那是另一回事。
Step 1:先確認你面對的是什麼
UE 的封包有兩種格式,處理難度差很多:
| 格式 | 檔案 | 難度 |
|---|---|---|
| 傳統 pak | .pak |
中等,格式公開、結構單純 |
| IoStore | .utoc + .ucas |
高,要處理 container、chunk id、global name map |
我的目標只有一個 38GB 的 .pak——運氣好,走傳統路線。
pak 的結尾有一段 footer,這是所有資訊的入口。它的結構(v11)從尾端往前是:
[EncryptionKeyGuid 16B][bEncryptedIndex 1B][Magic 4B = 0x5A6F12E1]
[Version 4B][IndexOffset 8B][IndexSize 8B][IndexHash 20B]
[CompressionMethods 32B × 5]
寫個 20 行的解析就能拿到關鍵三件事:
FOOTER = 16 + 1 + 4 + 4 + 8 + 8 + 20 + 32 * 5
f.seek(size - FOOTER)
foot = f.read(FOOTER)
guid, encflag = foot[:16], foot[16]
magic, ver, ioff, isize = struct.unpack("<IIQQ", foot[17:41])
assert magic == 0x5A6F12E1
methods = [foot[61 + i*32 : 61 + (i+1)*32].split(b"\x00")[0].decode() for i in range(5)]
輸出:
版本=11 加密=False 壓縮法=['Oodle']
這行輸出決定了後面所有事:
加密=False(EncryptionKeyGuid 全 0)→ 不用找 AES key,省掉最痛的一步。壓縮法=['Oodle']→ 待會需要 Oodle 解壓器。這是 RAD Game Tools 的專有壓縮,Python 標準庫沒有。
Step 2:讀索引(拿到 18.5 萬個檔案的地圖)
Primary index 的結構是這樣(v10+):
mount = rd_str(idx) # 掛載點
num = u32(idx) # 檔案數
seed = u64(idx) # path hash seed
if u32(idx): # 有 path hash index
ph_off, ph_size = u64, u64; idx.read(20)
if u32(idx): # 有 full directory index
fd_off, fd_size = u64, u64; idx.read(20)
enc_size = u32(idx)
encoded = idx.read(enc_size) # 壓縮過的 entry 資料
真正的檔案路徑在 full directory index,它是個兩層 map(目錄 → 檔名 → entry 偏移):
ndirs = u32(fd)
for _ in range(ndirs):
d = rd_str(fd)
for _ in range(u32(fd)):
fn = rd_str(fd)
off = u32(fd) # 指向 encoded entries 的偏移
files[d + fn] = off
跑完:
mount=../../../ 檔案數=185,003
目錄=9,007 檔案=185,003
這一步的價值在於:你現在有了整個遊戲的檔案地圖,可以用關鍵字搜。 我要找的東西,一個 grep 就浮出來了:
Pal/Content/Pal/DataTable/PartnerSkill/DT_PartnerSkill.uexp
Pal/Content/L10N/zh-Hant/Pal/DataTable/Text/DT_PalFirstActivatedInfoText.uexp
Pal/Config/DefaultPalWorldSettings.ini ← 官方預設值,直接讀
Step 3:解碼 entry(位元打包的省空間格式)
每個檔案的 offset / size / 壓縮資訊,被位元打包在一個 32-bit flag 裡。這是 pak 格式最容易寫錯的地方:
v = u32(encoded, o)
comp = (v >> 23) & 0x3F # 壓縮方法 index(0 = 不壓縮)
nblocks = (v >> 6) & 0xFFFF # 壓縮區塊數
offset = u32 if v & (1<<31) else u64 # 依 flag 決定寬度
usize = u32 if v & (1<<30) else u64
csize = u32 if v & (1<<29) else u64
注意那幾個 flag bit:同一個欄位可能是 4 bytes 也可能是 8 bytes,取決於 flag。這種設計是為了省索引空間(18.5 萬個檔案,每個省 4 bytes 就是 700KB),但對解析器來說是個陷阱——寫錯一個 bit,整份索引就歪掉。
Step 4:Oodle 解壓(用你已經有的東西)
Oodle 是專有壓縮,沒有官方 Python binding。多數教學會叫你去找 oo2core_*.dll——但更快的路是:你電腦裡可能已經有能用的實作了。
我機器上剛好有一個處理該遊戲存檔的開源工具,它自帶 Oodle 解壓(ooz)。直接接管來用:
sys.path.insert(0, "<那個工具的 lib 目錄>")
import ooz
out = b""
for (start, end) in blocks:
chunk = f.read(end - start)
want = min(block_size, usize - len(out))
out += ooz.decompress(chunk, want) # 要餵解壓後的目標大小
關鍵細節:Oodle 解壓必須知道輸出大小(不像 zlib 可以邊解邊長)。這個大小要從 entry 的 usize 和 block size 自己算出來,算錯就爆。
這裡有個更通用的心法:你要的能力,往往已經躺在你裝過的某個工具裡。 與其從頭找 DLL、處理 ABI,不如先盤點手上有什麼。
Step 5:繞過 usmap 這個大坑
現在你有解壓後的 .uexp 了。壞消息:UE5 預設用 unversioned properties——欄位名不寫進檔案,只寫 index,要靠一份 .usmap 對照表才能還原。而 usmap 通常要靠 dumper 從執行中的遊戲抓,那是另一個工程。
但如果你要的是文字(技能說明、道具描述、在地化字串),有一條捷徑:直接掃 FString。
UE 的字串序列化很好認——int32 長度 + 內容,正數是 UTF-8、負數是 UTF-16:
ln = struct.unpack_from("<i", b, i)[0]
if 0 < ln < 400: # UTF-8
s = b[i+4 : i+4+ln]
elif -400 < ln < 0: # UTF-16
s = b[i+4 : i+4+(-ln*2)].decode("utf-16-le")
DataTable 的儲存順序是 RowName 緊接著 Value,所以掃出來的字串序列天然就是配對的:
for i, s in enumerate(strings):
if s.endswith("_TextData"): # 這是 row key
rows[s[:-9]] = strings[i+1] # 下一條就是內容
這招不通用(結構化的數值欄位還是得靠 usmap),但對「文字表」100% 夠用,而且零依賴。我用它解出 305 條資料,完整性驗證:305 個 key、305 個成功配對、0 漏行。
Step 6:驗證(這步不能省)
挖到資料只是一半,你必須證明它是完整且正確的,否則你只是在製造更精緻的謠言。我做了三層驗證:
- 完整性:解析出的 key 數 = 表內 key 數(305 = 305,0 漏行)。如果解析器有 bug,這裡一定對不上。
- 反向搜尋:我宣稱「只有一個單位有 X 效果」——那就用別的關鍵字(同義詞、相關詞)重掃全表,確認沒有漏網的。結論才敢寫「唯一」。
- 交叉比對:把挖出來的結論拿去對照遊戲的其他資料來源(另一張表、設定檔)。例如我原本引用外站說「上限是 10」,回頭在遊戲檔案裡找不到任何佐證,就把這句話刪掉了——寧可少講一句,不要講錯一句。
第 3 點是最重要的。逆向工程最大的風險不是解不開,而是解開了但理解錯了,還很有自信地公開。
成果
- 一支 ~200 行、零第三方依賴的 UE5 pak 讀取器(footer → 索引 → entry → Oodle → 字串)
- 從 38GB 封包裡精準取出需要的 3 個資料表 + 1 個官方設定檔
- 得到一份官方沒公開、wiki 還沒有、中文圈完全沒有的機制清單
- 順帶推翻了一條我原本要引用的外站說法(因為遊戲檔案裡找不到證據)
這支工具不綁特定遊戲——任何用傳統 pak + Oodle 的 UE4/UE5 遊戲都適用。
為什麼 Ultra Lab 要寫這個
表面上這是遊戲考據,實際上這是我們每天在做的事的縮影:當「權威來源」說不知道的時候,去讀原始的那一層。
我們做 UltraProbe 是同一件事——不看廠商的行銷話術,直接掃 prompt、掃 agent 的實際行為,看它到底有沒有防護。答案通常不在文件裡,在二進位檔裡、在流量裡、在實際跑起來的行為裡。
工具會變,這個習慣不會。
免責與邊界
- 全程唯讀,沒有修改任何遊戲檔案。
- 不散布遊戲資產或整包文字,本文只呈現「我整理出的結論」與方法。
- 機制與數值屬於事實陳述,寫成攻略沒問題;資產有著作權,那是另一回事。這條線要自己守好。