Cutscene (STR mode)
Pre-rendered cutscene playback combines PSX STR video (MDEC hardware decoder) with the XA-ADPCM audio interleaved in the same CD-XA sectors. The engine drives it through game modes 26 and 27 (StrInit / StrMode), which map to SceneMode::Cutscene in the clean-room port.
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 channel. 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 handler is overlay-resident. Its source is pinned: PROT 0970 (cutscene_str, slot-A base 0x801CE818), identified statically from the disc by its MV*.STR movie paths + MDEC decoder strings - so the handler is now 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
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.
The mapping from cutscene name to expected (file_no, ch_no) channel pair is still overlay-resident (in the not-yet-captured cutscene overlay) - only needed to select a cutscene by name from a separate multi-channel container; the in-file interleaving above needs no such map. 8-bit ADPCM is detected and dropped with a warning (none appears in the movie corpus).
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 table itself still requires the STR/MDEC overlay capture; the TOML interface lets engines distribute the recovered map once that lands without a code change.
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 |
|---|---|---|---|
0x801CAE40 | 144 B | 24 B × 6 | Compact MV-file table: MV1.STR;1 .. MV6.STR;1 |
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 | CDNAME labels for mid-game FMV-bearing field scenes |
Compact MV-file table layout
Each entry in the compact table at 0x801CAE40 is 24 bytes:
+0x00 char[12] filename (libcd-shaped, e.g. "MV1.STR;1\0")
+0x0C u32 reserved (zero across the captured corpus)
+0x10 u32 BCD MSF (byte 0=BCD minute, 1=BCD second, 2=BCD frame, 3=zero)
+0x14 u32 file size in bytes (LE)
See the STR FMV table format page for the parser API and per-entry MSF/LBA/size table.
Mid-game FMV-bearing field 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 scenes have FMV trigger points in their field-VM scripts. The exact MV*.STR each plays is encoded in the per-scene script as the operand of the FMV-trigger op decoded below. Engines resolve the index through cutscene::fmv_index_to_str_filename in crates/engine-core/src/cutscene.rs.
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 to select a 64-byte dispatch-table slot from0x801D0A6C. On retail USA the table has 12 slots (fmv_id ∈ 0..=11); the field VM has been observed writing values up tofmv_id = 8via the per-STR FMV trigger corpus. The mapping (fmv_id → STR file + frame range) is not a 1:1 walk overMV1.STR..MV6.STR- it skipsMV2.STRandMV5.STR(disc-resident but unreferenced) and slots 5..=11 point at cut paths. 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). 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. A fmv_id whose slot points at a cut/missing path drains as a no-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, and shows the video one frame per redraw, then calls finish_cutscene() and resumes the field once the frames drain. 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 was pinned later 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.
The per-scene trigger assignment is disc-sourced (the "runtime-reconstructed" reading is falsified)
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. The last three resolve to the dev-only \DATA\MOV.STR path - vestigial triggers for movies cut from the retail disc. Pinned by the disc-gated scene_fmv_triggers_disc test; the earlier conclusion that the bytecode is "reconstructed at scene-load from the field-pack preamble" is falsified - the ops were simply compressed.
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 overlay's own scene references. Outside the MAN-carried scripts, a raw sweep keeps taiku (fmv_id 5, the cut MOV15.STR slot) and opmap01 / koin1b (fmv_id 7) as uncontextualized byte candidates in non-MAN structures.
Per-STR FMV trigger corpus
The current corpus carries nine save states captured RIGHT 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 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.
In-engine 3D cutscene (opdeene opening prologue)
Not every cutscene is an STR FMV. The New Game opening - the "Genesis tree" prologue narration - is an in-engine 3D cutscene scene (opdeene, CDNAME/PROT #748), a field scene 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.)
The cutscene plays out of the scene MAN's cutscene-timeline partition (partition 2). Its closing record (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 town01 hand-off arm (op 0x2E GFLAG_SET 26, 2E 1A at 0xA5E), and inline narration text.
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.
opdeene's timeline carries two 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
Entering opdeene live installs the decoded pages on the world (World::open_cutscene_narration; the host gathers them via man_field_scripts::collect_partition_narration over partition 2). The presenter CutsceneNarration walks them one page at a time: World::tick advances a per-page timer (auto-advancing the subtitle, default ~2.5 s/page), and a confirm press skips to the next page. The host renders the active page centered near the bottom of the screen (cutscene_narration_draws_for).
The narration gates the Rim Elm hand-off: World::take_prologue_handoff returns nothing while the narration is on screen, so the opening order matches retail - narration plays, then a confirm press triggers the town01 transition. The presenter's per-page dwell (DEFAULT_PAGE_FRAMES = 120 ≈ 2.0 s) and the renderer's ¾-down placement are pinned to retail (below). The disc-gated test opdeene_narration_playback.rs cold-boots opdeene, asserts the narration installs (22 pages) and gates the hand-off, ticks it to completion on the timer, and confirms the hand-off then releases to town01.
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 (white fade-in), 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 is keyed by scene-entry / tile-trigger position (FUN_801D27E0/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 a child text context with theNinline pages as its bytecode (parent PC += 3). Each page is drawn byFUN_8003C764: centered (X = (320 − width)/2), fixedY = 180on the 240-px virtual screen, per-page timer seeded to0x78= 120 frames. - 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: param 0/1/2 →_DAT_8007b790(pitch, GTERotMatrixX) /b792(yaw,RotMatrixY) /b794(roll,RotMatrixZ; zeroed in field); params 3/4/5 →_DAT_800840b8/bc/c0(shake/offset trio); params 6/7/8 →_DAT_80089118/1c/20= the camera focus as the GTE translation(-X, +Y, -Z); param 9 →_DAT_8007b6f4= the GTE H projection register (focal length/zoom) viafunc_0x8003d254=setCopControlWord(2, …). The view rotation is three Euler angles: the GTE buildFUN_8001CF50composes the matrix by rotating about each axis viaRotMatrixX/Y/Z(0x800461A4/629C/638C, each masking the angle to 12 bits,4096 = 360°, indexing the sin/cos LUT at0x80070A2C, composed with GTEmvmva). So param 0 is the camera pitch, not a "rot/zoom" word - the zoom is H. The per-frame interpolation is also pinned:FUN_801DB510eases the focus, shake/offset trio, and the typed0x801F2798param table toward their control-block targets with an exponentialsravlerp, so beats blend rather than snap. The only thing left implicit is the eye distance - retail places the eye at the GTE translation (the focus) and projects through H, with no explicit distance scalar. Confirmed againstnew_game_cutscene_intro_a: focus(8640, 0, 10304), pitch180, yaw-2967, roll0, H792. Negative finding (don't re-walk): reading the GTE rotation matrix + translation from a save-state frame does not recover the camera - the matrix is the last-rendered object's composed transform (row norms ≈ 6.0), not a unit camera-view rotation.
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.
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, and the closing GFLAG_SET 26 writes the hand-off bit through the same host path the main field VM uses - so the town01 hand-off fires by execution, not by a static MAN-walk derivation. The static arm (World::arm_prologue_handoff_from_man) remains a fallback when the timeline record can't be resolved, and a safety net arms it if execution can't reach the closing op within the frame cap, so the prologue can never stall.
Two single-shared-VM accommodations, approximate by design:
- Narration pages are neutralized. In retail's cutscene context
CC F8 80 Nroutes toFUN_8003C764(text draw) and consumes itsNinline pages; the engine's single field VM decodes0x4Cn8 sub-0 as the actor allocator, whose PC-advance would land on the page bytes. Because the engine presents the narration through the separateCutsceneNarrationpresenter, the loader overwrites each narration span (located bycutscene_text::NarrationBlock::byte_span) with field-VM NOPs (0x21) - an offset-preserving fill, so relative jumps still resolve and the camera/move/GFLAGops at their original offsets still execute. The actor-allocator host hook is also suppressed while the timeline steps (World::in_cutscene_timeline). - Camera params. The op-
0x45events flow to theCameracontroller and the host writes the param set toWorld::camera_state. The nativeplay-windowrenders the cutscene withwindow::cutscene_camera_mvpwhenever a cutscene timeline is installed, decoding the pinned params (see the op-0x45table above): it frames the focus(-param6, param7, -param8)- sign-corrected back to world space from the negated GTE-translation globals - tilted by the pitch (param 0) and rotated by the yaw (param 1, PSX4096= full turn) with the FOV derived from param 9 (the GTE H register), viaSceneHost'scutscene_view. The one approximation left is the eye distance: retail has no explicit eye-distance param (the eye sits at the GTE translation and projects through H), so the engine orbits the focus at a scene-sized radius - but the orbit angles (pitch + yaw) are now the decodedRotMatrixX/RotMatrixYangles rather than a fixed tilt (a beat omitting the pitch slot falls back to the prior fixed ~24° framing). The shot re-targets each time the timeline executes a new Camera Configure op; rather than cutting,play-windoweases the rendered(focus, pitch, yaw, FOV)toward each new beat throughwindow::CutsceneCameraInterp(per-frame ease, angles along the shortest arc; reset to snap when the timeline first installs) - mirroring retail's ownFUN_801DB510exponential ease - so beats blend the way retail's GTE camera does.
Disc-gated coverage: opdeene_timeline_execution.rs cold-boots opdeene, asserts the timeline installs with the hand-off bit clear, ticks until it sets the bit by execution, and reports the frame it armed. The CI synthetic cutscene_timeline_synthetic.rs exercises the executor (GFLAG-by-execution, safety net, idempotent completion) without disc data.
Open items
- `town01` opening timeline + name-entry handoff (wired). The
town01opening (partition-2 record 3) runs in-engine on the new-game hand-off as a spawned cutscene timeline: it plays the establishing camera + Vahn's walk-out (stepping past the conditional-wait parks the engine doesn't model -0x4Cnibble-Cscript_alloc/globals,0x2D/0x30flag-tests - while honoring0x4Atimed waits), then opens the “Select your name.” overlay at the pinned op0x49STATE_RESUME (body0x02c6,49 03 00) via the op-49 host hooks, freezing until the player names the lead. Save-correlated:_DAT_8007B450(the op-0x49slot) holds0x800EB297= that op's RAM address + 1. Regression:town01_opening_name_entry_wiring.rs+town01_opening_timeline_trace.rs. See boot.md. - FMV dispatch table - decoded from disc. The play loop
FUN_801CF098(1236 B) is reached from the selector at0x801CECA0(_DAT_8007BA78 << 6 + 0x801D0A6C), and that dispatch table is static overlay data now decoded straight from the disc (legaia_asset::fmv_dispatch): eachfmv_id's movie + frame range, used to seek to the right segment (cutscene_av::fmv_segment_window). The STR overlay (PROT 0970) is Ghidra-importable at its base, so the master-dispatch is a static decompile, no capture. Still finer-grained: the XA channel selector + MDEC frame-demux state machine (below). - XA channel map.
(file_no, ch_no)→ cutscene-name association is inside the STR/MDEC overlay. The MV-file table doesn't carry XA channel info directly; the channel selector is presumably driven by\DATA\MOV.STR;1(which appears to be a multi-channel container distinct from the per-cutscene\MOV\MVn.STR;1files). - MOV15.STR + MV1A.STR. Two extra path strings appear alongside the six numbered MVs. These are dev / debug branches:
MOV15is the 15-FPS test file, andMV1Ais an alternate / cut version of MV1. Neither ships in the released disc layout.
Provenance (sources)
| Subject | Source |
|---|---|
| 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 |