Cutscene (STR mode)
How Legaia plays its pre-rendered movies - and why the famous opening narration turns out not to be a movie at all. STR is the PSX's standard movie container: video frames decoded by the MDEC (the console's dedicated video-decompressor chip) interleaved sector-by-sector on the disc with XA audio (compressed sound the CD drive plays as it streams past). The retail game drives playback through game modes 26 and 27 (StrInit / StrMode); the clean-room port maps them to SceneMode::Cutscene and reimplements the MDEC decoder in crates/mdec.
Two kinds of cutscene
Legaia has two cutscene families, and telling them apart matters. The six MV*.STR files on the disc are true pre-rendered movies, played by a dedicated player. But the New Game opening - the "Genesis tree" prologue narration - is not one of them: it is an in-engine 3D cutscene chain, five ordinary field scenes scripted to play themselves with zero input (see the five-scene chain below). MV1.STR, the movie most people assume is the intro, is actually the title-screen attract reel.
Everything on this page comes from tracing the original PS1 code with a disassembler (Ghidra); FUN_80xxxxxx names are RAM addresses of functions in that trace. The retail movie player itself lives in an overlay - a chunk of code the game streams from the disc into RAM on demand - rather than in the main executable, which is why its addresses sit in the 0x801C0000+ overlay window.
Game modes
| Index | Name | param | next |
|---|---|---|---|
| 26 | STR (StrInit) | 0x80A | - |
| 27 | STR MODE (StrMode) | 0x000 | ConfigInit |
StrInit bootstraps the cutscene: opens the STR stream, initialises the MDEC decoder, starts the XA audio. StrMode runs the per-frame loop: reads the next batch of sectors, decodes a frame, blits it full-screen, and advances the audio position. When the stream ends, the mode chain transitions to ConfigInit (index 1). The retail handlers are statically decompiled from PROT 0970 - see the retail playback engine below.
The retail handler is overlay-resident. Its source is pinned: PROT 0970 (entry 0970 of PROT.DAT, the disc's single big archive; CDNAME label cutscene_str), loading at the slot-A base 0x801CE818 - the shared swappable overlay window the field / battle / menu / minigame overlays also occupy, one at a time. It is identified statically from the disc by its MV*.STR movie paths + MDEC decoder strings - so the handler is Ghidra-importable straight from the disc (asset overlay ghidra) without a live capture; see static overlay pipeline. Only runtime values still need a capture.
STR sector format
STR video is carried in 2048-byte Mode 2 Form 1 sectors. Each sector's user-data area starts with a 32-byte sector header; the remaining 2016 bytes are the demuxed-frame payload. Concatenating the [0x20..2048] payload of every sector of a frame (in arrival order) reconstructs that frame's demuxed bitstream, which begins with the Iki frame header.
Offset Bytes Field
0x000 2 magic - 0x0160 = video sector; any other value = non-video, skip silently
0x002 2 type - 0x8001
0x004 2 chunk_number - 0-indexed position of this sector within the frame
0x006 2 chunks_per_frame - total sectors needed to complete this frame
0x008 4 frame_number - sequential, wraps at 0xFFFF
0x00C 4 frame_size_bytes - total demuxed bytes across all chunks for this frame
0x010 2 width - frame width in pixels (multiple of 16)
0x012 2 height - frame height in pixels (multiple of 16)
0x014 12 replicated frame-header copy + zero padding (not used by the decoder)
0x020 2016 demux payload chunk
StrFrameAssembler accumulates sector payloads in arrival order. When chunk_number + 1 == chunks_per_frame, the demuxed frame is returned truncated to frame_size_bytes. Non-video sectors are skipped silently.
Implementation: crates/mdec/src/str_sector.rs
Retail playback engine (STR overlay + SCUS St streaming library)
Statically decompiled from PROT 0970 at base 0x801CE818 plus the SCUS-resident PsyQ-libpress-shape "St" streaming library it calls; byte-identical against the FMV-resident RAM capture.
Master dispatch - FUN_801CEA3C
The mode-26/27 entry. Pre-play it selects the bitstream decoder (DAT_801E09FC: Iki by default; dev slots 9/10 select the STRv2/v3 VLC-table decoder), clears the letterbox bands, and calls the play loop on the dispatch slot 0x801D0A6C + fmv_id * 0x20 (32-byte stride - sll v0,v0,0x5; an earlier 64-byte reading paired wrong slot halves, see the corrected table). Post-play it hands control back per fmv_id: mid-game slots copy a return-scene label from 0x801CE8AC into the next-scene name global 0x80084548 + a spawn word at 0x80084540 and set game mode 2; the intro hands off to mode 0x16 (22 = CARD init).
Play loop - FUN_801CF098
CdSearchFile resolves the slot's path, the loop seeks (start_frame − 1) × 10 sectors in, opens the SPU CD input for the interleaved XA (SpuSetCommonAttr CD volume 0x7F + CD-mix enable), then per frame: poll a complete demuxed frame, VLC-decode into the MDEC-code double buffer, DMA to MDEC, LoadImage the decoded strips into the slot's rect in VRAM (the PS1's video memory). Exit on the demuxer's end-frame latch - or a pad press (_DAT_8007B850 & 0x1F0) only when fmv_id == 0: the intro is skippable, mid-game FMVs are not. Teardown mutes the CD mix and issues CdlPause.
Frame-demux state machine (SCUS St library)
The ring lives in the streaming asset buffer (_DAT_8007B85C + 0x10000, 32 sectors; StSetRing FUN_8005BBF8 / StSetStream FUN_8005EDC4). The read runs Setmode 0xE0 (Speed|RT|Size1 - XA realtime play on, sector filter off) + CdlReadS (FUN_8005EB68). The data-ready demuxer FUN_8005ECD4/FUN_8005F024 gates each sector on magic 0x160 + stream number (type >> 10 & 0x1F), skips to start_frame in its seek state, accumulates sequential chunk_number payloads into per-frame ring slots (status 2 = complete; ring-full drops the frame), and latches end_frame on chunk 0. The overlay consumes frames via StGetNext FUN_8005EF40 / StFreeRing FUN_8005EE4C - the retail counterpart of the engine's StrFrameAssembler.
Bitstream decode + MDEC feed
DAT_801E09FC selects Iki (FUN_801D0378, retail: LZSS qscale/DC table via FUN_801D0604 + GTE leading-zero-count VLC scan) vs STRv2/v3 (FUN_801D070C, dev slots 9/10 only - dead in retail, no released movie uses it). FUN_801CFD84/FUN_801CFFDC run the MDEC DMA-0 feed; the out-slice callback FUN_801CF56C blits each 32-px strip; sync waiters FUN_801D0100/FUN_801D0198 + timeout dump FUN_801D0248 carry the MDEC_in_sync/MDEC_out_sync identity strings.
Those five (with the reset FUN_801CFEE0, MDEC_rest:bad option) are the clean-room boundary of this subsystem and the only part of the decode path that is not ported. Everything above them - the play loop, ring and stream setup, frame pump, slice callback, output control word - is a decision about the bitstream and has a crates/mdec counterpart. These are instead MDEC command/status register writes, DMA channel kicks, busy spins on a 0x100000-iteration budget and a printf of the FIFO bits: they describe a chip the software decoder does not model.
The STRv2/v3 path is a bit-prefix lookup, not a run/level VLC: the lookup table (unpacked at runtime into DAT_801E0A00 by FUN_801F1A00, called unconditionally once per FMV even for Iki slots that never read it) stores the pre-baked MDEC output codes - one to three per hit, plus a per-entry bit length - carved into four regions: luma DC, chroma DC, AC primary, AC secondary. Only the DC coefficients (raw 10-bit in v2; size-prefixed predicted differences chained per channel in v3), the 0x7C1F-escape raw codes and the end padding are computed. Both halves are ported: legaia_mdec::strv2_table (the unpacker, reachable as mdec strv2-table <overlay>) and its consumer legaia_mdec::strv2_decode::decode_frame. With no retail movie on this path there is no golden decode to check against; the port's tests pin the distinct code paths against the disassembly.
XA channel selection
Two distinct retail paths - and the STR overlay holds no channel selector:
- STR FMVs: no channel selection at all. The streaming read enables
CdlModeRTwithoutCdlModeSF, so the drive hardware-plays every ADPCM-flagged sector - and eachMV*.STRinterleaves exactly one XA track at(file 1, chan 0)(stereo 37.8 kHz 4-bit; raw-subheader-verified across all six movies). Audio selection is file + frame-range selection via the dispatch table; the multi-channel\DATA\MOV.STRcontainer the old hypothesis invoked is a dev leftover absent from the disc. - XA clips (
XA1.XA..XA34.XA- voice banks + streamed music):CdlSetfilter. The SCUS clip starterFUN_8003D53C(clip_id, chan, duration)reads the 8-byte[CdlLOC][u32 len]clip table at0x801C6ED8- sloti= fileXA<i+1>(34 slots, title-capture-pinned, lengths byte-exact vs the disc files; filled at boot byFUN_801CFA78in PROT 0895init.pak, which sprintf-generates\XA\XA%d.XA;1per slot and stages[BCD-MSF][size]via the ISO9660 lookupFUN_8005DBB4, called once from the init boot tick0x801CF500) - and its CdSync callback SMFUN_8003D764sequencesCdlSeekL→ Setmode0xC8(Speed|RT|SF) →CdlSetloc→CdlSetfilter {file = 1, chan}→CdlReadS→CdlGetlocPpolling to the end LBA. Every XA sector on the disc carriesfile_no = 1; the channel is caller-supplied - e.g. the menu voice dispatcherFUN_8004FCC8derives clip slot(id − 0x100) >> 3(remapped1/3/5 → 0x1A/0x1B/0x1C) andchan = id & 7.
So the complete channel map: movies = one track per file at (1, 0), selected by fmv_id → MVn.STR + frame range; XA files = (1, chan) inside XA<clip_id + 1>.XA, channel picked per cue. The per-channel content is extractable via xa demux-disc-all (316 channels across 34 files); the per-caller (clip_id, chan) cue census is catalogued in docs/subsystems/audio.md.
The pure computations of the dispatch chain - the id → (clip_slot, channel) mapping, the length-field → duration_sectors scale (len·60+99)/100, and the starter's end-LBA offset (duration·150+149)/60 clamped at 0x2A30 - are ported in legaia_engine_shell::xa_clip; the CD-control / CdlSetfilter state machines around them stay hardware-side and unported.
MDEC decoder (Iki bitstream)
MdecDecoder::decode_frame(frame) converts a complete demuxed frame into an RGBA8 pixel buffer. Legaia's movies use the PSX “Iki” bitstream variant, not the common STRv2 layout: the per-block DC and quantization scale are not in the entropy bitstream - they live in an LZSS-compressed lookup table right after the frame header, and the bitstream carries only AC coefficients. (Legaia overwrites STRv2's header qscale/version fields with the frame width/height, which is what a strict STRv2 parser rejects.) Clean-room port; sources: PSX-SPX BS-compression pages + jPSXdec's PlayStation1_STR_format.txt (format docs only).
1. Frame header (10 bytes)
Offset Bytes Field
0x000 2 mdec_code_count
0x002 2 0x3800 magic
0x004 2 width
0x006 2 height
0x008 2 lzss_size - byte length of the compressed qscale/DC table that follows
2. LZSS qscale/DC table
The lzss_size bytes after the header decompress to a block_count × 2-byte table. Control byte, bits tested LSB-first: a 0 bit copies one literal byte; a 1 bit is a back-reference - a length byte (+3, range 3–258) then a 1- or 2-byte offset (high bit of the first byte selects the 2-byte form; offset is +1, relative to the current output position; overlapping copies allowed). For block i the packed word is (table[i] << 8) | table[i + block_count]: top 6 bits = quant scale, low 10 bits = signed DC.
3. AC bitstream
Read as 16-bit little-endian words, MSB-first within each word, beginning immediately after the compressed table. Per block: AC run/level codes from the PSX VLC table, terminated by the End-of-Block code 10. The escape code 000001 is followed by a 16-bit raw MDEC value (run << 10 | signed-10-bit level). A block that fills all 63 AC positions is still terminated by an explicit EOB, so the decode loop always reads the next code rather than stopping when the coefficient index saturates.
4. Dequantize + IDCT
DC: coef[0] = DC * Q_MAT[0]. AC: coef[zigzag[i]] = (level * Q_MAT[i] * qscale + 4) >> 3 (arithmetic shift = floor; not range-clamped, since escape codes carry large levels). Two-pass separable 8×8 IDCT using IDCT_C[k][n] (pre-scaled by 2048); the row pass keeps full i64 precision and the single >> 24 after the column pass normalises a DC-only block to coef[0] / 8.
5. Macroblock layout
Each macroblock decodes 6 × 8×8 blocks: Cr, Cb, Y0 (top-left), Y1 (top-right), Y2 (bottom-left), Y3 (bottom-right). Macroblocks are laid out column-major: down each 16-pixel column top-to-bottom, then the next column to the right.
6. 4:2:0 upsampling + BT.601 colour conversion
Each Cb/Cr sample covers a 2×2 luma region. PSX MDEC outputs signed (zero-centred) samples, so the luma is offset by +128 on the final RGB.
R = (Y+128) + ((91881 * Cr) >> 16)
G = (Y+128) - ((22554 * Cb + 46802 * Cr) >> 16)
B = (Y+128) + ((116130 * Cb) >> 16)
A = 255
Output is a width × height RGBA8 buffer in row-major order.
Implementation: crates/mdec/src/lib.rs (MdecDecoder, AC_CODES, iki_lzss_decompress, IDCT_C, Q_MAT). The disc-gated str_mdec_decode_is_pixel_stable test pins a decoded-frame fingerprint as a regression guard.
XA audio
XA-ADPCM audio is carried on Mode 2 Form 2 sectors with submode & 0x24 == 0x24. The demuxer splits them by (file_no, ch_no) into per-channel streams. Each 128-byte sound group holds 8 sound units of 28 4-bit ADPCM samples; for stereo the left channel is the even units (0,2,4,6) and the right channel is the odd units (1,3,5,7), output L,R interleaved. The decode is bit-exact against an external lossless reference decode of a real cutscene track - every interleaved sample matches.
See docs/formats/xa.md for the full sector layout, coding-info bit definitions, filter coefficients, and the per-sound-group decode (parameter/nibble layout + full-precision predictor that keeps loud passages from distorting).
Interleaved cutscene audio (A/V sync)
The six MOV/MV*.STR movies interleave their audio with the video at the sector level: video sectors (Mode 2 Form 1, magic 0x0160) and one XA track (Mode 2 Form 2, all on file/channel (1, 0), stereo 37.8 kHz 4-bit) share the same LBA range. The audio needs no name-based pairing - it is pulled from the same sector stream as the video, so the two are aligned by construction.
The Form-1 extract under extracted/MOV/*.STR keeps the video intact but truncates each Form-2 audio sector (2324 → 2048 bytes), corrupting the audio. Faithful playback reads the raw 2352-byte sectors straight off the disc image: cutscene_av::decode_str_av_from_disc makes one pass over the sectors, routing Form-2 audio to a per-channel buffer and the rest to StrFrameAssembler, then decodes the dominant audio channel to PCM and the video to RGBA frames.
The PCM is staged into the engine audio output (AudioOut::play_xa) and the video clock is driven off the audio cursor (AudioOut::xa_cursor_secs): the visible frame is audio_position / frame_period (cutscene_av::due_video_frame), so the picture stays locked to the soundtrack rather than free-running on a separate timer. With no audio track the same function falls back to wall-clock pacing.
There is no per-cutscene channel map to recover: the retail STR player reads with the sector filter off (CdlModeRT without CdlModeSF), so it plays whatever single XA track the movie interleaves - see XA channel selection for the traced mechanism and for the CdlSetfilter path the separate XA*.XA clips use. The 8-bit ADPCM coding mode is decoded (BitsPerSample::Eight: 4 sound units per group, full-byte samples); no 8-bit audio appears in the movie corpus, so that path is covered by synthetic unit tests rather than a bit-exact reference.
Playback loop (play-str)
legaia-engine play-str <file> demonstrates end-to-end decoding. Two modes: play-str <file> plays a raw filesystem STR file as video only (the extract truncates the audio); play-str MOV/MV1.STR --disc <bin> resolves the movie inside the disc image and plays it with its interleaved XA audio in sync.
- Decode video frames + (disc mode) the audio track up front (
cutscene_av::decode_str_av_from_disc/decode_str_video_only). - Stage the decoded audio into
AudioOuton the first redraw so the audio cursor and the picture start together. - On
RedrawRequested: show the frame due at the current playback position (cutscene_av::due_video_frame) and render fullscreen. With audio the position is the audio cursor; without audio it is wall-clock. Either way the movie plays at its real rate, not the display refresh rate.
Frame-rate detection
PSX STR files carry no frame-rate field; the rate is implied by how many CD sectors elapse per frame at the 2x delivery rate (150 sectors/s). The raw 2048-byte-per-sector files preserve on-disc sector order 1:1 (audio sectors appear as skipped chunks), so the mean sectors-per-frame recovers the authored rate: fps = 150 / (total_sectors / video_frame_count). legaia_mdec::str_sector::analyze_str_timing computes this and StrTiming::frame_period returns the per-frame hold duration (falling back to the canonical 15 fps for a degenerate stream). All six Legaia movies measure exactly 10 sectors/frame → 15.00 fps (MV1 = 1345 frames = 89.7 s).
# Report frame inventory + detected frame rate
mdec scan-str cutscene.str
# Decode all frames to PPM images
mdec decode-str cutscene.str --out-dir frames/
# Play in a window (video only)
legaia-engine play-str cutscene.str
# Play a disc movie with its interleaved XA audio, in sync
legaia-engine play-str MOV/MV1.STR --disc "Legend of Legaia (USA).bin"
CDNAME → STR override map
Engines can override the hard-coded heuristic resolver in cutscene_str_for by handing play / play-window a TOML config:
# legaia-cutscene-map.toml
[scenes]
opdeene = "MOV/MV1.STR"
opstati = "MOV/MV2.STR"
opkorout = "MOV/MV3.STR"
opurud = "MOV/MV4.STR"
opmap01 = "MOV/MV5.STR"
edteien = "MOV/MV6.STR"
# Generate a starter file pre-seeded with the heuristic mapping
legaia-engine config dump-cutscene-map --out legaia-cutscene-map.toml
# Run with the override
legaia-engine play --scene opdeene --cutscene-map legaia-cutscene-map.toml
legaia-engine play-window --scene opdeene --cutscene-map legaia-cutscene-map.toml
The map layers on top of the heuristic: explicit entries win, missing keys fall through to cutscene_str_for. API: CutsceneMap::from_toml_path / from_toml_str / to_toml_string. The retail mapping is decoded straight from the disc - the fmv_dispatch table plus the MAN-carried per-scene triggers (below); the TOML layer remains as an engine-side override surface.
STR/MDEC FMV overlay residency
The retail StrInit / StrMode handlers live in a dedicated overlay distinct from the dialogue overlay - PROT 0970 (cutscene_str), a slot-A overlay at base 0x801CE818 (pinned statically; see static overlay pipeline). The residency window below is from a save state during FMV playback; the addresses match the disc entry loaded at that base.
Pinned data structures inside the residency window (captured from a save state during FMV playback):
| Address | Size | Stride | Contents |
|---|---|---|---|
0x801CAE08 | variable | 24 B | libcd CdlFILE directory cache (the MOV dir while an FMV plays) |
0x801CCA80 | 336 B | 56 B × 6 | ISO9660-shape directory record copies of the same six files |
0x801CE810 | ~150 B | variable | Path-string table (\DATA\MOV.STR;1, \DATA\MOV15.STR;1, \MOV\MV1A.STR;1, \MOV\MV6..MV1.STR;1) |
0x801CE8AC | ~50 B | variable | Post-FMV return-scene labels (CDNAME shape) |
Directory-record cache
The 24-byte records at 0x801CAE08 are PsyQ CdlFILE structs - [u32 CdlLOC][u32 size][char name[16]] - libcd's CdSearchFile cache for the last directory searched, not an FMV structure. An earlier name-first parse ("compact MV table at 0x801CAE40") was phase-shifted 8 bytes; the apparent one-entry shift dissolves at the CdlFILE phase. See the STR FMV table format page.
Post-FMV return scenes
The FMV overlay's data section carries the CDNAME labels of seven field scenes - distinct from the op* / ed* engine cutscene scenes:
town0b map01 chitei2 map02 jou uru2 town0e
These are the destinations the master dispatch FUN_801CEA3C hands control to after playback: one label per mid-game fmv_id (1..4, 6..8), written to the next-scene name global 0x80084548 with a spawn/door word at 0x80084540. They are not the trigger-op scene list (only chitei2 overlaps - a scene that returns to itself).
Field-VM FMV-trigger op
The field VM triggers an FMV via a 7-byte instruction sub-dispatched off opcode 0x4C:
0x4C 0xE2 lo hi _ _ _ ; PC advances by 7
^^^^^^
i16 LE fmv_id (sign-extended through FUN_8003CE9C)
Outer opcode 0x4C enters the field-VM dispatcher's high-nibble re-dispatch at FUN_801E0C3C (JT base 0x801CEE60). The high nibble of byte 1 selects the secondary handler; the low nibble selects the inner sub-op. For byte 1 = 0xE2:
| Step | Address | What it does |
|---|---|---|
| Outer dispatch | 0x801DE94C..0x801DE980 | andi 0x7F, subtract 0x21, jump through 47-entry JT at 0x801CECC0. PC += 1. |
| Outer JT entry | 0x801CED6C | Outer op 0x4C → handler 0x801E0C3C. |
| High-nibble JT | 0x801CEE70 | byte1 >> 4 == 0xE → handler 0x801E3040. |
| Sub-op JT | 0x801CF010 | byte1 & 0xF == 0x2 → handler 0x801E30E4. |
| FMV handler | 0x801E30E4 | _DAT_8007BA78 = (s16)bytecode[2..3]; _DAT_8007B83C = 0x1A (next game mode = 26 = StrInit). PC += 6. |
The two globals it writes are the only side-effects:
_DAT_8007BA78- FMV index. Read by the str_fmv overlay's master dispatch to select a 32-byte dispatch-table slot from0x801D0A6C. On retail USA the table has 23 slots; the nine retail movies occupyfmv_id 0..=8- exactly the corpus range - and everyMVn.STRon the disc is dispatched (MV3.STRcarries four segments by frame range; slots 9+ are dev files absent from the disc). The table is static overlay data, decoded from the disc bylegaia_asset::fmv_dispatch; see the STR FMV table format doc for the mapping._DAT_8007B83C- next-game-mode global. Setting it to0x1A(decimal 26) kicks the main mode dispatcher (FUN_80017714) intoStrIniton the next frame, which loads the str_fmv overlay and reads_DAT_8007BA78to pick the file.
The field-VM port handles this op as op4c_n_e_sub2_fmv_trigger(fmv_id: i16) in legaia_engine_vm::field and the world's FieldHostImpl records the request as World::pending_fmv_trigger plus a FieldEvent::FmvTrigger { fmv_id }.
The world drives the Field → Cutscene → Field flow itself, mirroring the retail next-game-mode dispatch: the next World::tick consumes the pending trigger at the top of the frame (one frame after the op fires, just as the main dispatcher reads the next-game-mode global a frame late). If the id resolves to a playable slot it flips World::mode into SceneMode::Cutscene and records the active FMV (World::active_fmv()), suspending the field VM while it plays (the STR overlay owns the frame in retail). A fmv_id whose slot points at a dev/missing path drains as a no-op. The resolver (fmv_index_to_str_filename) mirrors the retail nine-slot map, with a sibling carrying the post-play return scenes; the disc-parsed legaia_asset::fmv_dispatch::FmvTable is the authoritative source.
The host polls World::active_fmv_str_filename(), plays the resolved MV*.STR, and calls World::finish_cutscene() when playback ends, returning to the field with the field-VM program counter already past the op. The legaia-engine play loop runs this flow headlessly, decoding the resolved STR via MDEC to report its frame count; the windowed play-window host plays it in the engine window - it decodes the resolved MV*.STR (shared cutscene_av module with play-str), suspends world ticks, shows the video one frame per redraw, then calls finish_cutscene() and resumes the field once the frames drain.
Where the player ends up afterwards is one kernel, not one per host. Retail does not put the player back where the movie started: the master dispatch writes a next-scene label, so triggering the town01 movie lands you in town0b. That transfer needs to load a scene, so it cannot live on the world alone - which is how it came to be written into the headless play loop and left out of the window and the browser page, both of which simply resumed the trigger scene. World::finish_cutscene now parks the finished id and SceneHost::apply_pending_fmv_handoff performs the transfer; the id is a drained edge, so every host can poll it and only the first transfers. Skipping the movie is not skipping the hand-off - a cut slot, an undecodable stream and the browser page's no-video auto-skip all still move the player, because retail writes the scene globals whether or not the picture played.
Booting from a disc image reads the movie straight from the ISO with its interleaved XA audio (the scene BGM sequencer is paused for the duration; the video is paced off the audio cursor); booting from an extracted root plays video only.
The trailing 3 bytes of the instruction are reserved by the dispatcher's PC math (the handler's addiu s8, s8, 6 is fixed, but only bytes +1..+3 are read). Disassemblers should leave them as opaque padding.
Static FMV-trigger sites - exhaustive
A backward sweep of every Ghidra dump in the corpus surfaces three writers of _DAT_8007B83C = 0x1A in retail. The first two are codified in legaia_engine_vm::cutscene_trigger as FMV_TRIGGER_SITES; the third is pinned via a PCSX-Redux watchpoint on the title-attract countdown.
| Site | Function | Mode-write addr | FMV-id source | Trigger condition |
|---|---|---|---|---|
field_vm_op_4c_e2 |
FUN_801DE840 |
0x801E3104 |
decode_u16_be(pc+1) from field-VM bytecode |
Field-VM bytecode hits 0x4C 0xE2 lo hi; reached via JT chain 0x801CEE60 (high nibble 0xE) → 0x801CF008 (low nibble 0x2). |
title_attract_loop (FUN_801DE234 label) |
FUN_801DD35C (label FUN_801DE234) |
0x801E0F50 |
Hardcoded 0 (= MV1.STR, intro) |
Title-screen idle countdown DAT_801ef16c underflows. |
title_tick_inline |
FUN_801DD35C |
0x801DDCF0 |
Inline: sh zero, -0x4588(v0) zeroes _DAT_8007BA78 at 0x801DDCE8 immediately before (= MV1.STR). |
Inline fall-through past the decrement instruction at 0x801DDCCC (bgez v0, 0x801DFC3C not taken). PC-verified via the live capture in boot - title-overlay state. |
Both title-side sites live in the same outer function FUN_801DD35C (the per-frame title-overlay tick); FUN_801DE234 is a Ghidra-promoted label inside its body. The 0x801DDCF0 site is the one the watchpoint pins in practice - every per-frame decrement passes through 0x801DDCCC and the underflow path immediately writes the mode-byte before any sub-call.
FUN_801E30E4 has zero static callers. It is a label inside FUN_801DE840, not a callable subroutine - Ghidra promotes it to a FUN_ symbol because the JT entry at 0x801CF008[2] resolves there. The actual control flow is the chain above.
Per-scene trigger assignment is disc-sourced
A raw bytewise PROT scan can't see the trigger ops because the scene scripts live LZS-compressed inside each scene's MAN. Decompressing every scene MAN and walking its partition-1 scripts with the field-VM disassembler (man_field_scripts::scene_fmv_triggers, the 0x3F-destination walk's sibling) recovers the full assignment statically - town01 → 1, garmel → 2, deroa / chitei2 → 3, dohaty → 4, town0d → 6, uru → 7, jouine → 8; one op per scene, no other scene MAN carries one. Under the corrected 32-byte dispatch stride the last three play MV4 / MV5 / MV6 - the earlier "vestigial triggers at cut movies" reading is superseded. Pinned by the disc-gated scene_fmv_triggers_disc test.
The FMV overlay's seven-label scene list (town0b map01 chitei2 map02 jou uru2 town0e) is therefore not the trigger-scene set (only chitei2 appears in both); those labels are the post-play return-scene table. Outside the MAN-carried scripts, a raw sweep keeps taiku (fmv_id 5 - the fourth MV3.STR segment, the one "stay in the current scene" slot, which fits a taiku trigger) and opmap01 / koin1b (fmv_id 7) as uncontextualized byte candidates in non-MAN structures.
Per-STR FMV trigger corpus
The corpus carries nine save states captured immediately before each FMV begins playing, one per _DAT_8007BA78 value (fmv_id ∈ 0..=8). They pin the trigger-side state across the full retail range:
_DAT_8007BA78 = expected_fmv_id(s16 LE) for each of nine saves_DAT_8007B83C = 0x1A(StrInit) for every save_DAT_8007BAC8 = 2000(BGM ID) for every save- Active scene =
map01for every save (one of the seven mid-game FMV-trigger field scenes) recover_base()=0x80139530(map01's field-pack base) for every save
The 0x4C 0xE2 lo hi byte sequence does NOT appear in the field-pack RAM region for any save - the corpus was generated by debug-menu-driven trigger paths, NOT by stepping the field VM through a per-scene FMV trigger op. So the corpus pins the (fmv_id, game_mode) tuple across the full 0..=8 range but does not disambiguate which fmv_id each of the seven mid-game scenes' field-VM bytecode writes at runtime - that gap is still gated on intra-transition field-pack projection capture.
The debug-menu mechanism itself is pinned: the two direct _DAT_8007BA78 store sites are corpus-exhaustive (raw-byte scan of all 1235 PROT entries, every addressing form) - the dev menu writes the global through its register-pointer editor (FUN_801DBD04 family, field overlay 0897). There is no per-FMV "event record" carrying post-play teleport/flags; the debug "jump to beat" behaviour is the MAP CHANGE warp appliers (FUN_801EE094/FUN_801EE328) plus the EVENT FLAG editor.
The corpus is codified at legaia_engine_core::capture_observations::cutscene_trigger_corpus and exercised by the disc-gated test cutscene_trigger_corpus_pins_fmv_id_across_nine_saves.
FMV play loop (decoded from disc)
The play loop FUN_801CF098 (1236 B) is reached from the master dispatch's selector (_DAT_8007BA78 * 0x20 + 0x801D0A6C); that dispatch table is static overlay data decoded straight from the disc (legaia_asset::fmv_dispatch): each fmv_id's movie + frame range, used to seek to the right segment (cutscene_av::fmv_segment_window). The full loop + demux state machine are decoded - see the retail playback engine.
In-engine 3D opening (the five-scene New-Game chain)
Not every cutscene is an STR FMV. The New Game opening - the "Genesis tree" prologue narration - is an in-engine 3D cutscene chain, field scenes running in master game-mode 0x03 (field RUN), not a MOV/MVn.STR video. (MV1.STR is the title-attract movie; the opening 3D sequence is engine-rendered.)
NEW GAME boots opdeene (CDNAME/PROT #748) and the opening then chains through five scenes with zero input (PCSX-Redux cold-boot pixel capture; disc-gated oracle opening_full_chain_e2e.rs): opdeene (creation-myth crawl, 14+8 pages; timeline P2[18] spawned by op 0x44 in the P1[0] entry script, ends with a 0x3F SceneChange) → opstati (Seru intro, 3+6 pages; op 0x44) → opurud (Mist story, 4+3+5 pages; op 0x44) → map01 (the world-map fly-in: static “twilight of humanity” title card + a 5-page crawl over an aerial approach of Rim Elm; P2[38] via the walk-on tile trigger) → town01 at tile (0x1D, 0x5B) (establishing pan → name entry → Vahn's walk-out; P2[3], tile trigger, one-shot C1 gate 0x225). A confirm press after opdeene arms GFLAG 26 fires the FUN_801D1344 town01 packet = the intro SKIP (its former reading as the required hand-off gate is superseded).
Record spawn mechanisms (live-probe-pinned; exec-BP on FUN_8003BDE0 = exactly 5 hits): op 0x44 SPAWN_RECORD in the entry system script (FUN_801DE840 case 0x44, ra 0x801DF098; the operand is a GLOBAL record index re-based - N0 - N1 into partition 2, gate forced to 1 - the old “COUNTER” reading is superseded), and the walk-on tile trigger (FUN_801D1EC4 → FUN_801D5630(1, x, z) → FUN_8003BDE0(x, z, rec[2], rec[3]), ra 0x801D218C): kind-1 records [tile_x][tile_z][p2_record][gate] in the scene .MAP's +0x10000 trigger block and its +0x12000 fallback window; the entry SEAT lands on the trigger tile and fires the same tick. gate = 1 spawns; gate = 0 records are object-binds consumed at scene init (FUN_8003A55C). FUN_8003BDE0 checks the record's C1/C2 story-flag gates against the bitmap at DAT_80085758 (bit = byte[flag>>3] & (0x80>>(flag&7)); C1 = block if ANY set, C2 = require ALL). Engine: field_regions::{TileTrigger, parse_tile_triggers, lookup_tile_trigger}, Scene::field_tile_triggers, World::install_spawned_record / install_gated_p2_record, VM hook op44_spawn_scene_record.
opdeene's timeline record (partition 2, record 18; record start at MAN offset 0xA47) is a field-VM script that interleaves camera staging (op 0x45 Camera Configure, a 23-byte payload block; op 0x46 RenderCfg), actors (op 0x23 MoveTo, op 0x34 Effect), the intro-skip arm (op 0x2E GFLAG_SET 26, 2E 1A at 0xA5E), inline narration text, and the terminal 0x3F SceneChange to opstati.
Inline narration format
The on-screen narration is carried as inline ASCII text pages embedded in the timeline script, not as a MES text id. A narration block is introduced by a field-VM op 0x4C in its outer-nibble-8 form with the cross-context extended target 0xF8:
0xCC 0xF8 0x80 N ; op (0xCC = 0x80|0x4C extended), N = page count
1F <ascii…> 00 ; page 1
1F <ascii…> 00 ; page 2
… ; N pages total
Each page is framed 0x1F <printable ASCII> 0x00 - 0x1F (ASCII Unit Separator) starts a page, 0x00 terminates it, the body is plain 7-bit ASCII. The page count N in the introducing op equals the number of 0x1F-framed pages that follow, which both validates the parse and gives a consumer the cadence for revealing subtitles.
A sibling static title-card op [CC F8 89 b1 b2] carries the same page framing but presents differently: the pages show simultaneously while the parent script continues; a later card block with blank pages clears it (the map01 fly-in's “twilight of humanity” card). The parser distinguishes NarrationKind::Crawl (op0 = 0x80) vs NarrationKind::Card (op0 = 0x89); the engine surfaces the card via World::cutscene_card.
opdeene's timeline carries two crawl blocks: a 14-page creation prologue and an 8-page Seru-history block (22 pages total). The clean-room parser is legaia_asset::cutscene_text (parse_narration / narration_pages); it locates the introducing op and the page framing structurally and decodes the runtime disc bytes (no narration text is baked into the repo). Inspect it with legaia-engine man-scripts --scene opdeene --disc <disc>.bin --narration --disasm-partition 2. The disc-gated test opdeene_narration.rs ground-truths the structure (two blocks, 14 + 8 pages, every page non-empty ASCII, declared count matches decoded) without committing the text.
Narration playback - the crawl roller (FUN_80037174)
The opening narration is a bottom-up scrolling crawl, not a one-caption-at-a-time presenter. The [CC F8 80 N] op routes to an on-screen-text actor whose handler is FUN_80037174 (SCUS-static): one roller actor owns all N pages, spawned as a child context so the parent timeline keeps executing while the pages scroll - the camera cuts / fades / WaitFrames authored between the crawl blocks play under the text (cold-boot capture of opdeene crawl-1 shows the eye cut through the Genesis-grove foliage to the villager tableau while the creation crawl scrolls; probe scripts/pcsx-redux/autorun_crawl1_capture.lua). The parent blocks only before a new crawl block and before the record's terminal SceneChange. Each line draws centered with all glyphs at once (no typewriter), scrolling upward inside a clipped window. Pixel-capture-pinned geometry: 0.5 px/frame everywhere except opurud (1.0 px/frame); opdeene = tall window enter ~y188 exit ~y64, 18 px spacing, up to 8 lines visible; opstati/map01 enter ~y203 exit y128, 16 px; opurud enter ~y187 exit y128.
The engine's CutsceneNarration is that roller as a state machine (RollerParams::for_scene carries the capture-pinned per-scene values); the timeline stepper installs each block's pages when its PC reaches the op (NarrationSite) and lets the timeline continue (non-blocking) so the between-block camera cuts play under the crawl - holding only for the last block of a scene (so the terminal SceneChange waits for the final pages) and when a new block would open over a still-scrolling one. The prior model - one caption per page, 120 frames, kills predecessor, Y = 180 / mid-screen - described the separate 4C E1 single-balloon op (spawner FUN_8003C764, handler FUN_801DA7F0): real, but not the crawl. The “It was the Seru.” caption appears between opdeene's two crawls (over the villager tableau) but is not a text balloon at all: it is a pre-rendered 112×32 4bpp TIM (two CLUT palettes = the fade steps) baked into the opdeene geometry pack PROT entry 0749 at LZS offset 0x01EC30, drawn by the scene renderer as a screen-space textured quad. Cold-boot text-path + blit censuses show every UI text/image draw path fires zero times in the caption window, and a full-RAM dump finds the string in no encoding - so the engine blits the scene texture, not a font string. The clean-room port decodes the TIM to RGBA on entering opdeene (cutscene_caption::decode_opdeene_caption), fades it in over the gap after the first crawl scrolls out, and blits one centered alpha-tinted quad - bounded to a retail-like ~2 s beat. The roller's px/frame values are pinned against retail's ~60 Hz field frames, but the sim ticks at 100 Hz, so World::tick advances the roller off a 60 fps sub-clock (field_frame_accum += 60; step = accum >= 100) rather than once per tick - otherwise the crawl scrolls 1.67× too fast and drains ~6 s early. The timeline is paced off the same sub-clock (World::step_spawned_record_contexts): retail counts every record duration in display frames - op-0x4A WAIT_FRAMES and the camera mover both accumulate DAT_1F800393 - so stepping it per sim tick drained waits 1.67× fast. That, not any missing step-parallelism, was the inter-crawl dead-air: retail's actor lists are walked in full every frame (FUN_8002519C), so every context already gets a slice per frame. The correction moved the whole zero-input chain from ~10% short of retail wall-time to within ~4% (opening_chain_wall_time). (tim-scan extracted/PROT/0749_opdeene.BIN.) FUN_8003CF04 is a list finder (walks 0x8007C34C for node[+0xC] == handler && !(node[+0x10] & 8)), not a kill function.
The narration does not gate the town01 hand-off: the roller is timer-driven and the FUN_801D1344 packet is the intro skip - it fires mid-narration too once GFLAG 26 is armed (World::take_prologue_handoff tears down the playing narration / card / timeline). Disc-gated: opdeene_narration_playback.rs + opening_full_chain_e2e.rs.
Roller op operands (Ghidra-traced). The spawner and the crawl-geometry config are two sub-ops of field-VM op 0x4C (MENU_CTRL), dispatched by switch(op0 >> 4) then switch(op0 & 0xF) in FUN_801DE840; both carry the cross-context target 0xF8 (player / camera-anchor actor). Spawn CC F8 80 N (op0 0x80, nibble-8 sub-0): operand N is the page count. The op allocates a child actor from template DAT_801F28A0 via FUN_80020DE0 (which copies template +0x8 = FUN_80037174 into the actor's handler word +0xC), points the child's script pointer +0x90 at the N byte, and leaves the following bytes framed [N][page0]...[page(N-1)]; the parent measures each page (FUN_8003CA38) to skip the block and continues. Geometry config CC F8 E8 ... (op0 0xE8, nibble-E sub-8; 10-byte op): fetches four signed-16 LE words at operand +1/+3/+5/+7; word3 selects the mode. word3 == 0 seeds the three geometry globals at _DAT_801C6EA4 (each defaults if written 0): +0x4C = word0 (default 0x40), +0x4E = word1 (default 0x08), +0x50 = word2 (default 0x04). word3 == 1 finds the live roller by handler FUN_80037174 and either pauses it (word0 == 0 → +0x10 |= 0x80000) or writes the stop trigger +0x52 = word0; word3 == 2 resumes; word3 == 3 unlinks the child. The name “4C 88” is a different op (op0 0x88, nibble-8 sub-8: writes _DAT_80084628/80084624/8008462C), not the crawl geometry - the seed op is specifically the nibble-E sub-8 (0xE8) form. Seed meaning (from the roller's reads): +0x4C = window top Y (line i at Y = +0x4C - subscroll + 16*i, default 64); +0x4E = visible line count (16 px each; bottom clip +0x4C + 16*(+0x4E), clamped ≤ 232; default 8); +0x50 = scroll-cadence divisor (per-frame accumulator advances by DAT_1F800393, steps the 1 px sub-scroll actor+0x9E on reaching +0x50, so px/frame = DAT_1F800393 / (+0x50); default 4); +0x52 = stop-after-N-lines pause trigger (default unset).
Timeline execution model (Ghidra-traced)
The cutscene timeline runs on the same field/event VM (FUN_801DE840) as every other field script - there is no dedicated cutscene executor.
- Record header. Partition-2 records are named records, not the partition-1
[u8 N][N*2 locals][4-byte header]shape. Layout:[u8 name_len][name_len*2 SJIS name][u8 C0][C0 bytes][u8 C1][C1*u16][u8 C2][C2*u16]<script>. The name length is in characters; the three condition-list gates are story-flag predicates tested before the record runs (block 1 = OR, block 2 = AND; block 0 skipped). Script entry offset =1 + name_len*2 + (1+C0) + (1+C1*2) + (1+C2*2). Foropdeene's record 18 (name_len=6"Opening", all blocks empty) that is0x10- the0x34EFFECT op (an instant colour reset to neutral), immediately followed byGFLAG_SET 26at+0x17. Decoderman_field_scripts::partition_record_span(FUN_8003BDE0). - Dispatch.
FUN_8003BDE0resolves a partition record by index, walks the header, and spawns a VM context (ctx[+0x90]= record base,ctx[+0x9e]= entry PC,ctx[+0x10] |= 0x100); the per-frame runnerFUN_80039B7CloopsFUN_801DE840until a yield. The index comes from the two caller families above - an entry-script op0x44or the walk-on tile trigger (FUN_801D1EC4). - Cross-context target
0xF8. Nearly every op carries the extended-target byte0xF8.FUN_8003C83C(0xF8)resolves to_DAT_8007C364- the player / camera-anchor actor - so the timeline drives the camera/lead actor. - Narration op.
CC F8 80 N(op0x4C, outer-nibble 8, sub-0) spawns the roller child (FUN_80037174) over theNinline pages; the parent timeline keeps running so the between-block camera choreography plays under the scroll. The single-line balloon path (4C E1, spawnerFUN_8003C764→ handlerFUN_801DA7F0: centeredX = (320 − width)/2,Y = 180, 120-frame timer) is a different op - not the opening crawl. - Op
0x4Cnibble-4 sub-9 (4C 49) never jumps here. The cutscene-dialogue overlay's case 9 selects a write variant on two bits of_DAT_1F800394: bit 25 → Delta, bit 24 → player-relative write (+0x4A = value + player_anchor[+0x16]), else Default - always advancing 6 bytes. The field-overlay-0897 dump's absolute-jump arm does not apply to the opening path (live probe:opurud's entry script reaches its44 32at+0x7Awith bit 24 set). EngineSub9State::PlayerRelativereplacesAbsJump. 4C 9Fregister-callback never fires during the opening (live probe: zero exec hits onLAB_801DA930).FUN_8003CF40only setsnode[+0x10] |= 8on an already-live actor whose entry equals the callback - inert when none is live; the engine's host hook reports “already satisfied” during the opening chain.- Camera Configure op
0x45. CONFIGURE (op0 & 0xC0 == 0) reads a big-endian 10-bit mask(op0<<8)|op1; bit(9−i)selects parami, each a signed-16 LE word written to the camera struct at0x801C6EA8 + 0x02 + i*4, then committed byFUN_801DE084(struct, apply_trigger), which maps every param to a camera global:
The full transform isparam struct off global role 0 +0x02_DAT_8007b790pitch (GTE RotMatrixXangle)1 +0x06_DAT_8007b792yaw (GTE RotMatrixYangle / heading)2 +0x0a_DAT_8007b794roll (GTE RotMatrixZangle). Retail authors it - eight scenes stage a non-zero one, from 0.9° to −58°; see settled threads. Nothing in the field-camera build zeroes it; the only clearing write is the scene-entry resetFUN_80025C24.3 / 4 / 5 +0x0e/12/16_DAT_800840b8/bc/c0eye-space translation trio (post-rotation (dx, dy, depth); analog of the battle camera's(0, 1280, 7680)- slot 5 is the eye-back depth)6 / 7 / 8 +0x1a/1e/22_DAT_80089118/1c/20camera focus = the GTE translation (-X, +Y, -Z)9 +0x26_DAT_8007b6f4GTE H projection register (focal length / zoom) via func_0x8003d254=setCopControlWord(2, …)screen = H·(R·(v − focus) + tr_eye)/Ze. The once-per-frame view builderFUN_800172c0assembles it: buildRfrom the angle globals (FUN_80026988), left-multiply the constant base matrixDAT_8007BF10(a uniform24576·I= 6× world scale), copy the eye-space translation trio_DAT_800840B8/BC/C0into the view struct's.t, then MVMVA the negated focus throughRand add.t- giving the uploaded GTE translationTR = R·(−focus) + tr_eye. The rotation buildFUN_8001CF50composesRviaRotMatrixX/Y/Z(0x800461A4/629C/638C, angles masked to 12 bits,4096 = 360°, sin/cos LUT at0x80070A2C, composed with GTEmvmva) - so param 0 is the camera pitch and the zoom is H. The per-frame mover isFUN_801DC0BC(notFUN_801DB510, which is the follow / scroll camera).FUN_801DE084(struct, apply, op0 >> 2 & 0xF)either snaps (apply == 0: write the ten globals, kill any mover in flight) or tail-callsFUN_801DD310, which hands a dedicated mover actor ten(start, end)pairs plus one shared progress / duration / curve. The mover is an ordinary node in_DAT_8007C34Cticked by the per-frame actor walk, so a glide runs in parallel with the record that staged it - a longapplyis a dolly velocity, and a beat landing mid-tween re-seeds every axis from the live pose and restarts the shared progress. Per frame:t = min(t + DAT_1F800393, d), then per axissif untouched,eif arrived, elses + curve_offset(e - s, t, d, curve). BecauseDAT_1F800393is the frame-skip factor,tcounts display frames andapplyis a duration in display frames 1:1. Curves:1(and any other value) linear(k·t)/d;2quadratic ease-out(n + (n/d)(d - t))/dforn = k·t;3quadratic ease-in((k·t)/d · t)/d;4ease-in-out (quad-in overd>>1to the midpoint, then curve 2). Every axis uses the same curve - the angles included, lerped as plain integers with no shortest-arc handling. Arrival is exact; an arrived glide frees its block and the mover dies. Falsified: the earlier per-axis split (“mode 1 eases the angles, runs the eye trio linear”) and the smoothstep model of mode 4. The portlegaia_engine_vm::camera_moverreproduces a live retail capture on 2471 of 2480 sampled axis values. The eye-back depth istr_eye.z(slot 5), not a missing scalar - the eye sits behind the focus bytr_eye(in the 6×-scaled space). Confirmed againstnew_game_cutscene_intro_a: focus(8640, 0, 10304), pitch180, yaw-2967, roll0, H792,tr_eye = (260, 1293, 17145); the focus projects to(792·260/17145 + 160, 792·1293/17145 + 120) = (172, 180)= the party position in that frame's framebuffer.
Timeline execution (engine port)
The engine executes this timeline as a spawned field-VM context. Entering opdeene live, World::load_cutscene_timeline_from_man locates the partition-2 record that issues GFLAG_SET 26 (via man_field_scripts::walk_partition_gflag_sites), resolves its named-record span, and installs a CutsceneTimeline - a second FieldCtx separate from the scene-entry system script on World::field_ctx, seeded on the system channel (script_id = 0xFB) so cross-context (0x80-bit) ops keep running after the record's first yield sets the context halt bit. The opstati / opurud legs install theirs through the faithful op-0x44 spawn (World::install_spawned_record); map01 / town01 through the walk-on tile trigger.
Only cutscene-class records (the opening chain, and gated walk-on beat records via install_gated_p2_record) install as this modal timeline (camera seize + locomotion lock). An ordinary scene's mid-play op-0x44 spawn installs as a concurrent helper context instead - World::helper_contexts (bounded table mirroring retail's small fixed context set), installed by World::install_spawned_helper_record and stepped by step_helper_contexts through the same run-until-yield slice - without seizing the camera, locking locomotion, or reading as cutscene_timeline_active(). Pending spawns queue (FIFO) rather than dropping while another record executes.
World::step_cutscene_timeline runs that context through the same legaia_engine_vm::field::step each frame, run-until-yield (mirroring retail's per-frame dispatch), bounded by a per-frame step budget and a frame cap. The Camera Configure (0x45) and MoveTo (0x23) ops emit the same FieldEvents the runtime Camera folds in; the GFLAG_SET 26 near the record's top arms the intro skip through the same host path the main field VM uses; and the terminal 0x3F SceneChange chains the next opening leg - all by execution. The static arm (World::arm_prologue_handoff_from_man) remains a fallback when the timeline record can't be resolved, plus a frame-cap safety net, so the prologue can never stall.
Spawned-record completion semantics. On-disc partition-2 records have no end opcode - they finish either in a tight Nop+JmpRel-to-self park (the walk-on fog-config / flag-reset ambients) or by looping back to their conversation top as a resident actor-driver (town01's Mei walk-on beat re-enters its loop via an op-0x45 APPLY jump). Retail leaves both spinning forever as parallel contexts, invisible to the player; the engine's modal timeline instead completes at the choreography wrap - a VM Advance jumping backward onto an already-executed PC (a per-record visited map) - so control returns without waiting out the anti-hang frame cap. Real waits (0x4A WAIT_FRAMES, flag-test handshakes) halt at their own PC and never trip the rule. Three more execution rules keep real records on the rails: a record byte with & 0x7F < 0x20 at the PC is the retail dialog-SM transition, not an opcode - a 0x1F lead opens an inline dialog box over the record bytes and parks the timeline (frame cap frozen; confirm dismisses / commits a picker choice, resuming past the segment; stray terminators are consumed); an 0x80-bit op whose target id matches no spawned channel (partition-0 object contexts) is skipped by its decoded width rather than run against the timeline's own context (running it there corrupts the caller and lets the CC 01 A0 channel-busy wait hijack the PC into the record header); and resolved-channel 4C A0 busy-waits fall through unconditionally, since engine channel pokes complete synchronously where retail's channel clears its own busy bit as its move plays out.
Two single-shared-VM accommodations, approximate by design:
- Narration blocks spawn the roller and let the timeline continue. The inline page bytes are data, not opcodes, so the stepper never walks the VM into them:
World::install_cutscene_timeline_recordparses each block into aNarrationSite(op_offset+byte_spanend + pages + kind). A crawl site installs the pages on the roller presenter and, mirroring retail's child-context spawn, advances the PC past the block so the between-block camera cuts play under the scroll - non-blocking. Two exceptions hold the timeline (narration_pc): the last crawl block of a scene blocks until its pages scroll out (so the terminal SceneChange doesn't cut them off), and a block reached while a prior roller is still scrolling holds (narration_pending_open) so two rollers never stack. A card site installsWorld::cutscene_card(blank pages clear it) and the parent continues. (The earlier "park the whole crawl" model is superseded - it serialized the camera cuts after the text instead of playing them under it.) - Camera params. The op-
0x45events flow to theCameracontroller and the host merges each beat's masked slots into a persistentWorld::camera_state.paramsset (mirroring retail'sFUN_801DE084, which writes each masked param into a persistent camera-struct slot; an omitted slot keeps its prior value). This matters: one of opdeene's nine op-0x45beats sets only slot 9 (H) ([(9, 792)]), so a wholesale replace would drop that shot's focus / pitch / eye-depth and snap the camera to thecutscene_viewfall-back framing; the per-slot merge keeps the staged shot and only tweaks the focal length. The set is cleared on scene entry so shots don't leak across scenes. The nativeplay-windowrenders the cutscene with the exact retail GTE model:compute_scene_camera's cutscene branch buildspsx_camera_mvp(pitch, yaw, H, tr_eye, focus)(the samescreen = H·(R·(v − focus) + tr_eye)/Zebuilder the field follow camera uses;FUN_800172c0), composed withFIELD_WORLD_FLIPexactly likefield_follow_camera_mvp.SceneHost'scutscene_viewdecodes the pinned params: focus(-param6, param7, -param8)(Y defaults to retail's0), pitch/yaw from params 0/1, H straight from param 9, and tr_eye = the eye-space translation trio (params 3/4/5,0x800840B8) - the eye-back depth isparam5. There is no eye-distance heuristic: the depth is a real decoded param. Because retail folds a6×world scale intoR(base matrixDAT_8007BF10) while the engine renders geometry at native1×,tr_eyeis divided by6- the perspective divide makes6×-geometry-at-zand1×-geometry-at-z/6project to identical pixels (the samedepth/6trickfield_follow_camera_mvp'sFIELD_CAM_DEPTH = 1200 = 7200/6uses).opdeenesupplies all three offset slots per beat. The shot re-targets each Camera Configure op; rather than cutting,play-windoweases the rendered(focus, pitch, yaw, H, tr_eye)toward each new beat throughwindow::CutsceneCameraInterp, which evaluates the retail curve shapes (camera_mover::curve_unit).apply == 0snaps the targets (a hard cut),apply > 0glides toward them over exactlyapplyretail display frames. opdeene mixes both: the entry shot snaps, but its mid-prologue forest dolly isapply 840paired with a760-frameWaitFrames, so the camera glides continuously while the narration crawl scrolls. The ease steps in retail display-frame time - it diffsWorld::field_frames, the count of sim ticks that mapped to a 60 Hz retail frame - so anapply-paced glide spans its authored display-frame count. Stepping it once per 100 Hz sim tick ran every glide 1.67× fast. The framing is pinned by the disc-freecutscene_framing_tests(focus →(172, 180); a133-unit character subtends the retail ~1/6-frame height, upright). The legacy orbit-radiuswindow::cutscene_camera_mvpis retained only as a unit-tested reference, no longer wired into a render path.
Disc-gated coverage: opening_full_chain_e2e.rs drives the whole zero-input chain (each hand-off + the narration-block cadence) and the confirm-skip path; opdeene_timeline_execution.rs cold-boots opdeene, ticks until the skip bit arms by execution, and follows the terminal SceneChange. The CI synthetic cutscene_timeline_synthetic.rs exercises the executor (GFLAG-by-execution, safety net, idempotent completion) without disc data.
town01 opening timeline + name-entry handoff
The town01 opening (partition-2 record 3) installs two ways - the natural chain arrival fires the walk-on tile trigger at (0x1D, 0x5B) (one-shot C1 gate 0x225), or the intro skip sets entering_town01_opening so the entry installs it (both routes honor the record's C1 gate, and the gate flag 0x225/549 lands from the record's own opening 52 25 script bytes - the self-latching one-shot, disc-gated organic_beat_records_disc.rs) - and runs as a spawned cutscene timeline: it plays the establishing camera pan (stepping past the conditional-wait parks the engine doesn't model - 0x4C nibble-C script_alloc/globals, 0x2D/0x30 flag-tests - while honoring 0x4A timed waits), then opens the “Select your name.” overlay at the pinned op 0x49 STATE_RESUME (body 0x02c6, 49 03 00) via the op-49 host hooks, freezing until the player names the lead; Vahn's scripted walk-out plays after the name commits. Save-correlated: _DAT_8007B450 (the op-0x49 slot) holds 0x800EB297 = that op's RAM address + 1. Regression: town01_opening_name_entry_wiring.rs + town01_opening_timeline_trace.rs + opening_full_chain_e2e.rs. See boot.
Per-actor channels - the vignette actors
The “characters doing things” during the narration are per-actor script channels. Retail spawns one script context per MAN partition-1 placement record at scene entry (FUN_8003A1E4, called per record 1..N1 by FUN_8003AEB0): the record base becomes the context's bytecode buffer (actor[+0x90]), its first opcode the entry PC (actor[+0x9E]), and its script id (actor[+0x50]) is partition-0 count + placement index - the id space cross-context (0x80-bit) ops resolve through FUN_8003C83C. The opdeene timeline drives them: after the camera-configure opening it halt-acquires channels 0x05..0x0F (a sweep of 4C 85 freezes), then pokes them beat by beat - a 4C 45 param write, a 4B ANIMATE cue, an A3/23 MoveTo - each channel's own placement script responding by animating / walking to its mark, then signalling completion via a context flag the timeline waits on (B3 <id> <bit> = cross-context CFLAG_TST).
The engine mirrors this in legaia_engine_core::field_channels: spawn_channels builds one FieldChannel per placement (retail script-id rule), spawned alongside a cutscene timeline; World::step_field_channels runs each live channel one frame-slice per tick (mirroring FUN_80039B7C's per-actor loop - ops until a yield, a park, or a 0x21 NOP, the retail frame-pacing point), and the timeline's cross-context pokes run against the resolved channel context (the acquirer clears the target's halt bit - the poke from the owner is the resume signal). Scripted moves write through to World::field_npc_positions so the render + probes follow; 0x4B ANIMATE cues land in World::field_npc_anim_cues, and the play-window render drains them each frame to re-target the NPC's clip player to the cued bundle record (record = anim id - 1, the placement anim-byte rule) - the vignette actors perform their scripted beats instead of looping the placement clip (the cue's per-keyframe parameter words are not modelled; the cued record loops until the next cue). Channels are cutscene-scoped (dropped when the timeline completes), so normal field NPC behaviour is untouched. Disc-gated opdeene_field_channels.rs asserts 13 channels spawn and execute + raise cues + take pokes.
Placement-default idle clips (vignette liveness). Even a halt-acquired channel keeps animating: retail's per-actor anim tick (FUN_8003BC08 → FUN_80021DF4) advances each actor's keyframe interpolation every frame independent of the parked script PC, so a vignette actor cued with its placement anim byte plays through the whole crawl. The play-window render mirrors this - it builds a looping FieldClipPlayer from each on-screen placement's default anim id and ticks it every frame (Field-mode only, not halt-gated). The clip source is the per-scene ANM bundle (player_anm::find_in_entry, type-0x05 section of the scene's first PROT slot), whose descriptor-count seed is not uniform: town01 resolves at count 3, but the prologue scenes stash theirs deeper - opdeene (PROT 749), opstati (754), opurud (764) only at count ≥ 5. The render path searches [3, 5, 6, 7]; hardcoding 3 resolves no bundle for the three prologue scenes, leaving their vignette actors without a clip player - a frozen tableau under the crawl. Disc-gated opening_scene_anm_bundle.rs pins the invariant.
Channel-completion handshake (CFLAG_TST / halt-acquire state-resume). A cross-context CFLAG_TST (B3 <id> <bit> = op 0x33 with the 0x80 bit, targeting a spawned channel's ctx[+0x50] id and testing ctx.flags & (1 << bit)) is the beat-completion wait: retail's 4C 85 acquire freezes the channel, a poke drives its beat, B2 <id> 0A resumes it, and the timeline halts at the B3 until the channel raises its completion bit (its own placement script runs 0x31 CFLAG_SET when the move/anim finishes). step_cutscene_timeline models that handshake: on a failing cross-context 0x33 it PARKS (CutsceneTimeline::channel_wait) - holding the PC on the flag-test op and, each subsequent tick, re-testing the awaited channel's bit, resuming past the op only once it is set (step_field_channels steps the real channel scripts each tick, so a channel whose beat completes raises the bit and the park clears). The park is bounded by CHANNEL_WAIT_PARK_TIMEOUT: a channel our port cannot advance to its flag-set falls back to the by-width step-past (the prior behaviour) so the prologue never stalls. Bit 10 (0x400, the halt/busy bit the acquire sweep toggles) is a suspension verify (B3 <id> 0A), not a completion wait, so it keeps the width step-past; the local/global flag-tests 0x2D/0x30 and a bare (non-cross-context) 0x33 step past too. Real timed 0x4A WAIT_FRAMES and the 0x49 STATE_RESUME name-entry suspend are still honoured.
Player-channel (0xF8) ExecMove / halt-acquire completion. Door-cutscene records drive the player
through the same handshake: A2 F8 <move_id> (ExecMove) pokes a move-table clip onto the player object, then
C3 F8 <sub> … (op 0x43 sub-0/1/A/B halt-acquire) halts the caller and state-resumes it at the operand s16 once
the move completes - a resume PC pointing backward into the poke loop (jou's castle-door record P2[5]:
C3 F8 00 5E E2 50 at +0x60 resumes at +0x50; the record's terminal 0x3F to jouina sits at +0xD0).
Retail resolves 0xF8 to the live player object (_DAT_8007C364, FUN_8003C83C); the engine spawns no player channel,
so field_channels::resolve_target keeps its None-for-0xF8 contract and run_spawned_record_slice models the
two ops directly: the ExecMove emits the same ExecMove field event and arms a short in-flight countdown
(CutsceneTimeline::player_move_frames, standing in for the playout since engine player pokes complete synchronously), and the
halt-acquire parks at the op (CutsceneTimeline::player_wait) until the countdown drains, then steps past it by
encoded width - the completion side of the handshake - so the record flows on to its trailing scene change instead of taking the
backward yield into a spin. A halt-acquire with no move in flight completes at once; the op-0x38 halt-acquire variant resumes
forward at its post-instruction PC, so its plain yield already reads as completion. Unit-covered by
cutscene_timeline_player_channel_door_reaches_scene_change; the disc-gated chapter1_hub_depth_oracle drives the jou
castle door through this path to SceneEntered("jouina").
Scripted screen fade (op 0x4C 0x12) + the effect colour (op 0x34 sub-0)
Op 0x4C 0x12 (7 bytes [4C, 12, r, g, b, ramp_lo, ramp_hi]) is the retail screen-fade primitive: the global multiply tint DAT_8007BCB8/B9/BA (neutral 0x80), optionally ramped over LE_u16(ramp) frames by the slot-job spawner FUN_8003C5F0. Every field scene's P1[0] entry script carries the arrival arm of the 0x52F/0x530/0x531 fade handshake: 4C 12 00 00 00 00 00 (instant black) then 4C 12 80 80 80 44 00 (ramp to neutral over 68 frames) - the fade-in from black that opens the prologue. New Game arms the handshake (World::begin_new_game sets sysflag 0x52F); the engine runs the entry script's load-frame slice at prologue-scene entry (World::pre_run_entry_script) so the instant black is on screen before the first rendered frame. The tint darkens the drawn 3D scene only - the narration crawl is a separate draw path and keeps scrolling bright, as the retail capture shows - and persists across scene changes (retail's cross-scene fade continuity). Engine model: fade::SceneTintRamp in World::screen_tint, surfaced by World::scene_screen_tint; play-window folds it into the colour-grade + depth-cue staging so the tint distributes to the final pixel. A landed non-neutral tint holds; a landed neutral drops to the identity path.
Op 0x34 sub-0 (7 bytes [34, op0, r, g, b, ramp_lo, ramp_hi]; the sub-0 arm at 0x801E1FB0 inside FUN_801DE840 - a Ghidra-promoted intra-function label) ramps the effect-layer global colour (neutral 0xFF) toward the operand RGB over the trailing word's frame count. The opening timeline drives it in the crawl gaps (34 05 00 00 00 D2 00 = to black over 210 frames, 34 01 FF FF FF 00 00 = instant neutral); the timeline's first op (34 05 FF FF FF 00 00, instant neutral) is a colour reset, not a white flash, and an all-zero colour is a ramp target, not a clear. It is not a screen fade: the retail cold-boot capture holds the lit villager tableau across the whole span where the timeline's 34 01 00 00 00 28 00 → 34 05 FF FF FF 5A 00 pair would black a full-screen fade, falsifying the earlier "between-beat black fade" reading (and the older "white flash + 50% wash" model before it). The value feeds the effect layer - the creation-glow planes are the likely consumer, still an open thread. Engine model: the same ramp type in World::effect_tint (scene-local, kept out of scene_screen_tint). Disc-gated opening_fade_from_black pins both value models against the real opdeene bytecode.
Full-scene sepia grade (the gold prologue look)
The whole prologue-cutscene leg of the opening renders through a persistent warm gold/amber grade - the 3D scene is tinted gold while the white narration text stays white. It is distinct from the transient screen fade above. The cold-boot pixel capture pins its scope: the grade persists across opdeene / opstati / opurud and drops for the full-colour map01 fly-in and town01.
Retail mechanism (capture-pinned). The grade is applied to the loaded scene assets, not per frame: a live capture of the retail opening (VRAM peeked against the disc TIMs) shows every CLUT row the opdeene bundle uploads rewritten entry-for-entry from the disc value (r, g, b) (5-bit BGR555) to
L = max(r, g, b) → (R, G, B) = (L, max(L − 1, 0), L >> 1)
with the STP bit preserved - zero mismatches across the graded terrain rows. The gold prologue is a palette-space luminance collapse to a gold ray, not a render-time tint: the same texel indices draw through gold-monochrome palettes. Two companion facts from the same capture close the older readings. No depth cue runs: walking every render node across the whole opening, node +0x78 (IR0, the DPCS blend factor FUN_8002735C loads per node, far colour at +0x74) holds 0 on every node at every beat - the only non-zero sightings are momentary far = black, IR0 = 0x1000 fades on vignette/text actors. The earlier "gold far colour + per-node depth-graded IR0" model is falsified; the far-field crush is the palette law seen through dark authored gouraud words, not a DPCS pull. And packet colours split by source: textured prims carry either the runtime-emitted neutral 0x80,0x80,0x80 (the ground tile kernel's quads - drawn gold purely by their law-collapsed CLUT) or a small amber family ≈ (M, 0.94·M, 0.43·M) - each loaded TMD's authored full-colour word collapsed to the same gold ray; near-field graded surfaces land B/R ≈ 0.44 ((L >> 1) / L), matching the law. The opdeene MAN carries no colour op and no per-actor depth-cue op; the grade is applied by the cutscene host to the scene's decoded assets at load. The GTE back/ambient colour DAT_8007B788 is 0x00202020 (dim) in opdeene vs 0x00FFFFFF in town01, but the field path issues no light op, so it is not the grade mechanism.
Engine port. The engine keeps the disc palettes in its software VRAM and applies the law in the mesh shaders instead - exactly equivalent, because a 4/8bpp texel is a palette entry: Renderer::set_palette_grade arms the palette-collapse mode (palette_law_word / palette_collapse_prim in the shaders, with CPU mirrors + lockstep tests): each decoded texel word goes through the exact 5-bit law, each non-neutral packet colour collapses to gold · max(r, g, b) (gold = the staged ColorGrade::PROLOGUE_SEPIA coefficients (1.0, 0.94, 0.43), the measured amber-family ratio), exact-neutral words stay neutral (the ground tile kernel's runtime word, retail-verified), and the view-depth cue ramp is inert (no node carries IR0 in the capture). The op 0x4C 0x12 screen tint rides the palette uniform's rgb, so scene fades still multiply every graded pixel. World::scene_color_grade still owns the scene gate (the prologue legs opdeene / opstati / opurud, None elsewhere); play-window stages the mode whenever the grade is active, and with the mode off (every interactive scene) all shader paths are bit-identical to the multiply-grade render. Pixel-verified on the villager tableau against a matched-region retail capture: the ground lands identically at G/R 0.890 / B/R 0.46..0.48 on both sides (the pre-law multiply grade left the engine ground green at G/R ≈ 1.07), and the text/UI overlays keep their own shaders, so the narration stays white. Guarded by scene_color_grade_only_on_the_prologue_cutscene. The superseded approximations - apply_grade's pixel multiply and the fade::DepthCueRamp view-depth ramp - remain as dormant plumbing, still staged by the host but bypassed while the palette mode is active.
Far-geometry brightness - not a separable law (resolved-negative). The far geometry (spires / wings) reads brighter and slightly blue-rich in the engine (B/R ≈ 0.27 vs retail's ≈ 0.15..0.16), but this is not a missing far-field palette or depth law, and there is no such law to port. No load-time gold-law CPU pass is statically visible: a signature scan for the law's arithmetic across the STR host overlay 0970, the field overlay 0897 and SCUS_942.54 finds no CLUT-rewrite loop - the "cutscene-host overlay load hooks are the candidates" reading is falsified; the CLUT rewrite the capture observed is a table / DMA upload, not a scannable arithmetic loop. The palette grade itself is faithful; the engine's excess is un-darkened neutral packets in the sampled region: lit-descriptor prims with no baked colour block are fed neutral 0x80 by the mesh builder, so the collapse's neutral guard leaves them un-graded, where retail draws those same lit prims through the scene GTE back/far colour its field renderer loads (opdeene's unusually dim ambient) - the field-path GTE light source the engine deliberately omits - and the port's absence of distance culling widens the sampled far region. Both are engine boundaries, not palette-law defects.
Open items
- XA channel map - resolved. No channel selector exists in the STR overlay (FMVs play filter-off; each movie carries one
(1, 0)track); theXA*.XAclip path selectsCdlSetfilter {file 1, chan}per cue in SCUS. See XA channel selection. The "\DATA\MOV.STRmulti-channel container" hypothesis is falsified (dev leftover, not on the disc). - XA clip-table writer - resolved. The
0x801C6ED8clip-table content was pinned first (34[CdlLOC][len]slots =XA1..XA34); the filler is now pinned statically too:FUN_801CFA78in PROT 0895init.pak, which sprintf-generates\XA\XA%d.XA;1per slot and fills[BCD-MSF][size]via the ISO9660 lookupFUN_8005DBB4, called once from the init boot tick0x801CF500. The earlier "untraceable DMA/computed write" framing was an SCUS-only sweep's blind spot - the writer is overlay-resident; the two SCUS materialisation sites are the readers. The per-caller(clip_id, chan)cue census is catalogued indocs/subsystems/audio.md. - MOV15.STR + MV1A.STR provenance. Two extra path strings appear alongside the six numbered MVs, dispatched by dev slots 9/10 -
MOV15a 15-FPS test file,MV1Aan alternate / cut version of MV1. Neither ships in the released disc layout. - 8-bit ADPCM reference decode. The
BitsPerSample::Eightdecode path is exercised only by synthetic unit tests; no 8-bit audio exists in the movie corpus to pin it bit-exactly.
Provenance (sources) + negative findings
Negative finding (don't re-walk): reading the GTE rotation matrix + translation from a save-state frame does not recover the cutscene camera - the matrix is the last-rendered object's composed transform (row norms ≈ 6.0), not a unit camera-view rotation. The camera comes from the op-0x45 param globals instead.
| Subject | Source |
|---|---|
| Master dispatch + return-scene hand-off | FUN_801CEA3C (PROT 0970 static decompile) |
| Play loop + demux SM | FUN_801CF098 / FUN_8005F024 (St library) |
| XA-clip channel selector | FUN_8003D53C / FUN_8003D764 (CdlSetfilter) |
| XA clip-table writer | FUN_801CFA78 (PROT 0895 init.pak static disassembly) |
| XA clip dispatch arithmetic | crates/engine-shell/src/xa_clip.rs |
| STRv2/v3 table + decoder | crates/mdec/src/strv2_table.rs / strv2_decode.rs |
Per-movie XA (file 1, chan 0) single track | raw-sector subheader scan of all six MOV/MV*.STR |
| STR sector header layout | crates/mdec/src/str_sector.rs; PSX-SPX §STR Video Files |
| Iki AC VLC table + LZSS qscale/DC table | crates/mdec/src/lib.rs; PSX-SPX BS-compression pages + jPSXdec PlayStation1_STR_format.txt (format docs) |
| IDCT + dequantize formula | crates/mdec/src/lib.rs; PSX-SPX §MDEC |
| BT.601 coefficients | crates/mdec/src/lib.rs |
| XA sector layout + demux | crates/xa/src/demux.rs |
| Interleaved STR A/V decode + sync clock | crates/engine-shell/src/cutscene_av.rs |
| Audio-cursor playback clock | crates/engine-audio/src/lib.rs (AudioOut::xa_cursor_secs) |
| Game modes 26 / 27 | crates/engine-core/src/mode.rs |
play-str frame loop | crates/engine-shell/src/bin/legaia-engine.rs |