reverse-engineeringunreal-enginepythondataminingoodle

從零寫一支 UE5 pak 讀取器:我怎麼從 38GB 遊戲封包挖出官方沒公開的資料

· 15 分鐘閱讀
目錄
  1. 先誠實講:什麼能做、什麼做不到
  2. Step 1:先確認你面對的是什麼
  3. Step 2:讀索引(拿到 18.5 萬個檔案的地圖)
  4. Step 3:解碼 entry(位元打包的省空間格式)
  5. Step 4:Oodle 解壓(用你已經有的東西)
  6. Step 5:繞過 usmap 這個大坑
  7. Step 6:驗證(這步不能省)
  8. 成果
  9. 為什麼 Ultra Lab 要寫這個
  10. 免責與邊界

從零寫一支 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:驗證(這步不能省)

挖到資料只是一半,你必須證明它是完整且正確的,否則你只是在製造更精緻的謠言。我做了三層驗證:

  1. 完整性:解析出的 key 數 = 表內 key 數(305 = 305,0 漏行)。如果解析器有 bug,這裡一定對不上。
  2. 反向搜尋:我宣稱「只有一個單位有 X 效果」——那就用別的關鍵字(同義詞、相關詞)重掃全表,確認沒有漏網的。結論才敢寫「唯一」。
  3. 交叉比對:把挖出來的結論拿去對照遊戲的其他資料來源(另一張表、設定檔)。例如我原本引用外站說「上限是 10」,回頭在遊戲檔案裡找不到任何佐證,就把這句話刪掉了——寧可少講一句,不要講錯一句。

第 3 點是最重要的。逆向工程最大的風險不是解不開,而是解開了但理解錯了,還很有自信地公開


成果

  • 一支 ~200 行、零第三方依賴的 UE5 pak 讀取器(footer → 索引 → entry → Oodle → 字串)
  • 38GB 封包裡精準取出需要的 3 個資料表 + 1 個官方設定檔
  • 得到一份官方沒公開、wiki 還沒有、中文圈完全沒有的機制清單
  • 順帶推翻了一條我原本要引用的外站說法(因為遊戲檔案裡找不到證據)

這支工具不綁特定遊戲——任何用傳統 pak + Oodle 的 UE4/UE5 遊戲都適用。


為什麼 Ultra Lab 要寫這個

表面上這是遊戲考據,實際上這是我們每天在做的事的縮影:當「權威來源」說不知道的時候,去讀原始的那一層。

我們做 UltraProbe 是同一件事——不看廠商的行銷話術,直接掃 prompt、掃 agent 的實際行為,看它到底有沒有防護。答案通常不在文件裡,在二進位檔裡、在流量裡、在實際跑起來的行為裡。

工具會變,這個習慣不會。


免責與邊界

  • 全程唯讀,沒有修改任何遊戲檔案。
  • 不散布遊戲資產或整包文字,本文只呈現「我整理出的結論」與方法。
  • 機制與數值屬於事實陳述,寫成攻略沒問題;資產有著作權,那是另一回事。這條線要自己守好。

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

需要技術協助?

免費諮詢,24 小時內回覆。