Containers: how a game file is put together
- Source
docs/formats/containers.md- SHA-256
1200789c…6b6acac7
Structure · Layers · pakd archives · How the game reads it · Rebuilding · Leads
How a game file is built, from the .dat on disk down to its entries, and how it is rebuilt. A .dat is obfuscated with RotXor; inside is a ZIP holding one member, usually a pakd archive of numbered entries. What's inside an entry (tables, text, …) has its own format doc: xlce for data tables, txte for text. Terms are as in the glossary.
| Based on | Gibbed's DataBogoCrypt.cs and BaseBogoCrypt.cs (reference_tools/Gibbed.LetUsClingTogether/projects/Gibbed.Reborn.FileFormats/Crypt/, the same in reference_tools/gibbed10/projects/Gibbed.Reborn.FileFormats/Crypt/), Gibbed.Reborn.DecryptData/Program.cs and Gibbed.Reborn.EncryptData/Program.cs; PackFile.cs (reference_tools/Gibbed.LetUsClingTogether/projects/Gibbed.TacticsOgre.FileFormats/, the same in reference_tools/gibbed10/); Gibbed.LetUsClingTogether.PackFileTable/Program.cs and Gibbed.LetUsClingTogether.UnpackFileTable/Program.cs; PKWARE's APPNOTE.TXT for the ZIP records; the 551 game files named under Method, all of them. |
| Checked | 2026-10-11 |
| Method | Layout copied from these sources and checked against them. How the game reads it: static analysis of the unpacked executable (SHA-256 7b0230a11498f40396c9048345e7cfe0ef442cb7ca8faffc6f197920d2669599) with Ghidra 12.1.4, via rea and the decompiled code in local/exe/decomp/; the R31 review of names (2026-10-11). Checked against the game data, 2026-10-11, over the 551 files the docs cover (battle/, localize/LocalizeText_en-us.dat, menu/menu_data.dat, event/chapter/, event/event_data.dat): the round trip, tor round-trip <those paths> --docs docs, rebuilt 551/551 files byte for byte (1,182/1,182 decoded entries, 551/551 payloads); the scan, a C# program over the same files (session scratchpad r22/scan.cs), tallied every ZIP record value and checked every pakd's layout (582 packs, 31 of them nested; 1,841 entries). Every file in Data/ (34,227), 2026-10-11 (R28): the survey, tor survey local/game/Data --docs docs, unwrapped every file as Files that are not RotXor + ZIP says (0 that can't be) and listed each ZIP member's name and payload's magics; the full round trip, tor round-trip local/game/Data --docs docs --quiet, rebuilt 34,224 of the 34,227 files byte for byte (the 3 others are in Rebuilding); the ZIP tally, a C# program (session scratchpad r28/ziptally.cs) over the ZIP records of all 10,016 RotXor + ZIP files. No in-game tests yet. |
Structure
A game file is a box inside a box: obfuscated, then zipped, around an archive of numbered entries. Each entry is one piece of data.
<name>.dat game file
└── RotXor obfuscation (Layers, 1)
└── ZIP, one member "pack" (Layers, 2–3)
└── pakd archive (pakd archives)
├── #0 an entry
├── #1 an entry
└── …
The tree is an illustration of the nesting, not of any one file. Not every file is built this way: some are wrapped otherwise, or not at all (Files that are not RotXor + ZIP), and some ZIP members are a single file of another format, not a pakd (Layers). An entry's first 4 bytes say what kind it is:
- likely: a table (
xlce): rows and columns, like a spreadsheet. Source: GibbedExportSheetreads thexlcmagic. - likely: a text table (
txte): rows of key → text. Source: GibbedEventTextFile. - likely: another archive: a nested pack, holding more entries. Source: Gibbed
PackFileTablewrites nested packs. - likely: graphics and animations:
btxtextures,ashgcompressed sprites and textures,SAS3animations; and others not yet understood, kept as bytes. Source: those docs. - confirmed: the top-level entry magics in all of
Data/arexlce,txte,pakd,btx,ashg,ARC,SAS3,sabf,IDX0,BMAR,PGRS,INVKandTASK, and entries with no known magic. Source: the survey (R28).
Layers
| Layer | What it is | Confidence | Source |
|---|---|---|---|
| 1. Obfuscation | RotXor, as in RotXor. No plaintext header. It doesn't change the length. | confirmed | The round trip (every file deobfuscated and obfuscated back byte for byte); the scan (each ZIP is as long as its file, 551/551). The game's own deobfuscation, 0x1401a2cf0 (How the game reads it). Gibbed DataBogoCrypt agrees (works in place, same length). |
| 2. ZIP | A ZIP archive holding a single member, compressed with DEFLATE (method 8), laid out as in ZIP layout. | confirmed | The scan: all 551 are exactly that, with nothing after the end record; the ZIP tally: every RotXor + ZIP file has one member, method 8. Gibbed UnpackFileTable agrees (reads a ZIP as exactly one member and fails on a second). |
| 3. Payload | The single member. When it is named pack or <name>.pack, it is a pakd; otherwise its name has a format extension and it is that format: .img and .btx a btx texture, .ash an ashg, .pac an ARC, .bin no known magic. |
confirmed | The survey of every RotXor + ZIP file (R28; again 2026-10-13, tor survey local/game/Data --docs docs): every member named pack or *.pack is a pakd; every *.img and *.btx, a btx; every *.ash, an ashg; the *.pac (Shaders/Shader.dat), an ARC; every *.bin, no known magic. |
RotXor
The obfuscation works on the whole file in place. All three steps, and the two keys, are confirmed (source: the round trip, through RotXor.FromDoc, which reads the keys from this doc; the game's 0x1401a2cf0, below; Gibbed DataBogoCrypt.Decrypt and Encrypt, BaseBogoCrypt, whose "BogoCrypt" this is, agree).
Deobfuscating, in order:
- Rotate left by 3 bits. The whole file is treated as one string of bits, most significant bit of byte 0 first, and rotated left by 3: byte
ibecomes(b[i] << 3) | (b[i+1] >> 5), and the last byte takes its low bits from the first byte (0x1401a2b70; GibbedDeshift). - XOR with key 1, start word 8. From byte 0, each whole word
g(0, 1, 2, …; 4 bytes) is XORed with word(8 + g) mod 15of key 1. Bytes after the last whole word are left alone. - XOR with key 2 from byte 16. From byte 16 on, each whole word
gis XORed with word(start + g) mod 17of key 2, where the start word isbyte[15] & 0x7F, read after step 2. Step 3 doesn't touch byte 15, so the start word comes from byte 15 of the plain file.
Obfuscating runs the steps in reverse (Gibbed Encrypt): step 3 with the start word from plain byte 15, then step 2, then rotate the whole file right by 3 bits (byte i becomes (b[i-1] << 5) | (b[i] >> 3), and byte 0 takes its high bits from the last byte; Gibbed Enshift).
Word g of the file is XORed with key word k = bytes 4k to 4k + 3 of the key, in order (Gibbed XorWithTable).
Key 1 (60 bytes, 15 words):
B0 BC 6D 19 E5 14 B6 EA F4 CB 16 B1 7D E5 7F B5
0B 7C 5E C4 2D 4F 31 99 67 98 19 E8 28 58 DE C9
C1 9B C9 83 34 C4 64 85 36 FA 0E DE FB E7 68 99
71 32 34 36 E5 01 7D 63 5C 81 77 7C
Key 2 (68 bytes, 17 words):
C6 53 98 ED D0 AA E0 1B 43 31 16 32 CE 0D 52 57
7B 72 0F FB B7 A9 C6 F1 F1 72 2C 89 5F B2 1B FA
A8 DB E3 E2 41 B5 BE C9 5D AE C6 21 E4 D2 88 74
97 26 4F 87 7A F0 69 7A B7 7E 15 09 BA 55 AD CA
B1 60 34 76
ZIP layout
The ZIP holds nothing beyond the one member: a local file header, the compressed data, one central directory record and the end of central directory record, in that order. The record layouts are the standard ones (APPNOTE.TXT, checked 2026-10-04). The values in Meaning are the ones in the game's files: the scan found each in all 551, and the round trip rebuilds every record byte for byte (it keeps the original's values, so the values come from the scan). confirmed: across all of Data/, every RotXor + ZIP file but one has exactly these values (the ZIP tally, R28). The one that doesn't, Shaders/Shader.dat (member sh_tacos.pac), was made by another tool: mod_time and mod_date set (0x644C, 0x54A9, 2022-05-09), version_made_by 0x003F (6.3), central flags 0x0000; its DEFLATE stream isn't zlib's (Rebuilding). n is the length of the member's name (4 for pack); csize is the compressed size.
The file in order:
| Offset | Size | Type | Name | Meaning | Confidence | Source |
|---|---|---|---|---|---|---|
| 0x0 | 30 + n |
— | local_header |
Local file header. | confirmed | The round trip; the scan. APPNOTE 4.3.7. |
30 + n |
csize |
raw[csize] |
compressed_data |
Raw DEFLATE stream of the member (Rebuilding). | confirmed | The round trip; the scan. APPNOTE 4.3.8; Gibbed UnpackFileTable and PackFileTable agree (DEFLATE). |
30 + n + csize |
46 + n |
— | central_directory |
One central directory record, for the member. | confirmed | The round trip; the scan. APPNOTE 4.3.12. |
| next | 22 | — | end_of_central_directory |
End of central directory record. No comment; ends the file. | confirmed | The round trip; the scan. APPNOTE 4.3.16. |
Local file header
Every value here is the one in all 551 files checked (the scan), and every record rebuilds byte for byte (the round trip).
| Offset | Size | Type | Name | Meaning | Confidence | Source |
|---|---|---|---|---|---|---|
| 0x00 | 4 | raw[4] | signature |
50 4B 03 04 (PK\3\4). |
confirmed | The scan. APPNOTE 4.3.7. |
| 0x04 | 2 | u16 | version_needed |
20. | confirmed | The scan. APPNOTE 4.3.7; Gibbed PackFileTable agrees (insists on version 20 for the ZIPs it writes). |
| 0x06 | 2 | u16 | flags |
0x0000. Bit 3 clear, so no data descriptor follows the data. |
confirmed | The scan. APPNOTE 4.3.7, 4.4.4. |
| 0x08 | 2 | u16 | method |
8 (DEFLATE). | confirmed | The scan. APPNOTE 4.4.5; Gibbed PackFileTable agrees (CompressionMethod.Deflated). |
| 0x0A | 2 | u16 | mod_time |
0. | confirmed | The scan. APPNOTE 4.3.7. |
| 0x0C | 2 | u16 | mod_date |
0. | confirmed | The scan. APPNOTE 4.3.7. |
| 0x0E | 4 | u32 | crc32 |
CRC-32 of the uncompressed member. | confirmed | The scan (checked against each inflated member). APPNOTE 4.3.7, 4.4.7. |
| 0x12 | 4 | u32 | compressed_size |
csize. |
confirmed | The scan. APPNOTE 4.3.7. |
| 0x16 | 4 | u32 | uncompressed_size |
Size of the member. | confirmed | The scan. APPNOTE 4.3.7. |
| 0x1A | 2 | u16 | name_length |
n. |
confirmed | The scan. APPNOTE 4.3.7. |
| 0x1C | 2 | u16 | extra_length |
0: no extra field. | confirmed | The scan. APPNOTE 4.3.7. |
| 0x1E | n |
raw[n] |
name |
The member's name: pack in every file checked. |
confirmed | The scan. APPNOTE 4.3.7. |
Central directory record
| Offset | Size | Type | Name | Meaning | Confidence | Source |
|---|---|---|---|---|---|---|
| 0x00 | 4 | raw[4] | signature |
50 4B 01 02 (PK\1\2). |
confirmed | The scan. APPNOTE 4.3.12. |
| 0x04 | 2 | u16 | version_made_by |
20 (0x0014), made on MS-DOS (upper byte 0). |
confirmed | The scan. APPNOTE 4.4.2. |
| 0x06 | 2 | u16 | version_needed |
20. | confirmed | The scan. APPNOTE 4.3.12; Gibbed PackFileTable agrees. |
| 0x08 | 2 | u16 | flags |
0x0800: bit 11, the name is UTF-8. Differs from the local header's flags. |
confirmed | The scan. APPNOTE 4.4.4. |
| 0x0A | 2 | u16 | method |
8 (DEFLATE). | confirmed | The scan. APPNOTE 4.4.5. |
| 0x0C | 2 | u16 | mod_time |
0. | confirmed | The scan. APPNOTE 4.3.12. |
| 0x0E | 2 | u16 | mod_date |
0. | confirmed | The scan. APPNOTE 4.3.12. |
| 0x10 | 4 | u32 | crc32 |
As in the local header. | confirmed | The scan. APPNOTE 4.3.12. |
| 0x14 | 4 | u32 | compressed_size |
csize. |
confirmed | The scan. APPNOTE 4.3.12. |
| 0x18 | 4 | u32 | uncompressed_size |
Size of the member. | confirmed | The scan. APPNOTE 4.3.12. |
| 0x1C | 2 | u16 | name_length |
n. |
confirmed | The scan. APPNOTE 4.3.12. |
| 0x1E | 2 | u16 | extra_length |
0. | confirmed | The scan. APPNOTE 4.3.12. |
| 0x20 | 2 | u16 | comment_length |
0. | confirmed | The scan. APPNOTE 4.3.12. |
| 0x22 | 2 | u16 | disk_number_start |
0. | confirmed | The scan. APPNOTE 4.3.12. |
| 0x24 | 2 | u16 | internal_attributes |
0. | confirmed | The scan. APPNOTE 4.3.12. |
| 0x26 | 4 | u32 | external_attributes |
0x20 (MS-DOS archive bit). |
confirmed | The scan. APPNOTE 4.3.12. |
| 0x2A | 4 | u32 | local_header_offset |
0. | confirmed | The scan. APPNOTE 4.3.12. |
| 0x2E | n |
raw[n] |
name |
As in the local header. | confirmed | The scan. APPNOTE 4.3.12. |
End of central directory record
| Offset | Size | Type | Name | Meaning | Confidence | Source |
|---|---|---|---|---|---|---|
| 0x00 | 4 | raw[4] | signature |
50 4B 05 06 (PK\5\6). |
confirmed | The scan. APPNOTE 4.3.16. |
| 0x04 | 2 | u16 | disk_number |
0. | confirmed | The scan. APPNOTE 4.3.16. |
| 0x06 | 2 | u16 | central_directory_disk |
0. | confirmed | The scan. APPNOTE 4.3.16. |
| 0x08 | 2 | u16 | members_on_disk |
1: the one member. | confirmed | The scan. APPNOTE 4.3.16 ("total number of entries … on this disk"); Gibbed UnpackFileTable agrees (one member). |
| 0x0A | 2 | u16 | members_total |
1. | confirmed | The scan. APPNOTE 4.3.16 ("total number of entries"); Gibbed UnpackFileTable agrees (one member). |
| 0x0C | 4 | u32 | central_directory_size |
46 + n. |
confirmed | The scan. APPNOTE 4.3.16. |
| 0x10 | 4 | u32 | central_directory_offset |
30 + n + csize. |
confirmed | The scan. APPNOTE 4.3.16. |
| 0x14 | 2 | u16 | comment_length |
0: no comment. | confirmed | The scan. APPNOTE 4.3.16. |
Files that are not RotXor + ZIP
Every other file in Data/ is RotXor + ZIP. The toolchain reads each file's wrapping from the Files and Wrapping columns of this table (Wrapping.FromDoc; globs matched case-insensitively against the path under Data/, * also matching /): MLZ0 → LZ4, XOR with key 1, or none, where the file is the payload.
confirmed: this table covers every file in Data/ that isn't RotXor + ZIP, and each wrapping is removed and put back byte for byte: every file below (the .sab and .efz files, the movies and the single files) rebuilds identically through the toolchain's Wrapping (MLZ0 packed again with LZ4's default compressor, our copy (../../toolchain/src/LZ4/UPSTREAM.md) of K4os.Compression.LZ4 1.3.8), and no file in Data/ fails to unwrap. Source: the full round trip and the survey (R28, 2026-10-11).
| Files | Raw magic | Wrapping | Notes | Confidence | Source |
|---|---|---|---|---|---|
FileTable.bin |
none (F1 1E A6 03) |
none | Not wrapped: the game's file table (filetable.md). | confirmed | Hex inspection; read as is by its loader 0x1402b1490 (filetable.md). |
effect/**/*.efz |
MLZ0 |
MLZ0 → LZ4 | LZ4: MLZ0, the unpacked size (u32), then one LZ4 block from byte 8 to the end of the file. The payload is an EFX0011 effect. Not RotXor. The game's effect loads read it this way (How the game reads it). |
confirmed | The round trip of effect/, 2026-10-13: tor round-trip local/game/Data/effect --docs docs --quiet, every file byte-identical (2,087/2,087): the unwrap decodes each block from byte 8 to the end of the file and refuses one that doesn't unpack to exactly the size at byte 4, and LZ4's default compressor gives back every .efz byte for byte (efx.md). Gibbed UnpackFileTable (MLZ0 → LZ4Codec.Decode); the game's unwrap kind 2 (0x1401a2a00). |
effect/global/common/indices.bin |
none (94 09 00 00) |
none | Not wrapped: a table, effect_indices: vfx ID → set of c_all.efz (Vfx). |
confirmed | Hex inspection; read as is by 0x140311b20. |
effect/EffectMaterialData.bin |
xlce |
none | A plain, unwrapped xlce table, effect_material_data, rows of 0x74 bytes (Vfx). |
confirmed | Hex inspection; read as is by 0x140311b20. |
sound/**/*.sab |
sabf |
none | A sead sound bank (sabf.md). The same magic is in pakd entries of sound/bin/SECommon.dat, title/title.dat and event/scene/*.dat. |
confirmed | The survey, 2026-10-13: tor survey local/game/Data/sound local/game/Data/title local/game/Data/event/scene --docs docs, every .sab file's payload is sabf, and sabf entries are in sound/bin/SECommon.dat, title/title.dat and event scene packs (sabf.md). |
sound/sead.config |
scnf |
none | The sound engine's settings (scnf.md). | confirmed | Hex; scnf.cs. |
movie/*.pmf, movie/*.PMF |
FF DB 0A 4A ... |
XOR with key 1 | Not RotXor + ZIP: an Ogg Theora file XORed word by word with RotXor's key 1, word w of the file with key word w mod 15 (pmf.md). |
confirmed | Exploration program pmfscan.cs over every movie file (every Ogg page CRC right); the game's read callback 0x14026c260 → 0x1402b0970 → 0x1401a2bf0. |
pakd archives
Offsets are from the start of the pakd (its magic). Checked in all 582 packs of the files checked (the scan), and rebuilt from these rules byte for byte in all 551 top-level packs (the round trip, through Pakd.Build); Gibbed's PackFile agrees throughout.
| Offset | Size | Type | Name | Meaning | Confidence | Source |
|---|---|---|---|---|---|---|
| 0x0 | 4 | raw[4] | magic |
pakd. Gibbed also accepts it byte-swapped, and then reads the pack as big-endian; no file checked is. |
confirmed | The scan. Gibbed PackFile (Signature). |
| 0x4 | 4 | u32 | count |
Number of entries. | confirmed | The scan; the round trip. Gibbed PackFile. |
| 0x8 | 4 × count |
u32[count] |
offsets |
Start of each entry, from the start of the pakd. |
confirmed | The scan; the round trip. 0x1402d4a30; Gibbed PackFile. |
0x8 + 4 × count |
4 | u32 | pack_size |
The pack's own size: the end of the last entry, from the start of the pakd. For a top-level pack it is the payload's size; for a nested pack it is 16 less than its entry's size (below). The game skips it. |
confirmed | The scan (551/551 top-level, 31/31 nested); the round trip writes it as the end of the last stored entry (Pakd.Build). ids readers start at 8 + 4 × (count + 1) (0x1402702a0, 0x1402bbcd0, 0x140350380). Gibbed PackFile (TotalSize). |
after pack_size |
4 × count |
u32[count] |
ids |
Optional ID table, one u32 per entry, present when there's room for it and unk_header_end: the first entry starts at or past 8 + 4 × (count + 1) + 4 × count + 4. In some of the packs checked some ID is non-zero. In most of the others there's room for the table before the first entry, but it's all zero, which can't be told from padding: the rebuild writes no table there and gives back the same bytes, so an all-zero table and no table are the same thing. Gibbed writes the table only when an ID is non-zero. What the game makes of an ID depends on the loader (Reading a pakd). |
confirmed | The scan; the round trip (Pakd.Ids, Pakd.Build). Gibbed PackFile (RawId, read as a DirectoryId and a FileId). |
| next | 4 | u32 | unk_header_end |
Always 0 (582/582 packs); no code reads it. Its presence shows in the data: battle_data_release.dat#0 has 25 entries, so 8 + 4 × 26 = 112 is already 16-aligned, yet its first entry is at 0x80. |
confirmed | The scan; the round trip (Pakd.Build). Gibbed PackFile agrees (zero; rejects a pack where it isn't 0). |
| next | varies | raw[n] | padding |
Zeros up to the first entry, at the next 16-byte boundary: the header is align16(8 + 4 × (count + 1) + 4) bytes, or align16(8 + 4 × (count + 1) + 4 × count + 4) with ids. |
confirmed | The scan (582/582 packs: first entry at that offset, padding all zero); the round trip. Gibbed PackFile.GetHeaderSize. |
How entries are stored:
- confirmed: entries follow back to back, each from its offset to the next one (the last to
pack_size), each at a 16-byte boundary. Source: the scan (all 1,841 entries); the round trip. GibbedPackFileTableagrees (writes entries back to back after the header). - confirmed: an entry's stored bytes are its own data, then zeros to a 16-byte boundary: at least 1 and at most 16 (
align16(size + 1)) in everyxlceandtxteentry checked. confirmed: anentry_unit_1entry whose rows end 8 bytes past a boundary has 24 instead, so which layer adds the zeros, the table's writer or the pack's, isn't one rule; the rebuild keeps such bytes as they are. Source: the round trip andr22/tables.cs(the decoded entries); the scan: all 1,810 entries that aren't packs are a multiple of 16 long and end with a zero byte. - confirmed: a nested pack's own size is a multiple of 16, and it is followed by exactly 16 zero bytes. Source: the scan (31/31). Gibbed
PackFileTableagrees (writes 16 zero bytes after each nested pack and records its entry size asTotalSize + 16).
How the game reads it
How the executable unwraps a file after reading it (filetable.md covers the read itself). Every fact here is static analysis of the unpacked executable (Ghidra 12.1.4, the decompiled code in local/exe/decomp/), so at most likely; the functions are in the names list.
- likely: almost every load asks for unwrap kind 1, RotXor and ZIP; two effect loads ask for kind 2,
MLZ0. Source: the last argument of every call to0x140295bc0and0x140295c90(kind 2 only in0x140311b20,effect/effectmanager.cpp, and0x140414930, the effect slot pool). - likely: for kind 1 the game deobfuscates the whole buffer in place with the three RotXor steps, over the length from the file table: rotate left by 3 bits, XOR with key 1 from start word 8, XOR from byte 16 with key 2 from start word
byte[15] & 0x7F. It does so for any length above zero; Gibbed's 16-byte minimum isn't the game's. Source:0x140295670→0x1401a2cf0→0x1401a2b70(rotate),0x1401a2bf0(key 1, at0x140ba5300),0x1401a2c70(key 2, at0x140ba5290). - confirmed: the two keys in the executable are byte for byte the keys in RotXor. Source: rea
read_bytesat0x140ba5300(60 bytes) and0x140ba5290(68 bytes), unpacked executable SHA-2567b0230a1…. - likely: key 1 isn't only for
.datfiles: the movie read callback (0x1402b0970, pmf.md), the shader loader (0x1401646f0, inlined) and the save I/O (0x140193ac0,0x140193c60, regionsaveio/bladesaveio.cpp) XOR with it too. Source: those functions. - likely: the game reads only the ZIP's local file header. It allocates the output from
uncompressed_size(0x16), finds the data at0x1E + name_length + extra_length, and, whenmethodisn't 0, inflatescompressed_size(0x12) bytes as raw DEFLATE with zlib 1.2.11 (window bits −15); withmethod0 it copies them, but then reports failure. It succeeds wheninflatereturnsZ_STREAM_ENDorZ_OK. Source:0x1401a29e0(size),0x1401a2a00(inflate:inflateInit2_0x140198ef0with −15 and"1.2.11",inflate0x140199170; the copy returns false), called from theFileSystem::Decompworker via0x140295550→0x1402c16c0. - likely: it never reads the signature,
version_needed,flags, the times,crc32or the name, nor the central directory or the end record. So no CRC is checked, and a rebuilt ZIP has to be valid only in its local header'smethod,compressed_size,uncompressed_size,name_lengthandextra_length, and in its DEFLATE stream; it doesn't have to be byte-identical. Source:0x1401a2a00reads no other field. - likely: for kind 2 the game reads the uncompressed size from byte 4, and decompresses
size − 8bytes from byte 8 withLZ4_decompress_safe; it doesn't check theMLZ0magic, and ignores the result. Source:0x1401a29e0,0x1401a2a00.
Reading a pakd
- likely: the game reads a
pakdin place, with no reader of its own: entryistarts at theoffsets[i]value from the start of the pack, and a loader that needs every entry loopscounttimes. Source:0x1402d4a30(battle_data_release.dat: fixed entries, each aspack + u32 at 0x8 + 4i, never readingcount);0x1402b9cd0(the text tables: every entry, up tocount). - likely: it doesn't check the
pakdmagic, nor readpack_size,unk_header_endor the padding, and doesn't check that an offset is in range. The one exception is the sprite register routine0x14026f4b0, which compares the first three bytes withp,a,k, one at a time, to tell a sprite pack from an image (ashg.md). Source: the two loaders above read nothing else from the header;0x14026f4b0. - likely: the
idstable is read in four places, each its own way.object/bundles: high 16 bits a resource kind (0 a unit sprite file, 1 a face), low 16 bits the ID the entry is cached under (0x1402efac0,0x14031eeb0,0x1402eef20, and0x140320ad0forbattle/entry/entry_unit_pack00.datandpack01.dat). Sprite packs: onlyids[0],0x1FF10000(one texture) or0x1FF10001(sheet, aura and mask) (0x1402702a0). Event scenes: an ID is a base per kind plus a number (0x11000faces,0x12000actors,0x50000unit sprite files,0x13000sound banks, …), searched by0x140350380.menu/menu_classface.dat: an entry is found by its ID (0x1402bbcd0). Details in ashg.md. Other loaders read so far ignoreids. - likely: an entry is found by its index alone, and what kind it is comes from the loader that asks for it:
0x1402b9cd0takes an entry as text only when it starts withtxt. Source:0x1402b9cd0(strncmp(entry, "txt", 3)). - likely:
bl::archiveis not thepakdreader: it reads the engine'sARCarchives (named, typed records, found by type, ID, index or name; content optionallycmp-compressed with zlib or LZMA), which sit insidepakdentries inmenu/and as the ZIP member ofShaders/Shader.dat.0x140037f40belongs to a third format, the chunk chains insideBMARmodels. Source: arc.md.
So a rebuilt pakd needs correct count, offsets and, where it has them, ids; the rest of the header is kept for a byte-identical rebuild, not because the game needs it.
Rebuilding
How a payload is wrapped back into a .dat. Each layer is rebuilt as laid out above; the table gives what the rebuild has to work out. Every rule is proven by the round trip over the 551 files checked (see Method); whether the game loads a rebuilt file is not (below).
| Layer | Rule | Confidence | Source |
|---|---|---|---|
| Entries | The format module that encodes an entry pads it as above: align16(size + 1) when the original's bytes after its data were exactly that, otherwise those bytes kept as they are. |
confirmed | The round trip: 1,182/1,182 decoded entries byte-identical. |
pakd |
Offsets and pack_size worked out from the entries; ids kept as is when the original had a non-zero one, and no table written otherwise. Nested packs are rebuilt first, bottom up, then padded with 16 zeros. |
confirmed | The round trip: 551/551 payloads byte-identical. Gibbed PackFileTable agrees (offsets and TotalSize from the entries, nested packs closed before their parent, 16 bytes of padding after each). |
| ZIP | As in ZIP layout, with CRC and sizes worked out. Name, flags and attributes come from the original's records when given; without one, the defaults are the values in those tables, which are every file's values. The game reads only the local header's method, sizes and name and extra lengths (How the game reads it), so the rest is kept for a byte-identical rebuild, not because the game needs it. | confirmed | The round trip; the scan (the defaults). What the game reads: 0x1401a2a00. |
| DEFLATE | Raw DEFLATE (no zlib header) made by zlib's algorithm at level 9, memLevel 8, window 15, default strategy, through our own copy of ZLibDotNet. It reproduces the original stream of every RotXor + ZIP file but one. The other, Shaders/Shader.dat, was made by another tool (ZIP layout): no zlib setting gives back its stream (every level, memLevel, window and strategy tried). So when a payload is unchanged the rebuild keeps the original's stream as is, without compressing (since 2026-10-14, R32: compressing every unchanged payload again took 74% of an export); tor compression is what checks our compressor gives the original streams back. An edited payload is always compressed as above; the game needs only a valid stream (How the game reads it). |
confirmed | The full round trip, 2026-10-11 (R28): every RotXor + ZIP file whose payload rebuilt is byte-identical; Shaders/Shader.dat only through its kept stream. tor compression local/game/Data, 2026-10-11: every stream but Shaders/Shader.dat's (27,999 bytes against its 27,414). The zlib settings: session scratchpad r28/zparams.cs. Gibbed PackFileTable also compresses at level 9. |
| RotXor | Obfuscation, the deobfuscation steps in reverse. | confirmed | The round trip. Gibbed DataBogoCrypt.Encrypt and Gibbed.Reborn.EncryptData agree. |
| Whole file | ZIP the payload, then obfuscate with RotXor; or the file's other wrapping (Files that are not RotXor + ZIP). With the original's ZIP record fields kept, this gives back every file in Data/ whose payload rebuilds, byte for byte. |
confirmed | The full round trip, 2026-10-11 (R28): 34,224 of 34,227 files byte-identical; the other 3 are LocalizeText_de-de, _es-es and _fr-fr, whose text entries don't yet encode back (R19). |
Not yet tested: whether the game loads a rebuilt file, which also needs a matching FileTable.bin. Static analysis says it should, as long as the local header and the DEFLATE stream are valid (How the game reads it).
Leads
- What the
pakdidsvalues mean in files whose loaders haven't been read yet (Reading apakdlists the four readings known). - Confirm in game (R14) that a rebuilt
.datwith a different but valid ZIP (other times, a zero CRC) loads, as How the game reads it predicts. 0x1402b9cd0: these docs read it as the loader of the text tables, but the names list calls itText_UnindexTables(txte.md says it undoes the loading on unload); settle which it is.regions.jsonllabels0x1401a1940–0x1401a2d3f(the RotXor and unwrap functions) "Mx::Graphics", an inferred label that doesn't fit.- legacy/formats/containers.md (
../../legacy/formats/containers.md) has earlier observations to recheck (reference only).