Writing a UE5 Pak Reader From Scratch: Pulling Undocumented Data Out of a 38GB Game Archive
Table of Contents
- Being upfront: what works and what doesn't
- Step 1: Identify what you're actually facing
- Step 2: Read the index (a map of 185,000 files)
- Step 3: Decode entries (bit-packed for space)
- Step 4: Oodle decompression (use what you already have)
- Step 5: Routing around the usmap problem
- Step 6: Verification (do not skip this)
- Outcome
- Why Ultra Lab writes about this
- Scope and disclaimers
Writing a UE5 Pak Reader From Scratch: Pulling Undocumented Data Out of a 38GB Game Archive
This is a log of a real reverse-engineering session. The starting point was mundane: I wanted to know which units in a game have hidden effects when placed in your base. No official docs. The community wiki said "abilities still being investigated." Nothing at all in Chinese.
So I asked the game itself. Result: two hours, zero dependencies, ~200 lines of Python, and I had official data tables out of a 38GB archive — producing a list that exists nowhere else on the internet.
This post isn't about that game list (different audience). It's about the method. If you ever hit "the answer is in this binary and no tool exists," this workflow transfers directly.
Being upfront: what works and what doesn't
Let me draw the boundaries before this sounds like magic:
- ✅ Works: read the archive's file listing, extract any file, pull text content out of data tables.
- ⚠️ Conditional: UE5 assets (
.uasset/.uexp) serialize with unversioned properties by default. Without a usmap, you cannot fully reconstruct structured fields. That's the big trap; I'll show how to route around it. - ❌ Not covered: modifying the game, redistributing assets. Everything here is read-only. Facts you extract (numbers, mechanics) are fair game for a guide; dumping the assets themselves is a different matter.
Step 1: Identify what you're actually facing
UE ships two archive formats, and the difficulty gap is large:
| Format | Files | Difficulty |
|---|---|---|
| Legacy pak | .pak |
Moderate — documented, simple structure |
| IoStore | .utoc + .ucas |
Hard — containers, chunk IDs, global name map |
My target was a single 38GB .pak. Lucky: the easy path.
The footer is the entry point to everything. Reading backwards from the end (v11):
[EncryptionKeyGuid 16B][bEncryptedIndex 1B][Magic 4B = 0x5A6F12E1]
[Version 4B][IndexOffset 8B][IndexSize 8B][IndexHash 20B]
[CompressionMethods 32B × 5]
Twenty lines gets you the three things that matter:
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)]
Output:
version=11 encrypted=False compression=['Oodle']
That one line dictates everything downstream:
encrypted=False(all-zero key GUID) → no AES key hunt. The most painful step, skipped.compression=['Oodle']→ you'll need an Oodle decompressor. It's RAD Game Tools' proprietary codec — not in the Python standard library.
Step 2: Read the index (a map of 185,000 files)
Primary index layout (v10+):
mount = rd_str(idx) # mount point
num = u32(idx) # file count
seed = u64(idx) # path hash seed
if u32(idx): # has path hash index
ph_off, ph_size = u64, u64; idx.read(20)
if u32(idx): # has full directory index
fd_off, fd_size = u64, u64; idx.read(20)
enc_size = u32(idx)
encoded = idx.read(enc_size) # bit-packed entry blob
Real paths live in the full directory index, a two-level map (directory → filename → entry offset):
ndirs = u32(fd)
for _ in range(ndirs):
d = rd_str(fd)
for _ in range(u32(fd)):
fn = rd_str(fd)
off = u32(fd) # offset into the encoded entries blob
files[d + fn] = off
Result:
mount=../../../ files=185,003
dirs=9,007 files=185,003
The payoff: you now hold a map of the entire game and can grep it. What I needed surfaced immediately:
Pal/Content/Pal/DataTable/PartnerSkill/DT_PartnerSkill.uexp
Pal/Content/L10N/<locale>/Pal/DataTable/Text/DT_PalFirstActivatedInfoText.uexp
Pal/Config/DefaultPalWorldSettings.ini ← official defaults, plain text
Step 3: Decode entries (bit-packed for space)
Each file's offset / size / compression info is bit-packed into a single 32-bit flag. This is where pak parsers most often break:
v = u32(encoded, o)
comp = (v >> 23) & 0x3F # compression method index (0 = stored)
nblocks = (v >> 6) & 0xFFFF # number of compressed blocks
offset = u32 if v & (1<<31) else u64 # width depends on the flag!
usize = u32 if v & (1<<30) else u64
csize = u32 if v & (1<<29) else u64
Note those flag bits: the same field may be 4 or 8 bytes wide depending on a flag. It's a space optimization (185k files × 4 bytes saved = 700KB), but it's a landmine for a parser — get one bit wrong and the whole index skews.
Step 4: Oodle decompression (use what you already have)
Oodle is proprietary with no official Python binding. Most guides send you hunting for oo2core_*.dll. Faster route: you may already have a working implementation on disk.
I happened to have an open-source save editor for this game installed, and it ships its own Oodle binding (ooz). Borrow it:
sys.path.insert(0, "<that tool's lib dir>")
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) # must supply the decompressed size
Critical detail: Oodle requires the output size up front (unlike zlib, which can grow as it goes). You compute it from the entry's usize and block size. Get it wrong and it blows up.
The transferable lesson: the capability you need is often already sitting inside a tool you installed for another reason. Inventory what you have before you go DLL hunting and fighting ABIs.
Step 5: Routing around the usmap problem
You now have a decompressed .uexp. Bad news: UE5 uses unversioned properties by default — field names aren't in the file, only indices, resolved via a .usmap mapping normally dumped from a running game. That's a whole separate project.
But if what you want is text (skill descriptions, item text, localization), there's a shortcut: scan for FStrings directly.
UE string serialization is unmistakable — int32 length + payload, where positive means UTF-8 and negative means 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")
DataTables store RowName immediately followed by its value, so the extracted string sequence is naturally paired:
for i, s in enumerate(strings):
if s.endswith("_TextData"): # row key
rows[s[:-9]] = strings[i+1] # next string is the value
This is not general-purpose (structured numeric fields still need a usmap), but for text tables it's 100% sufficient and dependency-free. It gave me 305 rows, with a completeness check: 305 keys found, 305 paired, 0 dropped.
Step 6: Verification (do not skip this)
Extracting data is half the job. You must prove it's complete and correct, or you're just manufacturing better-looking misinformation. Three layers:
- Completeness: parsed key count == key count in the table (305 == 305, zero dropped). A buggy parser fails here loudly.
- Reverse search: I claimed "only one unit has effect X." So I re-scanned the entire table with different keywords (synonyms, related terms) to hunt for anything I'd missed. Only then does the word "only" go in print.
- Cross-checking: validate conclusions against another source inside the game (a second table, a config file). I had originally planned to cite an outside site claiming "the cap is 10" — I could find no evidence for it anywhere in the game files, so I cut the sentence. Better to say one thing less than one thing wrong.
Point 3 matters most. The real risk in reverse engineering isn't failing to crack the format. It's cracking it, misreading it, and publishing confidently.
Outcome
- A ~200-line, zero-dependency UE5 pak reader (footer → index → entry → Oodle → strings)
- Precisely 3 data tables + 1 official config file extracted from a 38GB archive
- A mechanics list that is undocumented officially, absent from the wikis, and nonexistent in Chinese
- One outside claim disproved and dropped, because the game files didn't back it
The tool isn't game-specific — any UE4/UE5 title using legacy pak + Oodle will work.
Why Ultra Lab writes about this
On the surface it's game trivia. Underneath it's a miniature of what we do daily: when the authoritative source says "unknown," go read the layer underneath.
That's exactly what UltraProbe is — we don't take a vendor's marketing claims about AI safety; we scan the actual prompts and the agent's actual behavior. The answer usually isn't in the docs. It's in the binary, the traffic, the runtime behavior.
Tools change. The habit doesn't.
Scope and disclaimers
- Read-only throughout. No game files were modified.
- No redistribution of game assets or bulk text. This post presents my conclusions and the method.
- Mechanics and numbers are statements of fact and fine to write guides about. Assets are copyrighted and are a different matter. Know where the line is.