burkey.co /docs/spitwad/wad-format
index wad-format
~/docs/spitwad/wad-format.md
spitwad

WAD Format

The on-disk layouts spitwad reads and writes. A WAD is a header, lump data, and a directory with one entry per lump. For the classic types the writer places lump data directly after the header and the directory after the data. For EWAD the directory and string table follow the header and aligned lump data comes last. The reader accepts any arrangement that passes the checks below. All integers are little-endian.

TypeSignatureHeaderEntryName bytes
IWADIWAD12168
PWADPWAD12168
PACKPACK12168
WAD2WAD2123215
WAD3WAD3123215
EWADEWAD64561 to 255, in a string table

PACK uses the classic layout here. It is not the real Quake PAK layout, so real PAK files are misread.

Classic#

Used by IWAD, PWAD, and PACK.

Header, 12 bytes:

OffsetTypeField
04 bytesSignature
4u32Lump count
8u32Directory offset

Directory entry, 16 bytes:

OffsetTypeField
0u32Lump offset
4u32Lump size
88 bytesName, zero-padded. Not terminated when all 8 bytes are used

WAD2 and WAD3#

The header is the classic 12 bytes. Directory entry, 32 bytes:

OffsetTypeField
0u32Lump offset
4u32Size on disk
8u32Uncompressed size
12u8Lump type, kept in Lump.kind
13u8Compression, 0 for none
142 bytesPadding
1616 bytesName, NUL-terminated

spitwad does not implement compression. A nonzero compression byte, or sizes that disagree, fails with SPITWAD_ERR_UNSUPPORTED. The name is limited to 15 bytes so that the terminator always fits, and bytes after the first NUL are ignored on load. The writer stores 0 for compression and padding, and writes the lump size to both size fields.

EWAD#

A pack format for game engines: 64-bit offsets and sizes, a directory sorted by a seeded 64-bit hash of each name so that lookup is a binary search, lump data aligned for memory mapping, a header hash over all metadata, a content hash per lump, a codec flag, and names kept in a separate string table that shipping builds can strip.

Header, 64 bytes:

OffsetTypeField
04 bytesSignature EWAD
4u32Format version, 2
8u32Lump count
12u32Alignment, a power of two from 1 to 2^30
16u64Hash seed
24u64Directory offset
32u64String table offset
40u64String table size, 0 when names are stripped
48u64Header hash
568 bytesReserved, zero

Directory entry, 56 bytes:

OffsetTypeField
0u64Key: XXH64 of the name bytes, without the NUL, under the header seed
8u64Lump offset, a multiple of the alignment
16u64Stored size
24u64Raw size. Equals stored size unless the LZ4 flag is set
32u64Content hash: XXH64 of the stored bytes with seed 0
40u32Type, a fourcc chosen by the application. 0 when unused
44u32Name offset into the string table, or 0xFFFFFFFF when names are stripped
48u16Schema version, chosen by the application
50u16Flags
52u32Reserved, zero

Entries are sorted by key, ascending, and keys are unique. In an archive with names, every key must equal the hash of its own name, so no two entries can share a name. The content hash of an empty lump is 0xEF46DB3751D8E999.

Header hash. XXH64 with seed 0 over, in this order wherever they sit in the file: the 64-byte header with the hash field treated as zero, the whole directory, then the whole string table (nothing when names are stripped). It covers every key and content hash, so it changes when any lump's bytes change, and readers use it as the archive's identity (WAD_View.archive_hash). It reads no lump data, so an archive can be validated when memory-mapped. It catches corruption, not tampering.

Version 2 is the only version. A file with any other version fails with SPITWAD_ERR_MALFORMED, and there is no reader for version 1.

String table. NUL-terminated names, back to back. A name offset is relative to the start of the table, and the name's NUL must fall inside the table. Names are 1 to 255 bytes and may hold any byte except NUL. By convention they are paths with forward slashes, no leading slash, and case-sensitive, but spitwad enforces none of that. A stripped archive has a string table size of 0 and 0xFFFFFFFF in every name offset. Lookups still work, since they hash the name under the seed.

Flags. Bit 0 is EWAD_FLAG_LZ4: the stored bytes are an LZ4 block produced by the application and raw size is their decompressed size. spitwad never compresses or decompresses. All other bits are reserved, and a set reserved bit fails with SPITWAD_ERR_UNSUPPORTED. Without the LZ4 flag, raw size must equal stored size.

Alignment. Every lump offset, including an empty lump's, is a multiple of the header alignment. The writer defaults to 16. Offsets are file-relative, so a file mapped at a page boundary yields pointers with the same alignment.

Seed. The key hash is seeded per archive so that two names which collide under one seed can be told apart under another. The writer starts at seed 0, or a chosen value, and if two different names share a key it increments the seed and rebuilds, up to 64 times. Two archives may therefore use different seeds, so a lookup across several archives hashes the name once per archive.

Writer layout. Header at 0, directory at 64, string table directly after the directory, then each lump in directory order at the next multiple of the alignment. Padding and reserved bytes are zero. Reserved bytes are validated, but padding belongs to no field and is covered by no hash, so it is never read. Identical lumps produce identical bytes whatever order they were added in.

Validation#

Every type must pass these checks on load. Failures return SPITWAD_ERR_MALFORMED, and the WAD is left unchanged.

The input must be at least as long as its header, and start with a known signature. The directory and every lump range must fit the input. A non-empty directory, and non-empty lump data, may not overlap the header. Empty lumps may carry any in-range offset, including 0, which is common for map markers.

Lumps may overlap each other, since some tools share identical data between entries. Each lump is copied on load, so the sum of all lump sizes may not exceed 16 times the input size. For the classic types that sum is also capped at UINT32_MAX. The lump table itself costs about 290 bytes per entry on top of that, which is at most 18 times the directory's size.

An EWAD must also have version 2, a power-of-two alignment, zero reserved bytes, a directory and string table that start at or after byte 64, a header hash that matches (checked after the directory and string table ranges and before any entry), strictly ascending keys, lump offsets that are multiples of the alignment, name offsets that land on a 1 to 255 byte NUL-terminated name inside the table whose seeded hash is the entry's key (or the no-name marker in every entry when the table is empty), and raw size equal to stored size for every lump without the LZ4 flag. A set reserved flag bit is SPITWAD_ERR_UNSUPPORTED. These checks apply to both wad_load_data and wad_view_open. wad_load_data additionally verifies every content hash and fails with SPITWAD_ERR_UNSUPPORTED for a stripped archive or a lump larger than UINT32_MAX; wad_view_open leaves lump data unread and verifies on request through wad_view_verify.