scene_v12_table - the per-scene .PCH walk-on trigger sidecar Confirmed
"When the player steps on tile (x, z), fire event N" - that is what this file encodes. Every
scene (town, dungeon, overworld) can carry one: a small sidecar the developers called
DATA\FIELD\<scene>.PCH (the literal path-suffix strings .MAP /
.PCH / .LZS survive in SCUS_942.54, the game's main executable, at
0x8007B3BC / 0x8007B3C4 / 0x8007B3CC). Story beats, cutscene launches,
and area transitions that trigger by walking somewhere all route through these records. The first
0x800 bytes are a four-kind sub-table directory + the kind-1 trigger records - the same header
shape as the .MAP file's +0x10000 trigger block (field locomotion § trigger block) - followed
by a full scene event-scripts prescript at
a sector-aligned offset. The curious part: that second half is dead in retail - the loader
overwrites everything past the first 0x800 bytes at scene load, so only the trigger records ever
run (runtime staging).
Overview
Implementation: crates/asset/src/scene_v12_table.rs. CLI: asset scene-v12 <PROT-entry> (single), asset scene-v12-scan <dir> (bulk). 97 PROT entries match (PROT.DAT is the disc's single big archive; a "PROT entry" is one of its numbered files, and CDNAME is the name map that groups consecutive entries into per-scene blocks). FUN_80xxxxxx names throughout are Ghidra-traced functions, labelled by their RAM address.
Position law: a scene with CDNAME #define <scene> n carries its .PCH at
raw TOC index n + 1 = extraction entry n − 1 (defines are raw indices - see
CDNAME § numbering space; disc-gated scene_v12_position_law_and_lzs_sibling in
crates/asset/tests/scene_v12_corpus.rs). Scenes without one (opurud, opkorout,
edson - op-0x44-driven cutscene scenes with no trigger tiles) hit the loader's zero-fill branch below.
Naming caveat. The extraction filename labels apply defines as extraction indices and are shifted
+2, so per-file attributions inherited from those labels are off by one block: 0093_map01.BIN is
garmel's table (raw 95 = 94 + 1), Drake map01's is 0084_suimon.BIN
(raw 86 = 85 + 1, and it alone carries b2 = 0x26 = the P2[38] fly-in record the
cutscene page pins to map01), and town01's is
0002_gameover_data.BIN (raw 4, carrying the opening trigger record (0x1D, 0x5B, 0x03) =
P2[3] at the documented arrival tile).
Confidence: Confirmed - header algebra, inline-record shape, and prescript-at-0x800 layout verified across
all 97 corpus entries by the disc-gated scene_v12_corpus test. The inline-record semantics is also
confirmed: the records are kind-1 walk-on tile triggers [tile_x][tile_z][p2_record][gate], read by
the same consumers as the .MAP +0x10000 trigger block (see
Runtime staging below). The earlier “b0 = scene-local resource index”
reading is superseded.
On-disc layout
+0x000 u16 N + 4 ; directory end-of-table offset
+0x002 u16 0x0012 ; kind-0 sub-table offset (empty in retail)
+0x004 u16 0x0000 ; kind-0 count
+0x006 u16 0x0014 ; kind-1 sub-table offset (= the records)
+0x008 u16 param ; kind-1 count (0..=192 in retail)
+0x00A u16 N ; kind-2 sub-table offset (empty)
+0x00C u16 0x0000 ; kind-2 count
+0x00E u16 N + 2 ; kind-3 sub-table offset (empty)
+0x010 u32 0 ; kind-3 count + pad to 0x14
+0x014 param × 4 bytes ; kind-1 trigger records
+end_records (= 0x14 + 4*param) ; empty kind-2/3 sub-table bodies at
; +N (= +end_records+2), +N+2, +N+4.
; Zero on disc (see Open questions on
; the old "runtime fixup" reading).
+end_records .. 0x800 ; zero padding
+0x800 u16 script_count ; scene event-scripts prescript
+0x802 script_count × u16 ; offset table (relative to +0x800)
+0x800 + offsets[i] ; per-record move-VM stager records
; `[i16 model_sel][u16 flags][bytecode]`
; (records typically open `0xFFFF 0x0000`
; = model_sel -1 transform node; NOT
; field-VM bytecode - see below).
Header algebra - a four-kind sub-table directory
The three u16 fields at u16[0], u16[5], u16[7] sit in the tightest algebraic family the corpus exhibits:
| Field | Value |
|---|---|
u16[0] | N + 4 |
u16[5] | N |
u16[7] | N + 2 |
N | 4 * param + 22 |
N is the byte distance from the start of the file to the first runtime fixup slot, which sits immediately past the inline records: N = (0x14 + 4*param) + 2 = 4*param + 22.
The header is a four-kind sub-table directory - the same shape the per-tile lookup helper reads for the
.MAP +0x10000 trigger block: for kind k, the sub-table body offset is the s16
at +4k+2 and its record count the s16 at +4k+4 (overlay 0897 FUN_801D5AE0,
called by the two-window wrapper FUN_801D5630). Under that reading the algebra dissolves:
| Kind | Offset field | Count field | Retail .PCH value |
|---|---|---|---|
| 0 | u16[1] = 0x0012 | u16[2] = 0 | empty (teleports live in the .MAP block) |
| 1 | u16[3] = 0x0014 | u16[4] = param | the inline trigger records |
| 2 | u16[5] = N | u16[6] = 0 | empty (elevation overrides) |
| 3 | u16[7] = N + 2 | u16[8] = 0 | empty (region AABBs) |
u16[0] = N + 4 is the directory's end-of-table offset. Every retail .PCH populates
kind 1 only; the empty kinds' offsets pack consecutively past the records, which is exactly the
N = 4*param + 22 tie the detector checks. The zero “fixup slots” at +N, +N+2,
+N+4 are the empty kind-2/3 sub-table bodies.
Inline records at +0x14 - kind-1 walk-on tile triggers
A trigger does not contain its event - it points at one: the scene's MAN (its map data file) holds the named event records in partition 2, and each trigger names the partition-2 record to spawn. param records, each 4 bytes, in the trigger-block kind-1 form:
| Byte | Field | Notes |
|---|---|---|
+0 | tile_x (b0) | Trigger tile column (128-unit field tiles). |
+1 | tile_z (b1) | Trigger tile row. |
+2 | p2_record (b2) | MAN partition-2 record index spawned on step-on. |
+3 | gate (flag) | Always 0x01 across all 97 entries = gate-1 “spawn on walk-on” (the .MAP block's gate-0 object-bind class never appears in a .PCH). |
Records sharing a b2 are multi-tile strips of one trigger - adjacent tiles that all fire the same
partition-2 record (a gate several tiles wide gets one record per tile). The record's own C1/C2 story-flag gates still apply at
spawn time (FUN_8003BDE0 vs DAT_80085758 - see the cutscene page).
Concrete shape for 0093_map01.BIN (garmel's table under the position law - the filename label is
the +2-shifted naive attribution, see the naming caveat above; param=12):
[0] x=15 z=08 p2=02 ┐
[1] x=14 z=08 p2=02 │ one trigger spanning 3 adjacent tiles
[2] x=13 z=08 p2=02 ┘
[3] x=17 z=2A p2=0C
[4] x=17 z=68 p2=0B ┐
[5] x=17 z=69 p2=0B │ 3-tile strip
[6] x=17 z=6A p2=0B ┘
[7] x=14 z=09 p2=0A
[8] x=06 z=5F p2=09
[9] x=14 z=5E p2=08
[10] x=77 z=12 p2=01
[11] x=72 z=3E p2=00
The earlier “maps to actor placements only on world-map kingdom scenes” reading is superseded:
(b0, b1) are tile coordinates on every scene class; on kingdom overworlds the referenced P2 records happen to be
the town/dungeon-entrance and story-beat records, which produced the placement correlation.
Event-script prescript at +0x800
Identical shape to the standalone scene_event_scripts format: a [u16 count][u16 offsets[count]] table indexing move-VM (FUN_80023070) records in the summon-stager format ([i16 model_sel][u16 flags][move-VM bytecode]) - not field-VM (FUN_801DE840) bytecode (it disassembles as field-VM with a 65–88% error rate). The per-record 0xFFFF 0x0000 lead is model_sel = -1 (a transform/pivot node) + flags = 0, and the 0x0008 terminator is move-VM opcode 0x08 (Halt). The field VM installs a record by id via FUN_800252EC (→ part-stager FUN_80021B04 → move VM). Parser: legaia_asset::scene_event_scripts::move_stager_records. The genuine per-scene field-VM scripts live in the scene MAN sub-asset (see script VM); this prescript is the move-VM stager table they spawn from.
Across the 97 v12 entries:
| Metric | Value |
|---|---|
Valid prescript at +0x800 | 97 / 97 |
script_count range | 2 .. 71 |
| Frame-opener rate ≥ 50 % | 75 / 97 |
| Max records per entry | 71 (0119_keikoku.BIN, 0154_retock.BIN) |
The 22 entries with frame-opener rate below 50 % carry "init"-style first records that open differently, then transition into the standard header-sentinel stream. Those entries carry the same word-aligned command structure; the first record is just shaped differently (record 0 on the town scenes is the fixed 768-byte master ambient stager the entry effect-actor installs - see scene-bundles).
Runtime staging - the .PCH sidecar
The field-asset loader FUN_8001F7C0 (called per scene entry from the mode-2 initializer
FUN_801D6704) stages the file statically - no capture needed
(see ghidra/scripts/funcs/8001f7c0.txt). Retail branch, in order:
DATA\FIELD\<scene>.MAP→ the per-scene buffer*(0x1F8003EC)(collision grid+0x4000, event-cell grid+0x8000, trigger block+0x10000).DATA\FIELD\<scene>.PCH→*(0x1F8003EC) + 0x12000. If the open (FUN_800608F0) misses, the loader zero-fills0x800bytes there instead - the empty-directory fallback for trigger-less scenes.h:\PROT\FIELD\<scene>\efect.dat→*(0x1F8003EC) + 0x12800(= _DAT_8007B8D0), which overwrites every.PCHbyte past the first0x800- so the runtime-live window is exactly the directory + kind-1 records; the on-disc prescript at+0x800is not reachable through this path.
A live capture corroborates: the table sits at heap 0x8014B530 = the town01 scene buffer
0x80139530 + 0x12000.
Consumers of the staged window:
- Scene init -
FUN_8003AEB0(the field/town map-init, body0x8003AFA8..0x8003B018) walks the kind-1 records (countat+0x12008, cursor from+0x12006) and ORs the footprint bit0x400into the u16 event-cell word at*(0x1F8003EC) + 0x8000 + (tile_z << 8) + (tile_x << 1). - Per step - the tile lookup
FUN_801D5630(overlay 0897) scans the.MAP's+0x10000block first and falls back to the+0x12000.PCHwindow (helperFUN_801D5AE0, same directory shape); a kind-1 hit reachesFUN_8003BDE0(x, z, rec[2], rec[3])and spawns the partition-2 record. Full runtime contract: field locomotion § trigger block.
So the .PCH is a patch/extension layer over the .MAP trigger block: same
directory, same record forms, second lookup window.
FUN_80021934 stages the .LZS, not the .PCH
The _DAT_8007B85C reader near ~0x800219xx is
FUN_80021934 (real entry 3 instructions before the 0x80021940 prologue; see
ghidra/scripts/funcs/80021940.txt): the scene-transition streaming actor, a 5-state SM (state at
actor+0x1A, jump table 0x80010760) that pre-streams the next scene's
scene_asset_table bundle during the transition
fade:
- It is not a game-mode handler: its only corpus reference is the handler word of the 24-byte spawn
descriptor at
0x80070734(the system-actor descriptor family at0x800705FC..0x80070763, just below the mode table at0x8007078C- phase-misaligned with it, layout[+4 0xFFFF0000][+8 handler][+0xC flags]).FUN_8001FD44(the named scene-change packet) spawns it via the pool spawnerFUN_80020DE0(actor+0x0C= handler,actor+0x1Azeroed; seeghidra/scripts/funcs/8001fd44.txt,80020de0.txt); the fiveFUN_8001FD44call sites all live in the field overlay 0897 (field-VM op0x3Fat0x801DEB14plus four controller sites). - Case 0 seeds a 70-frame countdown (
gp+0x710 = 0x46); cases 1/3 poll stream progress (FUN_8003DE7C(1)). - Case 2 (
_DAT_8007B8C2 != 0- the retail arm): streams raw TOC entryDAT_8007B768 + 3- the destination scene's block base + 3, the.LZS/scene_asset_tableslot - into_DAT_8007B85Cby index (FUN_8001EEF0→FUN_8003EB98; seeghidra/scripts/funcs/8001eef0.txt,8003eb98.txt). - Case 4 (retail): builds the literal path
DATA_FIELD\<scene>.LZS(suffix0x8007B3CC) and streams it into_DAT_8007B85Cby name, then hands off with_DAT_8007B83C = 2(mode 2 MAIN INIT, whoseFUN_801D6704→FUN_8001F7C0chain then stages.MAP/.PCH/efect as above).
So the raw scene block layout is n+0 .MAP (0x12000 footprint), n+1
.PCH (this format), n+2 event-scripts sister, n+3 .LZS bundle head,
n+6 BGM base - and the transition actor touches only n+3. The v12 record-table staging + consumer
chain is the .PCH path above; _DAT_8007B85C never holds this file.
Detection
The strict gate combines six checks:
buf.len() >= 16.u16[1] == 0x0012,u16[2] == 0,u16[3] == 0x0014,u16[6] == 0.u16[0] == u16[5] + 4(=N + 4).u16[7] == u16[5] + 2(=N + 2).0 <= param <= 1024(corpus tops out atparam = 192;0724_noaru.BINis theparam = 0edge case).N == 4 * param + 22(= the runtime-fixup slot algebra).
The algebraic tie at step 6 is the tightest constraint: across the entire 1234-entry PROT corpus it matches 97 entries with zero false positives. Steps 1–5 alone would already match the same set, but the explicit N/param check is a strong contract for code that consumes the parser output and relies on end_records = N - 2.
Sister formats
Every scene block carries a sister scene_event_scripts entry (prescript at offset 0, no directory header) at
raw n + 2 - directly after the .PCH at raw n + 1:
raw n+1 (extraction n−1) scene_v12_table / .PCH (this format) ┐ same
raw n+2 (extraction n) scene_event_scripts (no header) ┘ scene
For Drake map01 (n = 85) that is extraction 0084 + 0085; the historical
pairing of 0085 with 0093 crossed a block boundary (0093 is garmel's .PCH -
see the naming caveat above).
The two prescript tables likely serve different scopes (scene-enter triggers vs. per-actor / per-region triggers), or
they're “early-load” and “late-load” splits of one logical script set. The exact runtime split isn't pinned
down; note the .PCH-resident copy at +0x800 is dead via the retail staging path (efect.dat overwrites
it - see Runtime staging).
Reading the parsed structure
use legaia_asset::scene_v12_table;
let buf = std::fs::read("extracted/PROT/0093_map01.BIN")?;
let t = scene_v12_table::detect(&buf).expect("v12 header valid");
println!("N={}, param={}", t.n, t.param);
for (i, rec) in t.records.iter().enumerate() {
println!("rec[{i}]: b0={:02x} b1={:02x} b2={:02x}",
rec.b0, rec.b1, rec.b2);
}
for (i, s) in t.scripts.iter().enumerate() {
let bytecode = t.script_payload(&buf, i).unwrap();
println!("script[{i}] @{:#x} len={} opener={}",
s.start, s.len(), s.frame_opener);
}
The "embedded MAN at 0x1000" is an extended-footprint over-read
Extraction entries 0076 and 0164 show a canonical 7-asset
scene_asset_table at file offset
0x1000, which was read as the v12 “embedding” its scene's bundle. Byte comparison falsifies the
embedding: 0076 + 0x1000 onward is byte-identical to extraction 0078 (suimon's
ordinary base+3 bundle) and 0164 + 0x1000 to extraction 0166 (geremi's) - the extraction slice of the
.PCH slot simply over-reads into the following TOC entries (the same extended-window trap as the
historical “16 MB container at 0865”). Under the position law those two windows are suimon's and geremi's
.PCH files, not dolk2's / rikuroa's.
What stays true: dolk2 and rikuroa are the two scenes whose own base+3 bundle is
the MAN-less count=4 form (types [1, 2, 6, 0x14]; the type-0x14 slot is a small LZS
filler, not a MAN carrier). Retail sources their partition scripts elsewhere: each block ships a
standalone data_field_streaming entry whose type-3 chunk is a plain MAN (dolk2 extraction 70,
partitions [29, 73, 17]; rikuroa extraction 157, [13, 29, 64]), and the live script heap
at the Mt. Rikuroa Caruban beat byte-matches the 0157 chunk - the streaming carrier IS the resident MAN (see
script-vm § streaming variant MAN). The engine resolves it via the
field_man_payload streaming fallback (legaia_engine_core::scene_bundle::streaming_man_payloads,
disc-gated crates/engine-core/tests/v12_bundle_man_disc.rs); the earlier
V12Embedded { table_offset: 0x1000 } fallback resolved the over-read windows (suimon's / geremi's bundles) under
the CDNAME-shifted scene windows and is superseded for these two scenes.
Open questions
Falsified leads, kept as warnings: the _DAT_8007B8D0 relocation via FUN_800252EC is the
efect.dat prescript stager, not this file's; the FUN_8001F05C dispatch hypothesis fails because the
.PCH is a standalone top-level PROT entry, never a type << 24 chunk; FUN_8002541C
is a generic 3-mode streaming driver; the ~0x800219xx / _DAT_8007B85C reader is the sibling
.LZS transition streamer FUN_80021934 (above), not this file.
Still open:
- The
+N"fixup slot" writes. Under the directory reading the zero words at+N,+N+2,+N+4are the empty kind-2/3 sub-table bodies; whether any runtime writer fills them (the old “loader writes computed pointers” observation) needs a targeted re-capture of the+0x12000window. - Empty kinds 0/2/3. No retail
.PCHpopulates teleports, elevation overrides, or region AABBs (those live only in the.MAP+0x10000block). Whether the engine-side patch mechanism was ever used for them is a dev-history question, not a runtime one. - Two prescript tables per scene - the sister offset-0
scene_event_scriptsentry (rawn + 2) and this file's offset-0x800copy carry the same move-VM stager records, both consumed viaFUN_800252EC→FUN_80021B04. The runtime split is unpinned; the.PCHcopy is unreachable via the retail staging path (efect.dat overwrites+0x800..), pointing at the sister entry / the.LZSscripted-table prefix as the live copies.