At a glance

Where
PROT.DAT at the disc root; header + TOC = the first 3 sectors. DMY.DAT shares the layout (dev fixtures only)
Magic
None - two header words, then a flat u32 LBA table
Size / stride
1236 TOC rows of 4 bytes; entry size = gap to the next row, in 2048-byte sectors
In RAM
Raw copy of the first 3 sectors at 0x801C70F0
Parser
crates/prot (Archive, runtime_toc, tiling)
Confidence
Confirmed - retail's own resolver does the same subtraction, and the entries tile the archive exactly
Used by
asset loader, boot sequence, extraction pipeline, asset viewer

Three things to hold onto

An entry is one numbered region of the archive - the unit the game requests when it loads a scene, a battle or a menu. The TOC measures in sectors (2048 bytes) and LBAs, where an LBA here counts sectors from the start of PROT.DAT itself, not from the start of the disc.

  • One word per entry. The TOC stores only each entry's start LBA. Its size is the gap to the next entry's start - exactly how the game computes it.
  • The entries tile the archive. Starts ascend, each entry ends where the next begins, and the sizes sum to the whole file. That is what makes the reading self-checking.
  • Two index spaces, off by 2. The game counts TOC rows from the file's first word; this project's extraction counts from the first entry. Every index recovered from game code is 2 higher than the matching extracted/PROT/NNNN_*.BIN, and CDNAME.TXT is authored in the game's space.

Layout

