SiN PAK format

A reference for the SPAK archive format used by SiN (Ritual Entertainment, 1998) and its mission pack Wages of Sin (2WS).

SiN’s pak format is a direct widening of the Quake / Q2 PAK layout documented in quake-pak-format.md. Every structural rule from that doc (12-byte header, on-disk LE u32s, unordered directory, no compression, no checksums, NUL-padded filenames without a required terminator, orphan-bytes tolerance, empty-archive handling, the pakka_read_u32_le / pakka_write_u32_le contract) applies unchanged. This doc covers only the differences.

1. Sources

1.1 Primary references

1.2 Additional references

The layout is consistent across every shipping archive and the three values in the geometry row are the entire spec.

1.3 Preservation

Every live external reference in §1.1 and §1.2 was submitted to the Wayback Machine on 2026-05-24. To fetch a snapshot of any link above, prepend https://web.archive.org/web/ to its URL. Sources that carry their own archival guarantee (GitHub source files, Internet Archive item pages) are excluded from the submission set.

2. Differences from Quake / Q2 PAK

Three values change vs the 56/64 row in quake-pak-format.md §2:

Field Quake / Q2 / GoldSrc SiN
Signature magic "PACK" "SPAK"
Filename field 56 bytes 120 bytes
Directory entry 64 bytes total 128 bytes total

Everything else is identical:

2.1 Header — 12 bytes

Offset Size Field Notes
0 4 signature "SPAK" (no NUL).
4 4 diroffset u32 LE — byte offset of the directory block.
8 4 dirlength u32 LE — directory size in bytes (128 × N).

dirlength divides by 128, not 64. Entry count is dirlength / 128.

2.2 Directory entry — 128 bytes

Offset Size Field Notes
0 120 filename NUL-padded path; forward-slash separators.
120 4 file_pos u32 LE — offset of payload bytes in the archive.
124 4 file_length u32 LE — payload size in bytes.

The 64-byte widening compared to Quake is entirely in the filename field: SiN allocates 120 bytes where Quake allocates 56. The NUL-pad convention and “may run the full 120 bytes with no terminator” caveat from the Quake doc apply identically — the in-memory buffer is sized one byte larger so a '\0' can be force-written at index 120 before any C string API touches it.

The wider name and the higher entry-count cap (see §2.3) landed late in SiN’s development — both bumped on the same day, October 8, 1998, per the engine source’s revision history, about a month before SiN shipped on November 9, 1998. That timing suggests Ritual hit Q2’s MAX_PAK_FILENAME_LENGTH (56) and MAX_FILES_IN_PACK (4,096) ceilings during final asset assembly rather than designing the divergence up front. The widened name in particular ended up at exactly 120 bytes — enough headroom for the deepest paths the shipping corpus needed (SiN content paths like pics/skins/aphrodite_redteam.tga already crowd Quake’s 56-byte limit) without much slack to spare.

2.3 Engine-level loading constraints

These are properties of the shipping SiN engine’s loader, not of the SPAK container. Archives that violate them remain format-conformant; the engine just refuses to mount them.

Entry count cap. Ritual’s loader caps numpackfiles at 16,384 (MAX_FILES_IN_PACK in the engine’s q_files.h) — four times Q2’s 4,096. Pakka’s own PAKFILE_MAX_ENTRIES is 1,048,576, so pakka will write archives that exceed the engine’s cap; shipping pak0.sin / pak1.sin are well under it.

Mac port shipped .rez, not .sin. The Mac SiN port (Rebecca Heineman’s Burgerlib build, ca. 2002) replaced the SPAK loader with Burgerlib’s RezFile resource container and shipped pak0.rez / pak1.rez instead. The on-disk SPAK layout in §2 is preserved as dead code inside that port’s filesystem source but is not the active read path. PC SiN reads .sin files using the layout documented here; pakka models only the PC path.

Demo anti-modification check. The SiN shareware demo’s loader (compiled with a NO_ADDONS flag that retail builds disable) computes a checksum over the directory block — dirlen bytes starting at diroffset, payloads excluded — and refuses the archive if the result diverges from a hardcoded constant. Effect: any rebuilt demo pak0.sin fails to mount on the demo even when its payload bytes are byte-identical to the original, because the rebuilt directory’s exact byte layout (entry order, slack, padding) rarely reproduces the original’s. Retail SiN omits the check entirely and accepts any format-valid pak.

3. Disambiguation

Unlike Daikatana, SiN’s "SPAK" magic is unique within the PAK-class family and never collides with another row. pakka’s pakka_open dispatches "SPAK" directly to the SiN geometry without running a layout probe; --format sin / PAKKA_FORMAT_SIN skip the probe explicitly. The open-time signature check rejects a mismatched hint (--format pak on a "SPAK" archive, or vice versa) with PAKKA_ERR_INVALID_ARGUMENT.

4. I/O integration in pakka

The geometry table in src/common.c is the only place SiN-specific constants live:

                   signature  name_field_len  dir_entry_size  has_compression
SiN                 "SPAK"           120              128             no

Every PAK-class read/write site — load_directory, pakka_add_file / _memory’s name-cap check, write_pak_entry, write_pak_directory, init_pak_header — consults pakka_pak_geometry(fmt) and uses the returned row’s values. No SiN code path is forked; the same loop that reads a Quake entry reads a SiN entry, just with geom->name_field_len = 120 and geom->dir_entry_size = 128.

The write_pak_entry scratch buffer is sized to PAKKA_ENTRY_NAME_SIZE (256 bytes), which accommodates SiN’s 120 without dynamic stack allocation. This is the reason PAKKA_ENTRY_NAME_SIZE exists as a separate constant from PAKFILE_PATH_BUF (57 bytes for the Quake row).

pakka_create(path, PAKKA_FORMAT_SIN, ...) stamps a 12-byte SPAK header with dirlength = 0; subsequent adds and the commit path behave identically to PAK (see quake-pak-format.md §4.2). The CLI’s extension sniffer maps .sin to PAKKA_FORMAT_SIN; --format sin overrides the extension.

Name validation on add caps entry names at 119 bytes (one less than the on-disk field width, so the field always NUL-terminates), uses the same pakka_unsafe_entry_name traversal check as PAK, and reads through the same pakka_normalize_entry_name for the verify-side collision check.

5. Cross-format notes

The PAK-class family pakka enumerates side by side:

                   signature  name_field_len  dir_entry_size  has_compression
Quake / Q2 / HL     "PACK"            56               64             no
SiN                 "SPAK"           120              128             no
Daikatana           "PACK"            56               72            yes

See quake-pak-format.md §6 for the table’s broader context, and daikatana-pak-format.md for the third row.

6. Test coverage