Skip to content

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.

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
Source
ECMA-119 (1987), Volume and File Structure of CDROM for Information Interchange

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 + 16
next_header = header_start + bytes[12:16] // Only if bytes[12:16] != 0
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)
stop when bytes_written == decompressed_size
or when a decoded reference token has offset == 0 # explicit end-of-stream sentinel

The 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]

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.

Direct copy. The SLZ header is simply a copy passthrough:

output = input

Standard LZSS.

length = ((RB1 >> 4) & 0x0F) + 3
offset = ((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

Same token layout as Mode 1, but the reference token is reinterpreted as RLE once RB1 >= 0xF0:

is_rle = RB1 >= 0xF0
length = ((RB1 >> 4) & 0x0F) + 3
offset = ((RB1 & 0x0F) << 8) | RB0
Property Value
Window size 4096 bytes
Max match 17 bytes, to avoid collision with RLE trigger
Min length 3 bytes
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

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) * 2
offset = (((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 7D

Running offset, closed-form (i = 0-indexed byte position in the buffer):

m(i) = (3 * (i + 1)) & 0xFF

Unscramble (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)) & 0xFF
Source

CBios, 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.”

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

Bytes 4-7, extracted from the little-endian control word W:

num_entries = W & 0xFFFF
shift = (W >> 16) & 0x0F
stride = (W >> 20) & 0x03
alias_table = (W >> 29) & 0x01
datacenter = (W >> 30) & 0x01
runtime_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.

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.

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) << shift
table_count = 2 if alias_table else 1
payload_offset = 0 if datacenter else header_size + (num_entries * stride_bytes * table_count) # header_size = 8

payload_offset marks where entry offset_table(i) == 0 lands: the very start of the payload, before any shift is applied.

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

All lookups ultimately walk the offset table by index and compute a segment as above. What differs is how that index is obtained.

The caller already has the index, the index is built-in at a higher level:

offset = offset_table[i]

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.

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 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 IndexHeader IDCategory
1765.29Sound
1775.28Sound
1785.27Sound
1795.26Sound
1805.30Sound
1855.7Script
1865.0Script
1875.9Script
1885.31Sound
1895.11Texture
1905.12Texture
1915.13Texture
1925.14Texture
1935.15Texture
1945.16Texture
1955.17Texture
1965.18Texture
1975.19Texture
1985.20Texture
1995.21Texture
2005.22Texture
2015.23Texture
2035.10Paf
2045.1Message
2065.25Script

Entity Packs

One payload to 10 headers found in:

TOC RangeHeader IDsCategory
1206–15115.2.0Characters
1511–16825.3.0Enemies
1688–19395.4.0Equipment
1939–21265.5.0Weapons
2126–24265.6.0Map Assets

The header is resolved from this formula:

i = tocIndex - category.start
hid_base = category.hidPrefix + (i * 10) # this entry's header 0

Here 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) * 10
TOC[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 data

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 A
also fine: A B B B B B B B B B slot 0 doesn't share segment A
broken: A A B A A A A A A B
Source

Rel2AbsKodsAddr, GetKodsDataOffset, GetKodsIndexFromAlias, GetKodsDataAlias, GetKodsAddress, CRadiDatacenter