+0x00 unused u32 +0x04 rows - 1 u32 +0x08 toc[0] boot UI a toc[1] boot UI b toc[p+2] entry p start toc[p+3] entry p+1 start ... toc[1235] end = 59206 size_sectors(p) = toc[p+3] - toc[p+2] raw TOC index = p + 2 (the game's space)
The first words of PROT.DAT. Word +0x08 is both the TOC-size word and toc[0]; the first extracted entry's start LBA is two words later.
OffsetSizeFieldMeaning
+0x004unusedZero in both retail archives
+0x044rows - 1TOC row count minus one: PROT.DAT 1235, DMY.DAT 15
+0x084toc[0]Header + TOC size in sectors (3), which is also the first content LBA
+0x0C4 × rowstoc[1..]Start LBAs; toc[1235] = 59206 is the end terminator

The 1236 rows are toc[0] and toc[1] (two boot-UI regions), 1233 entry starts, and one terminator. For extraction entry p:

start_lba     = toc[p + 2]                  // LBA relative to PROT.DAT
size_sectors  = toc[p + 3] - toc[p + 2]     // the gap to entry p+1
byte_offset   = start_lba * 0x800
size_bytes    = size_sectors * 0x800

The LBAs are PROT.DAT-relative, so they are identical on the USA and PAL discs even though PROT.DAT sits at a different disc LBA per region. That is what makes whole-sector entry growth tractable: shift the later start-LBA words, no disc-wide cascade (disc § full-ISO relayout). The detector tries the header at offset 0x000 first, then 0x800; both retail archives use 0x000.

In-RAM TOC and the two index spaces

At boot the game copies the first three sectors of PROT.DAT into RAM, header words included, and resolves every later asset request against that copy. There is no transformation - but the extraction builds its toc[] after the two header words, so the two sides count from different origins.

File wordRaw TOC index (game)Extraction indexHolds
0, 1--the two header words
20(unindexed)boot-UI region a, LBA 3
31(unindexed)boot-UI region b
420000first extracted entry, LBA 121
p + 4p + 2pstart LBA of extraction entry p

Raw TOC index = extraction index + 2. Any PROT index recovered from a resolver argument must subtract 2 to land in extraction space (the battle side-band files: raw 0x37F / 0x380 = extraction 893 / 894). CDNAME.TXT's #define numbers are raw-space, so extraction filename labels are shifted +2 from the content the defines name.

Raw entries 0 and 1 (LBA 3..120) are two TIM-packs holding the boot-resident system-UI bundle - glyph atlas, sprite sheets, cursor parts (tim-pack).

How the game asks for an entry

  • By index - the streaming loader and the dev-build sound branch call the resolver with a raw TOC index.
  • By path - dev paths like data\battle\efect.dat or h:\PROT\FIELD\<scene>\… resolve through the CDNAME name map to an index, then to the resolver. Most retail code lands here.
  • Overlays - the field, battle and menu engines are code entries loaded on mode switch. Two paired loaders manage two independently swappable RAM slots, each calling the resolver with param + 0x381 = extraction entry param + 0x37F (2 = 0897 field, 3 = 0898 battle, 4 = 0899 menu). Two overlays can be resident at once.

A dual-mode loader chooses between the TOC index (retail, the boot-time value of _DAT_8007B8C2) and an h:\ dev-station path through a break 0x103 host trap. Only the retail branch runs on a real disc. The CD-read API stack behind these is on boot.

How we know

FunctionAddressWhat it provesDump
Entry sizeFUN_8003E68CTwelve instructions returning TABLE[i+3] - TABLE[i+2]; result goes straight to the sector readfuncs/8003e68c.txt
TOC resolverFUN_8003E8A8Indexes 0x801C70F0 + (idx+2)*4; stores start_lba at gp+0x8f0, returns the span in v0funcs/8003e8a8.txt
Boot TOC copyFUN_8003E4E8Reads the first 3 sectors raw into 0x801C70F0; byte-verified against a save statefuncs/8003e4e8.txt
By-index loaderFUN_8003EB98FUN_8003E800Passes the resolver's span as the sector countfuncs/8003eb98.txt
Path resolverFUN_8003E6BCCDNAME name map → index → LBA resolverfuncs/8003e6bc.txt
Overlay slot loadersFUN_8003EBE4 (*DAT_8001038C, gp+0x924), FUN_8003EC70 (*DAT_80010390, gp+0x934)Two parallel slots; param + 0x381 argumentper-function dumps
Dual-mode loaderFUN_8003E360; FUN_800608F0 / FUN_80060944Retail TOC branch vs dev h:\ trapfuncs/8003e360.txt
Tilingarchive_tiling_real.rsSizes partition LBA 121..59206 exactlydisc-gated test

Further evidence that the gap is the size, independent of the definition: known-length files agree with it (readef.DAT = 78 × 0x10800, summon.dat = 103 × 0x10800, every .MAP = 0x12000, bse.dat = 2 sectors); every scene-asset table lands at offset 0 of its entry; mid-cast save states hold a stager byte-resident up to the sector gap and stale bytes after it; and PROT 899 is 74 sectors, all its own.

Deep dive

History: the over-read entry size and the coordinates it mislabelled

The expression toc[p+5] - toc[p+3] + 4 is not an entry's size: it expands to size(p+1) + size(p+2) + 4, the two entries that follow p. It overshoots for 931 of 1233 entries and falls short for the rest. Parsing over that window ran into neighbours' sectors. Entry::declared_span_sectors keeps the value for diagnostics only (prot-extract list, column decl_span, flag OVR).

Every constant of the form "asset X at PROT N offset K" measured inside that window names real bytes but, when K ran past entry N's end, the wrong owner. Re-keying changes no byte:

WasIsWhy
Kingdom bundle at PROT 0085 / 0244 / 03910086 / 0245 / 0392The 7-asset table is at offset 0 of the next entry, not "at 0x1800 of the prescript entry"
Battle-form atlases at PROT 1204 0x25804 + k×0x8224, seven, last truncatedPROT 1205 4 + k×0x8224, eight0x25800 is 1204's exact length; the window stopped between atlases 6 and 7
Title TIM at PROT 0888 0x1AA28 with "duplicates" in 0889 / 0890PROT 0890 0x14228, one copyAll three resolve to the same absolute offset

Other artefacts of the over-read: the "prescript-prefixed asset table" (a neighbour's offset-0 table), inflated overlay self-pointer ratios (PROT 0901 has three, not nine), "shifted copy" claims between 0900 and 0901, and an init.pak detector that demanded 0x30000 bytes of a 75-sector entry. Two checks make a coordinate trustworthy: offset + length fits inside the entry, and the payload's own framing terminates inside it.

Older still: a Python proof-of-concept used start_lba = toc[p+5] - toc[p+2], collapsing ~80% of entries onto the same low-LBA ranges; and "the boot loader transforms the TOC" is falsified - only the index space differs. Catalogue: do-not-re-walk.

See also