Cutscene internals
The register-level companion to cutscenes: how the movie overlay pumps sectors through its ring and feeds the MDEC chip, how a frame decodes step by step, and how the scripted opening's timeline, narration roller, camera mover, actor channels and gold grade work in retail and in the port. Read the main page first; this one assumes its vocabulary.
At a glance
- Movie overlay
- PROT 0970 at
0x801CE818; play loop 1236 bytes; ring = 32 sectors at asset buffer+0x10000 - Frame decoder
- Iki: 10-byte header, LZSS qscale/DC table, AC VLC stream; port
psx_mdec::MdecDecoder - Opening timeline
- Partition-2 named records in each scene MAN, run by the field VM
FUN_801DE840as a spawned context - Camera
- Op
0x45: 10 params into globals; mover actorFUN_801DD310; portengine-camera::camera_mover - Roller
- Handler
FUN_80037174; geometry at_DAT_801C6EA4 + 0x4C..0x52; portCutsceneNarration - Grade
- Palette law
(L, (L·247)>>8, L>>1)on the two graded CLUT rects; portRenderer::set_palette_grade - Confidence
- Confirmed; the gold-grade CLUT rewrite is the prologue effect scripts' CLUT-cell cyclers
Movie overlay: ring, demux and MDEC feed
| Stage | Mechanism |
|---|---|
| Decoder select | DAT_801E09FC: Iki by default; dispatch slots 9/10 (dev files) select the STRv2/v3 path |
| Ring | 32 sectors in the streaming asset buffer at _DAT_8007B85C + 0x10000, set up by StSetRing / StSetStream |
| Read | Setmode 0xE0 (Speed | RT | Size1: real-time XA on, sector filter off) then CdlReadS |
| Demux | Data-ready callback gates each sector on magic 0x160 + stream number, skips to start_frame, accumulates sequential chunks into per-frame slots (status 2 = complete; a full ring drops the frame), latches end_frame on chunk 0 |
| Consume | StGetNext / StFreeRing - the retail counterpart of the port's StrFrameAssembler |
| Decode | Iki: LZSS qscale/DC table, then a GTE leading-zero-count VLC scan into the MDEC-code double buffer |
| Feed | DMA-0 into the MDEC; an out-slice callback blits each 32-px strip into the slot's VRAM rect; sync waiters spin on a 0x100000-iteration budget |
The five MDEC register/DMA helpers (feed, reset, two sync waiters, timeout dump) are the port boundary: they describe the chip, so the port has no counterpart for them. Everything above them - loop, ring, pump, slice callback, output control word - is a decision about the bitstream and has a crates/mdec counterpart.
The STRv2/v3 path (dev slots only)
This decoder is a bit-prefix lookup, not a run/level VLC. A table unpacked at runtime into DAT_801E0A00 by FUN_801F1A00 - called once per movie even for Iki slots that never read it - stores pre-baked MDEC output codes, one to three per hit plus a bit length, in four regions: luma DC, chroma DC, AC primary, AC secondary. Only the DC coefficients (raw 10-bit in v2; size-prefixed predicted differences per channel in v3), the 0x7C1F escape codes and the end padding are computed. Ported as psx_mdec::strv2_table (unpacker, mdec strv2-table <overlay>) and strv2_decode::decode_frame; with no retail movie on this path there is no golden decode, so its tests pin the code paths against the disassembly.
Overlay residency window (captured during playback)
| Address | Stride | Contents |
|---|---|---|
0x801CAE08 | 24 B | libcd CdlFILE cache ([CdlLOC][size][name[16]]) for the last directory searched - the MOV dir while a movie plays; not a movie structure |
0x801CCA80 | 56 B × 6 | ISO9660-shape directory records of the six movie files |
0x801CE810 | variable | Path strings: \DATA\MOV.STR;1, \DATA\MOV15.STR;1, \MOV\MV1A.STR;1, \MOV\MV6..MV1.STR;1 |
0x801CE8AC | variable | Post-movie return-scene labels: town0b map01 chitei2 map02 jou uru2 town0e |
0x801D0A6C | 32 B × 23 | Dispatch table - STR FMV table |
MOV15.STR is a 15 fps test file and MV1A.STR an alternate cut of MV1; neither ships. The 8-bit ADPCM decode path (BitsPerSample::Eight) has no example in the movie corpus and is covered by synthetic tests only.
Decoding one Iki frame
| Step | What happens |
|---|---|
| 1 Header | 10 bytes: mdec_code_count, magic 0x3800, width, height, lzss_size |
| 2 LZSS table | lzss_size bytes inflate to block_count × 2; control bits LSB-first, 0 = literal, 1 = back-reference (length byte +3, 1- or 2-byte offset +1, overlapping copies allowed). Block i: (table[i] << 8) | table[i + block_count] = 6-bit qscale, 10-bit signed DC |
| 3 AC stream | 16-bit LE words, MSB-first; PSX VLC run/level codes per block, EOB 10, escape 000001 + 16-bit raw run << 10 | level. A full block still ends with an explicit EOB |
| 4 Dequantise + IDCT | coef[0] = DC × Q[0]; coef[zigzag[i]] = (level × Q[i] × qscale + 4) >> 3, unclamped; two-pass separable IDCT with IDCT_C pre-scaled by 2048, full i64 rows, one >> 24 after the columns |
| 5 Macroblocks | Six blocks each: Cr, Cb, Y0..Y3; laid out column-major, 16 px down then the next column |
| 6 Colour | 4:2:0 upsample; R = Y' + (91881·Cr >> 16), G = Y' − ((22554·Cb + 46802·Cr) >> 16), B = Y' + (116130·Cb >> 16) with Y' = Y + 128; row-major RGBA8 out |
Implementation crates/mdec/src/lib.rs (MdecDecoder, AC_CODES, iki_lzss_decompress, IDCT_C, Q_MAT); a decoded-frame fingerprint is pinned against the disc. XA decode is bit-exact against a lossless reference of a real track; sector layout and filter coefficients are on XA audio.
The opening timeline
A timeline is a partition-2 named record in the scene MAN, executed by the ordinary field VM. Two things spawn one: op 0x44 in the scene's entry script (operand = a global record index re-based into partition 2), or a walk-on tile trigger (kind-1 record [tile_x][tile_z][p2_record][gate] in the scene .MAP's trigger block; the entry seat lands on the tile and fires the same tick). Both end in FUN_8003BDE0, which tests the record's story-flag gates against the bitmap at DAT_80085758 and spawns a VM context.
The scene's entry script runs first. The MAN loader FUN_8003AEB0 runs the script's install slice itself (FUN_8003AB2C, which calls the dispatcher directly), before any actor-list pass, so on the arrival frame the entry script's ops land before an arrival record's. In town01 that decides the visible tile window: the entry script sets the walking window (-10, -6, 8, 14) and the opening record then sets (-17, -13, 10, 31), which keeps the whole village in the establishing pan. The engine opens its first tick with that slice, ahead of a record installed at scene entry, and then runs the frame's own system pass, the script's second, as retail's first frame does.
| Field | Size | Meaning |
|---|---|---|
name_len | 1 | Name length in characters |
| name | name_len × 2 | SJIS record name |
C0 + bytes | 1 + C0 | Skipped |
C1 + u16s | 1 + 2·C1 | Block if ANY flag set |
C2 + u16s | 1 + 2·C2 | Require ALL flags set |
| script | - | Entry PC; opdeene record 18 opens with an effect-colour reset then GFLAG_SET 26 |
System flag 7 is raised by the MAN loader FUN_8003AEB0 on every scene load (ori 0x1 into 0x80085758 at 0x8003AEF4), and no shipped script clears it. A record whose C1 list names it never spawns: over a hundred developer leftovers carry one, among them town01's two サウンド外 / サウンド内 walk-on records south of the Genesis Tree, whose whole body is a Greetings. box.
Nearly every op in the record carries the cross-context target 0xF8, which resolves to the player / camera-anchor actor - so the timeline drives the camera and the lead actor rather than itself. On-disc records have no end opcode: they park in a Nop+JmpRel-to-self loop or loop back to their top as a resident driver, and retail leaves them spinning invisibly.
Camera Configure (op 0x45)
A big-endian 10-bit mask selects which of ten signed-16 parameters follow; each is written to a camera struct at 0x801C6EA8 and committed by FUN_801DE084, which either snaps (apply == 0, killing any glide in flight) or hands a mover actor ten (start, end) pairs plus one shared progress, duration and curve.
| Param | Global | Role |
|---|---|---|
| 0 / 1 / 2 | _DAT_8007B790/92/94 | Pitch / yaw / roll (GTE angles, 4096 = 360°); eight scenes stage a non-zero roll |
| 3 / 4 / 5 | _DAT_800840B8/BC/C0 | Eye-space offset trio (dx, dy, depth) - slot 5 is how far the eye sits behind the focus |
| 6 / 7 / 8 | _DAT_80089118/1C/20 | Focus point; GTE translation is (−X, +Y, −Z) |
| 9 | _DAT_8007B6F4 | H, the projection distance (zoom) |
The view builder composes screen = H · (R · (v − focus) + tr_eye) / Ze, with a constant 6× world scale folded into R. The mover advances t by the frame-skip factor each frame, so apply is a duration in display frames; every axis uses the same curve, angles included, with no shortest-arc handling.
| Curve | Shape | Formula for offset k = end − start |
|---|---|---|
| 1 (and any other) | Linear | (k·t)/d |
| 2 | Quadratic ease-out | (n + (n/d)(d − t))/d, n = k·t |
| 3 | Quadratic ease-in | ((k·t)/d · t)/d |
| 4 | Ease-in-out | Curve 3 to the midpoint over d >> 1, then curve 2 |
Port: legaia_engine_camera::camera_mover, and both play hosts draw a scripted shot from the camera globals it writes (frame_step::scripted_view). Because retail scales the world 6× and the engine renders at 1×, tr_eye is divided by 6 - the perspective divide makes both project identically. Reference frame: focus (8640, 0, 10304), pitch 180, yaw −2967, H 792, tr_eye (260, 1293, 17145) projects the party to (172, 180), matching the capture.
The narration roller
Spawn op CC F8 80 N allocates a child actor from template DAT_801F28A0 with handler FUN_80037174, points its script pointer at the page-count byte, and the parent measures the pages to skip past the block. Config op CC F8 E8 w0 w1 w2 w3 (four signed-16 words) is mode-selected by w3:
w3 | Effect |
|---|---|
| 0 | Seed geometry: +0x4C window top Y (default 64), +0x4E visible lines of 16 px (default 8, bottom clamped ≤ 232), +0x50 scroll divisor (px/frame = frame-skip / divisor, default 4) |
| 1 | w0 == 0 pauses the live roller; otherwise +0x52 = stop after N lines |
| 2 | Resume |
| 3 | Unlink the child |
Geometry and speed come from the scene: the roller reads its config block through *0x801C6EA4, the scene reset FUN_8003A024 stores 0x40 / 8 / 4 / 0, and a seed op overwrites the first three. A 4C E8 release count is block state too, so it dies with the scene. Every crawl block on the disc runs on a seed op placed immediately before it, or on the one an earlier block of the same scene left:
| Scene | Seeds (top / slots / divisor), in script order |
|---|---|
opdeene | 0x40 / 8 / 4 (all three words zero - the defaults) for both blocks |
opstati | 0x80 / 5 / 5, then 0x80 / 5 / 4 |
opurud | 0x80 / 4 / 4 twice, then 0x80 / 4 / 3 (the second block reuses the first seed) |
map01 | 0x80 / 5 / 4 |
The line pitch is a fixed 16 (retail addiu s3,s3,0x10). The clock is the adaptive frame step DAT_1F800393: the accumulator gains it each frame and a pixel of climb happens when it reaches the divisor, the remainder dropped. With opdeene's frame-step floor of 3 (written by its own prescript), divisor 4 climbs a pixel every two frames.
Lines draw centred, all glyphs at once, scrolling upward in a clipped window. The single-line balloon (4C E1, centred at Y = 180, 120-frame timer) is a different op and never carries the crawl. Port engine-field::cutscene_narration (CutsceneNarration + RollerSeed::config_op_before).
Per-actor channels
Retail spawns one script context per partition-1 placement at scene entry (FUN_8003A1E4); its script id is partition-0 count + placement index, the id space cross-context ops resolve through. The timeline halt-acquires channels 0x05..0x0F (a sweep of 4C 85), pokes each beat (4C 45 param, 4B animate, 23/A3 move), resumes with B2 <id> 0A, and waits at B3 <id> <bit> until the channel's own script raises its completion flag. Even a halted channel keeps animating, because the per-actor anim tick runs independently of the parked script PC.
Port legaia_engine_field::field_channels: one FieldChannel per placement, stepped a slice per tick; pokes run against the resolved context; a failing cross-context flag test parks the timeline (bounded by CHANNEL_WAIT_PARK_TIMEOUT, falling back to a step-past). The player channel 0xF8 is modelled directly: ExecMove arms a countdown and the halt-acquire parks until it drains, so door records flow on to their scene change. Idle clips come from the scene's ANM bundle, whose descriptor-count seed is not uniform - the prologue scenes resolve only at count ≥ 5, so the render searches [3, 5, 6, 7].
Engine execution rules
- Only cutscene-class records (the opening chain, gated walk-on beats) install as the modal timeline with camera seize and locomotion lock; an ordinary mid-play op-
0x44spawn becomes a concurrent helper context that leaves the camera alone but still refuses the pad until it ends - retail's script runner raises the player's engaged bit for every context it steps. - Narration blocks are data, so the stepper installs the pages and advances the PC past the block; it holds only for a scene's last crawl (so the scene change waits) and when a roller is still scrolling.
- Camera beats merge into a persistent parameter set - one
opdeenebeat sets onlyH- cleared on scene entry. - Completion is the choreography wrap: a backward jump onto an already-executed PC. Real waits (
0x4A, flag handshakes, the0x49name-entry suspend) halt at their own PC. - A byte
& 0x7F < 0x20at the PC is a dialog transition, not an opcode; an0x80-bit op whose target matches no spawned channel is skipped by width; resolved-channel busy-waits fall through because engine pokes complete synchronously. - One engine tick is one vsync, and record contexts are stepped on every one; the frame delta they see is the game frame's length on a game-frame vsync and zero between (pacing).
Pacing: every wait is a count of display frames
Two clocks pace a record, and both count through the same factor. Op 0x4A (wait frames) adds the adaptive frame-skip byte DAT_1F800393 to a per-context sum on each visit and passes on the first visit whose sum reaches the operand; the camera mover adds the same byte to its progress. That byte is the number of display frames one logic tick spans, so every authored duration is a duration in 60 Hz frames, to within one game frame.
That last game frame is measurable. The op credits the frame that reaches it as well, so a wait of N at step s holds its record for s × (ceil(N / s) − 1) vsyncs:
| Step | Operand → vsyncs held |
|---|---|
| 2 | 6 → 4, 30 → 28, 45 → 44, 90 → 88 |
| 3 | 5 → 3, 10 → 9, 30 → 27, 45 → 42 |
| 4 | 5 → 4, 15 → 12, 50 → 48, 430 → 428 |
- Measured. Every row is read off a per-vsync capture of the zero-input opening; all 59 waits the chain holds at one step from end to end fit the formula.
- The step is the adaptive one.
opdeeneruns at 3 because its prescript raises the floor;opstatiandopurudsit on a floor of 2 and step by 2, 3 or 4 as their frames cost. No field scene steps by 1 - that floor belongs to name entry, the menus and battle. - Why crediting one per vsync is wrong. It holds a wait for
N − 1ticks, up to two vsyncs long per wait at step 3, which over the opening record's waits puts it some thirty vsyncs behind retail by its last vignette. - End-latch spins are on the grid too. The anim tick runs once a game frame and multiplies each clip's step by the frame step, so the engine advances every placed-prop, NPC and cross-context clip cursor on game frames only. The actor ticks after the record, so the record sees a clip's end latch on the next game frame and every spin ends on one.
- The residual is one-sided. A retail leg, measured scene label to scene label, includes the load and mode-transition window before the record's first tick; the engine's scene loads are instant, so it lands short by that window. Running long is the regression signal.
Two ops that do not do what their dump suggests
4C 49selects a write variant on two bits of_DAT_1F800394(bit 25 delta, bit 24 player-relative+0x4A = value + anchor[+0x16], else default), always advancing 6 bytes. The field-overlay dump's absolute-jump arm never applies on the opening path.4C 9Fis a retire sweep, not a callback registration: it sets the kill bit on every live actor whose per-frame handler matches, then advances the script two bytes. It is inert during the opening and fires zero times under a live probe.FUN_8003CF04is a list finder over0x8007C34C, not a kill function.
Fades, effect colour and the gold grade
| Op | Bytes | Target | Effect |
|---|---|---|---|
4C 12 | [r][g][b][ramp u16] | Multiply tint DAT_8007BCB8..BA, neutral 0x80; ramp jobs via FUN_8003C5F0 | Screen fade over the 3D scene; persists across scene changes; text unaffected |
34 sub-0 | [op0][r][g][b][ramp u16] | Spawns a colour tween on the effect actor at _DAT_8007B62C; op0 itself carries the blend (&1 ? 2 : 1) and the push kind (8 on &2, 0 on &4, else 2) | Walks the effect colour out and back in as a pair of tweens; not a screen fade - the tableau stays lit through it. An all-zero operand clears the effect outright (the arm drops the actor pointer and never spawns), and a pure-white target under blend 2 loses an eighth of its duration |
Every scene's entry script arms 4C 12 00 00 00 00 00 then 4C 12 80 80 80 44 00 - instant black, 68-frame ramp to neutral. New Game sets system flag 0x52F to arm that handshake, and the engine pre-runs the entry script's load-frame slice so black is on screen before the first render. Port: fade::SceneTintRamp in World::presentation.tint; the effect beat is a pool colour tween read back as World::screen_tint_pushes, which is the one model - a live capture of a retail beat reads a three-argument push per frame, the push's shape exactly, while the global multiply tint stays neutral throughout. The three arguments are an ordering-table bucket, a semi-transparency equation and a GP0 colour word (red in the low byte); both hosts draw the frame's pushes through one shared emitter.
The gold grade on opdeene / opstati / opurud is a palette-space law applied to the loaded assets: the CLUT rects (0, 473, 320, 2) and (0, 476, 320, 34) are rewritten entry for entry from the disc value to (L, (L·247) >> 8, L >> 1) with L = max(r, g, b), STP bit preserved. The writer is four mode-3 CLUT-cell cyclers, records 10..13 of each prologue scene's effect-script bundle: two capture the rects and re-upload them grey (op 0x2B saturation -0x400), two capture the grey and re-upload it at hue +0x38 / saturation +0x80, every frame through FUN_80019D50. Packet colours split by source - the ground kernel's runtime neutral 0x80 stays neutral and draws gold purely through its collapsed CLUT, while each resident TMD's authored colour words are rewritten by the scripts' own two 4C E6 HSV ops (saturation -0x100, then hue +0x38 / saturation +0x90 / value -0x1E) to (V, V·246 >> 8, V·112 >> 8) with V = min(max(r,g,b), 0xF8) − 30 - a retail opdeene state holds every resident word on that curve. No depth cue runs: IR0 is 0 on every render node throughout the opening.
The port applies the same laws in its mesh shaders (palette_law_word / prologue_sepia_word, CPU mirrors in lockstep tests) because a 4/8bpp texel is a palette entry; exact-neutral words stay neutral, and the screen tint rides the same uniform. The ground matches the capture at G/R 0.890, B/R 0.46..0.48. The far spires are the exception that is not a palette law: they are light-source rows, which the 4C E6 rewrite never touches, and retail shades them through opdeene's dim scene back colour 0x202020 (B/R ≈ 0.15 in the capture).
History: the depth-cue and tint models
The grade was first modelled as a gold far colour with a per-node depth-cue pull, then as a render-time pixel multiply; the CLUT peek falsified the first (IR0 = 0 everywhere) and the ground's green cast (G/R ≈ 1.07) the second. A signature scan across PROT 0970, 0897 and the executable found no CLUT-rewrite loop because there is none: the rewrite is two passes of the general HSV re-tint, data-driven by the effect scripts. The law was first read as (L, L−1, L/2), which holds below luminance 29. The op-0x34 ramp was read as a between-beat black fade and, before that, as a white flash with a 50% wash; the cold-boot capture holds the lit tableau across the span.
Battle intro and script helpers
| Piece | Where | What it does |
|---|---|---|
| Style select | FUN_801CE8CC (PROT 0979) | Reads battle flags DAT_8007BD60 bit 0x80, first monster id DAT_8007BD0C, scene index DAT_80084540; default style 2, style 3 for three formations, 4 for one; two delay-slot stores land on both branch arms, which is why the flags-set path defaults to 1 |
| Transition tick | FUN_801CF5BC | Owns the whole frame for DAT_801D2458 frames, then hands off to the battle scene |
| Emitters | render track | The fade's second argument is a blend mode, not a depth; style 2's emitter is not a GTE emitter. Port render-kernels::battle_intro |
FUN_801D27E0 | dialogue overlay | Party-leader swap state machine (6 states on actor +0x54); writes DAT_80084597 and spawns the talk's own record on every swap |
FUN_801D5C08 | dialogue overlay | Position tween: +0x9C += (+0x9E) × frame-skip, lerp +0x14 to +0x24, done bit 8 at t ≥ 0x1000 |
FUN_801D5D60 | dialogue overlay | Scripted-element teardown: restore the camera, clear enable flags once done |
FUN_801D6058 | dialogue overlay | Ambient particle emitter gated on _DAT_8007B854: single jittered spawns or 24 random bursts |
FUN_801D5E20 | dialogue overlay | Rotates a mesh's own colour words |
The full per-style emitter decode is in docs/subsystems/cutscene.md.
How we know
The movie path
| Function | Address | What it proves | Source |
|---|---|---|---|
| Master dispatch | FUN_801CEA3C | 32-byte slot stride (sll v0,v0,0x5), decoder select, return-scene hand-off, intro-only skip | PROT 0970 disassembly, byte-identical to the playback RAM capture |
| Play loop | FUN_801CF098 | Seek math, SPU CD mix, per-frame pump, teardown | PROT 0970 |
| St demux | FUN_8005ECD4 / FUN_8005F024 | Ring slots, magic gate, end-frame latch | SCUS_942.54 |
| Iki decoder | FUN_801D0378 / FUN_801D0604 | LZSS DC table + AC scan; STRv2 path FUN_801D070C is dev-only | PROT 0970 |
| FMV trigger op | 0x801E30E4 in FUN_801DE840 | Writes _DAT_8007BA78 and _DAT_8007B83C = 0x1A; a label, not a callable | Field overlay 0897 dump; JT chain 0x801CEE60 → 0x801CF010 |
| Title attract trigger | FUN_801DD35C (0x801DDCF0) | Idle countdown underflow writes mode 26 with id 0 | PCSX-Redux watchpoint - boot |
| Per-scene triggers | man_field_scripts::scene_fmv_triggers | One trigger op per scene MAN, the table on the overview | Disc walk of every scene MAN |
| XA clip starter | FUN_8003D53C / FUN_8003D764 | CdlSetfilter {1, chan} for clips; no filter for movies | SCUS_942.54 |
| Clip table filler | FUN_801CFA78 | 34 [CdlLOC][len] slots at 0x801C6ED8 from \XA\XA%d.XA;1 | PROT 0895 init.pak |
| Per-movie single XA track | - | (file 1, chan 0) on all six movies | Raw-sector subheader scan |
The opening and its helpers
| Claim | Evidence |
|---|---|
| Ring / demux state machine | FUN_8005BBF8, FUN_8005EDC4, FUN_8005EB68, FUN_8005ECD4, FUN_8005F024, FUN_8005EF40, FUN_8005EE4C (SCUS_942.54 St library) |
| MDEC feed boundary | FUN_801CFD84, FUN_801CFFDC, FUN_801CF56C, FUN_801D0100, FUN_801D0198, FUN_801D0248, FUN_801CFEE0 - carry the MDEC_in_sync / MDEC_rest:bad option strings |
| Record header + gates | FUN_8003BDE0; decoder man_field_scripts::partition_record_span |
| Spawn routes | Op 0x44 at FUN_801DE840 (ra 0x801DF098); tile trigger FUN_801D1EC4 → FUN_801D5630 → FUN_8003BDE0 (ra 0x801D218C); exec breakpoint = 5 hits across the opening |
Cross-context 0xF8 | FUN_8003C83C(0xF8) → _DAT_8007C364 |
| Camera | FUN_801DE084, FUN_801DD310 (mover), FUN_801DC0BC (per-frame), FUN_800172C0 (view), FUN_80026988 (rotation, LUT 0x80070A2C; FUN_8001CF50 is the per-node camera-relative variant), base matrix DAT_8007BF10; live capture 2471/2480 axis values |
| Roller | FUN_80037174, template DAT_801F28A0, allocator FUN_80020DE0, page measure FUN_8003CA38; per-scene geometry from pixel capture |
| Caption TIM | PROT 0749 at LZS offset 0x01EC30; zero UI text/blit draws in the caption window; string absent from a full RAM dump |
| Channels | FUN_8003A1E4 per placement from FUN_8003AEB0; anim tick FUN_8003BC08 → FUN_80021DF4 (13 channels on opdeene) |
| Fade ops | FUN_8003C5F0; op-0x34 arm at 0x801E1FB0 |
| Gold grade | VRAM CLUT peek vs disc TIMs, zero mismatches; render-node walk IR0 = 0; engine ground pixel-matched |
| Battle intro | FUN_801CE8CC, FUN_801CF5BC, DAT_801D2458 (PROT 0979) |
The per-STR trigger capture corpus
Nine save states, one per fmv_id 0..=8, captured immediately before each movie starts, pin the trigger-side state: _DAT_8007BA78 = the expected id, _DAT_8007B83C = 0x1A, BGM id 2000, active scene map01. The states were produced through the dev menu's register editor (FUN_801DBD04 family), not by stepping a scene's trigger op, so they prove the id/mode tuple across the whole range but not the per-scene assignment - that comes from the MAN walk above. There is no per-movie event record carrying post-play flags; the dev menu's “jump to beat” is the MAP CHANGE warp appliers plus the event-flag editor.
History: superseded readings
- The dispatch table was read with a 64-byte stride and paired the wrong slot halves; the shift instruction pins 32 bytes. With the right stride the last three scenes play
MV4/MV5/MV6rather than “vestigial triggers at cut movies”. - A “compact MV table” at
0x801CAE40was the libcdCdlFILEdirectory cache read 8 bytes out of phase. - The confirm-press flag-26 packet was read as the required hand-off into
town01; it is the skip, and the natural chain arrives through the tile trigger. Op0x44was labelled COUNTER before the probe showed it spawning a record. - Reading the GTE matrix from a save state does not recover the cutscene camera - it is the last-rendered object's composed transform (row norms ≈ 6). The camera comes from the op-
0x45globals.
More on do-not-re-walk.