Core Container Formats
This documentation covers the container formats used by the Radiata Stories Filesystem (FS). The goal is to provide enough information for developers to recreate the parsing used by the RadiataModdingTool.
This section is not a complete breakdown of the PlayStation 2 ISO layout. Rather, it covers the basics of ISO 9660 record layout so you could implement your own parser.
The ISO 9660 records start in sector 16: (16 * 0x800) + 0x9C. This section of the disk can be parsed by most disk parsing software, including certain default drivers.
Record Layout
Section titled “Record Layout”| Bytes | Description |
|---|---|
| 0 | Length of the Record |
| 1 | Extended Attribute Record Length |
| 2-9 | LBA (both-endian) |
| 10-17 | Data Size (both-endian) |
| 18-24 | Date and Time |
| 25 | Flags |
| 26 | File Unit Size |
| 27 | Interleave Gap size |
| 28-31 | Volume Sequence Number (both-endian) |
| 32 | Length of the File Identifier |
| 33+ | File Identifier |
Compression
Section titled “Compression”This covers the SLZ compressed format and the SLE scramble layer that’s sometimes applied on top of it. You will need to be familiar with Lempel–Ziv–Storer–Szymanski (LZSS) and Run-length Encoding (RLE) to get everything out of this section.
Reference token bytes are referred to as RB0, RB1, and RB2.
The header is 16 bytes, followed by the payload:
payload_start = header_start + 16next_header = header_start + bytes[12:16] // Only if bytes[12:16] != 0Header Layout
Section titled “Header Layout”| Bytes | Description |
|---|---|
| 0 | S |
| 1 | L |
| 2 | Z / E, selects for encryption (see SLE) |
| 3 | Mode |
| 4-7 | Length Compressed Payload |
| 8-11 | Length Decompressed Payload |
| 12-15 | Offset to Next Compressed Header (0 = no subsequent header) |
Termination
Section titled “Termination”stop when bytes_written == decompressed_size or when a decoded reference token has offset == 0 # explicit end-of-stream sentinelThe reference encoder always emits the sentinel at the end of a stream (a zeroed flag bit/word followed by a zeroed 2-byte token), so offset == 0 should be treated as “stop,” not as a copy instruction.
Out-of-window back-references resolve to 0x00, the ring buffer is zero-initialized. This applies to every LZSS mode:
byte = (src_index < 0) ? 0x00 : window[src_index]Flag Bits
Section titled “Flag Bits”Every mode groups tokens under a flag byte (modes 1-2) or flag word (mode 3) that precedes them. Flag tokens are consumed LSB first:
is_literal(n) = ((flag >> n) & 1) == 1(flag >> n) & 1 |
Token type | Size |
|---|---|---|
0 |
Reference / back-reference | 2 or 3 bytes, depending on mode/RLE case |
1 |
Literal | 1 or 2 bytes, depending on mode |
If the input runs out mid-flag-group, decoding simply stops (see Termination). There’s no padding requirement to fill out a partial flag group.
0: STORE
Section titled “0: STORE”Direct copy. The SLZ header is simply a copy passthrough:
output = input1: LZSS-8bit
Section titled “1: LZSS-8bit”Standard LZSS.
length = ((RB1 >> 4) & 0x0F) + 3offset = ((RB1 & 0x0F) << 8) | RB0| Property | Value |
|---|---|
| Flagbits | 8-bit |
| Literal size | 1 byte |
| Window size | 4096 bytes |
| Max match | 18 bytes (length when nibble = 15) |
| Min length | 3 bytes (length when nibble = 0) |
| Filler | 0x00 |
2: LZSS-8bit + RLE
Section titled “2: LZSS-8bit + RLE”Same token layout as Mode 1, but the reference token is reinterpreted as RLE once RB1 >= 0xF0:
is_rle = RB1 >= 0xF0Non-RLE case identical to Mode 1:
Section titled “Non-RLE case identical to Mode 1:”length = ((RB1 >> 4) & 0x0F) + 3offset = ((RB1 & 0x0F) << 8) | RB0| Property | Value |
|---|---|
| Window size | 4096 bytes |
| Max match | 17 bytes, to avoid collision with RLE trigger |
| Min length | 3 bytes |
RLE case triggered when RB1 >= 0xF0:
Section titled “RLE case triggered when RB1 >= 0xF0:”| Case | Condition | Length | Fill byte |
|---|---|---|---|
| Short fill | 0xF1 <= RB1 <= 0xFF |
(RB1 & 0x0F) + 3 → range 4-18 |
RB0 |
| Long fill | RB1 == 0xF0 |
RB0 + 0x13 → range 19-274 |
RB2 |
3: LZSS-16bit
Section titled “3: LZSS-16bit”Standard LZSS operating on 2-byte units — length and offset are stored at half their real value and doubled on decode:
length = (((RB1 >> 4) & 0x0F) + 2) * 2offset = (((RB1 & 0x0F) << 8) | RB0) * 2| Property | Value |
|---|---|
| Flagbits | 16-bit |
| Literal size | 2 bytes |
| Window size | 8192 bytes |
| Max match | 34 bytes (length when nibble = 15) |
| Min length | 4 bytes (length when nibble = 0) |
| Filler | 0x00 |
SLE is a separate, optional scramble layer applied to an SLZ payload’s compressed bytes. It’s a byte-wise subtractive-XOR cipher against a fixed 16-byte key.
Key (K, 16 bytes):
K = 66 66 54 42 B3 79 F0 C7 E7 D5 1E 4B 7B A4 1C 7DRunning offset, closed-form (i = 0-indexed byte position in the buffer):
m(i) = (3 * (i + 1)) & 0xFFUnscramble (decrypt) — C = scrambled input, D = descrambled output:
D[i] = ((C[i] - m(i)) & 0xFF) XOR K[i & 0xF]Scramble (encrypt) — the exact inverse:
C[i] = ((D[i] XOR K[i & 0xF]) + m(i)) & 0xFFCBios,
CDecompress,
CSplitDecompress,
DecodeKeyMain
Kods doesn’t store file sizes. It stores a table of boundaries (offsets). A segment of payload data is whatever lies between two of them. Parsing Kods is less “read a table of files” and more “read a table of cut points, then read off what’s between subsequent pairs of them.”
Header Layout
Section titled “Header Layout”| Byte Range | Content | Description |
|---|---|---|
| 0-3 | Magic Number | "Kods" (b'\x4B\x6F\x64\x73', or 0x73646F4B as word LE) |
| 4-7 | Control Word | See Control Word |
| 8- | Offset Table | See Offset Table |
| post offset table - pre payload | Alias Table | See Alias Table |
Control Word
Section titled “Control Word”Bytes 4-7, extracted from the little-endian control word W:
num_entries = W & 0xFFFFshift = (W >> 16) & 0x0Fstride = (W >> 20) & 0x03alias_table = (W >> 29) & 0x01datacenter = (W >> 30) & 0x01runtime_lock = (W >> 31) & 0x01
stride_bytes = 2 if stride else 4 # size (bytes) of one table entry| Bits | Field | Notes |
|---|---|---|
| 0-15 | num_entries |
|
| 16-19 | shift |
Range 0-15. |
| 20-21 | stride |
0 = 4-byte (u32) offset entries, 1 = 2-byte (u16) offset entries (No observed values > 2) |
| 22-28 | — | Unused |
| 29 | alias_table |
1 = a second (ID) table of identical size following the offset table |
| 30 | datacenter |
Set only on datacenter-style headers. Not used at runtime, either deprecated or used only for manual confirmation. |
| 31 | runtime_lock |
Set when the container has swapped from relative payload offsets to absolute RAM offsets. Can be safely ignored for static analysis. |
Offset Table
Section titled “Offset Table”num_entries fixed-width raw values (stride_bytes each) follow the magic and control word. Each raw value resolves to an actual byte offset, a “cut point” that a segment boundary sits at.
Offset Calculation
Section titled “Offset Calculation”sentinel = 0xFFFF if stride else 0xFFFFFFFF
offset(i) = if offset_table(i) == sentinel: INVALID else if offset_table(i) == 0: 0 else: offset_table(i) << shiftTable Size
Section titled “Table Size”table_count = 2 if alias_table else 1payload_offset = 0 if datacenter else header_size + (num_entries * stride_bytes * table_count) # header_size = 8payload_offset marks where entry offset_table(i) == 0 lands: the very start of the payload, before any shift is applied.
Segments
Section titled “Segments”A segment is defined by an adjacent pair of table entries: start-end boundaries. A slot is invalid only when: offset_table(i) | offset_table(i+1) == sentinel. A repeated offset isn’t invalid.
size(i) = offset(i+1) - offset(i) = len(payload) - offset(i) # if no such offset(i+1) exists| Type | Condition | Segment size |
|---|---|---|
| Valid | offset_table(i) != sentinel |
size(i) as above |
| Sentinel | offset_table(i) == sentinel |
size(i) = INVALID not a boundary at all |
Lookup
Section titled “Lookup”All lookups ultimately walk the offset table by index and compute a segment as above. What differs is how that index is obtained.
Direct index
Section titled “Direct index”The caller already has the index, the index is built-in at a higher level:
offset = offset_table[i]Alias-based
Section titled “Alias-based”when alias_table == 1, the caller instead has an Alias ID, and the index is found by scanning the alias table for a match:
i = first index such that (alias_table[i] & sentinel) == (Alias & sentinel)offset = offset_table[i]Either way, once an index is in hand, segment size is computed identically. The alias table only changes how you find the index, not what you do with it.
Alias Table
Section titled “Alias Table”When alias_table == 1 in the control word, a block of num_entries * stride_bytes bytes (same size and stride as the offset table) immediately follows the offset table.
Datacenter Headers
Section titled “Datacenter Headers”Datacenter headers are detached Kods headers. Triggered by datacenter == 1. Because there is no header before the payload, payload_offset = 0. TOC[5] is where the datacenter headers live on disk. Datacenter headers don’t resolve segments from relative offsets, they resolve from RAM offsets (see Segment Sharing).
Two header resolution schemes exist, by category:
| Category | Depth | Headers per payload |
|---|---|---|
| Asset Banks | Single | 1 |
| Entity Packs | Nested | 10 |
Asset Banks
One payload to one header found at hierarchical ID:
| TOC Index | Header ID | Category |
|---|---|---|
| 176 | 5.29 | Sound |
| 177 | 5.28 | Sound |
| 178 | 5.27 | Sound |
| 179 | 5.26 | Sound |
| 180 | 5.30 | Sound |
| 185 | 5.7 | Script |
| 186 | 5.0 | Script |
| 187 | 5.9 | Script |
| 188 | 5.31 | Sound |
| 189 | 5.11 | Texture |
| 190 | 5.12 | Texture |
| 191 | 5.13 | Texture |
| 192 | 5.14 | Texture |
| 193 | 5.15 | Texture |
| 194 | 5.16 | Texture |
| 195 | 5.17 | Texture |
| 196 | 5.18 | Texture |
| 197 | 5.19 | Texture |
| 198 | 5.20 | Texture |
| 199 | 5.21 | Texture |
| 200 | 5.22 | Texture |
| 201 | 5.23 | Texture |
| 203 | 5.10 | Paf |
| 204 | 5.1 | Message |
| 206 | 5.25 | Script |
Entity Packs
One payload to 10 headers found in:
| TOC Range | Header IDs | Category |
|---|---|---|
| 1206–1511 | 5.2.0 | Characters |
| 1511–1682 | 5.3.0 | Enemies |
| 1688–1939 | 5.4.0 | Equipment |
| 1939–2126 | 5.5.0 | Weapons |
| 2126–2426 | 5.6.0 | Map Assets |
Entity Pack Resolution
Section titled “Entity Pack Resolution”The header is resolved from this formula:
i = tocIndex - category.starthid_base = category.hidPrefix + (i * 10) # this entry's header 0Here is an example for resolving ‘Valkyrie’ (TOC[1444]) into its data types, keep in mind that header 0 splits the payload into 9 slots:
hid_base = (1444 - 1206) * 10TOC[5.2.0.hid_base] + TOC[1444]├── TOC[5.2.0.hid_base + 1] + slot 0 -> Model data├── TOC[5.2.0.hid_base + 2] + slot 1 -> Basic Animation data├── TOC[5.2.0.hid_base + 3] + slot 2 -> Secondary Basic Animation data├── TOC[5.2.0.hid_base + 4] + slot 3 -> Battle data├── TOC[5.2.0.hid_base + 5] + slot 4 -> Secondary Battle data├── TOC[5.2.0.hid_base + 6] + slot 5 -> Script Animation data├── TOC[5.2.0.hid_base + 7] + slot 6 -> Animation data├── TOC[5.2.0.hid_base + 8] + slot 7 -> Script Data└── TOC[5.2.0.hid_base + 9] + slot 8 -> NPC Battle dataSegment Sharing
Section titled “Segment Sharing”Because the offsets are resolved to RAM offsets, multiple slots can share the same segment. Here is a single segment example, where A < B:
fine: A A A A A A A A A B slots 0-8 share segment Aalso fine: A B B B B B B B B B slot 0 doesn't share segment Abroken: A A B A A A A A A BRel2AbsKodsAddr,
GetKodsDataOffset,
GetKodsIndexFromAlias,
GetKodsDataAlias,
GetKodsAddress,
CRadiDatacenter