Cutscenes: movies and the 3D opening
Legaia has two kinds of cutscene. The six pre-rendered movies on the disc are STR files - the PlayStation's standard movie container, video frames for the MDEC video-decompressor chip interleaved with XA audio the CD drive plays by itself. The famous creation-myth opening is not one of them: it is the ordinary field engine running scripts with the controller ignored. This page covers how a movie is started, streamed and decoded, then how the scripted opening is built; the register-level detail lives on cutscene internals.
At a glance
- Movie player
- PROT 0970 (
cutscene_str), an overlay loaded at slot A0x801CE818; game modes 26 (StrInit) and 27 (StrMode) - Movies
- Six
MOV/MV*.STRfiles, ninefmv_idslots (MV3.STRis split by frame range);MV1.STRis the title attract reel - Dispatch table
- Overlay data at
0x801D0A6C, 23 × 32-byte slots; parserlegaia_asset::fmv_dispatch- STR FMV table - Bitstream
- PSX Iki variant, not STRv2; 15 fps (10 sectors per frame); decoder
crates/mdec - Audio
- One XA-ADPCM track per movie at
(file 1, channel 0), stereo 37.8 kHz, interleaved sector by sector; decodercrates/xa - Engine
SceneMode::Cutscene; playerengine-shell::cutscene_av+legaia-engine play-str; opening timeline inengine-core- Confidence
- Confirmed - static decompile of PROT 0970 plus RAM captures during playback and a cold-boot pixel capture of the opening
Two kinds of cutscene
| Kind | Examples | Runs in | Skippable |
|---|---|---|---|
| Pre-rendered movie (STR) | Title attract reel, the seven mid-game movies | Movie overlay PROT 0970, modes 26/27 | Only the attract reel (fmv_id 0) |
| Scripted 3D scene | New Game opening (opdeene → town01), door and story beats | Field engine, mode 3, field-VM records | Opening: confirm press after the first crawl |
| Battle-intro transition | The shatter / swirl before a fight | PROT 0979, same overlay slot | No |
Function names of the form FUN_80xxxxxx are RAM addresses of routines traced in the original executable. The movie player is an overlay - code streamed from the disc into a shared RAM window on demand - which is why its addresses sit in the 0x801C0000+ band alongside the field, battle and menu overlays.
How a movie starts
Every route into a movie ends the same way: two globals are written, and on the next frame the main mode dispatcher boots mode 26. Scene scripts do it with a 7-byte field-VM instruction; the title screen does it from its idle countdown.
| Byte | Value | Meaning |
|---|---|---|
| 0 | 0x4C | Outer opcode MENU_CTRL (high-nibble re-dispatch) |
| 1 | 0xE2 | Sub-dispatcher 0xE, sub-op 2 = FMV trigger |
| 2..3 | i16 LE | fmv_id, sign-extended |
| 4..6 | - | Padding the PC math reserves; never read |
The handler stores the id in the FMV-index global and sets the next-game-mode global to 26. Nothing else happens until the next frame, when StrInit loads the overlay and reads the id back to pick a dispatch slot. The port handles the op as op4c_n_e_sub2_fmv_trigger in legaia_engine_vm::field; the world consumes the pending trigger at the top of the next tick, flips into SceneMode::Cutscene and suspends the field VM for the duration.
Which scene plays which movie
The assignment is disc data: each triggering scene's MAN (its script-and-data bundle) carries exactly one trigger op, recovered by decompressing every scene and walking its scripts (man_field_scripts::scene_fmv_triggers). After playback the master dispatch does not return to the triggering scene - it writes a return scene from its own label table.
fmv_id | Trigger scene | File | Return scene |
|---|---|---|---|
| 0 | title screen (idle) | MV1.STR | mode 22 (memory-card init) |
| 1 | town01 | MV2.STR | town0b |
| 2 | garmel | MV3.STR segment | map01 |
| 3 | deroa, chitei2 | MV3.STR segment | chitei2 |
| 4 | dohaty | MV3.STR segment | map02 |
| 5 | (no MAN carries it) | MV3.STR segment | stay in current scene |
| 6 | town0d | MV4.STR | jou |
| 7 | uru | MV5.STR | uru2 |
| 8 | jouine | MV6.STR | town0e |
Slots 9 and above name dev files (MOV15.STR, MV1A.STR, \DATA\MOV.STR) that are not on the disc. The return-scene hand-off is one kernel shared by every host: World::finish_cutscene parks the finished id and SceneHost::apply_pending_fmv_handoff loads the destination - so skipping or failing to decode the movie still moves the player, exactly as retail writes the scene globals whether or not the picture played.
Playing a movie: the retail path
0x801D0A6C + fmv_id × 0x20, clears the letterbox bands, selects the Iki decoderFUN_801CEA3C
2SeekCdSearchFile resolves the slot's path; the play loop seeks (start_frame − 1) × 10 sectors in and opens the SPU CD inputFUN_801CF098
3StreamCdlReadS with real-time XA on and the sector filter off, into a 32-sector ring in the asset bufferSt library
4Demuxvideo sectors (magic 0x0160) accumulate per frame in ring slots; XA sectors are played by the drive and never touch the CPUFUN_8005F024
5Decodethe Iki bitstream is entropy-decoded into MDEC codes and DMA'd to the chip; each 32-px strip is blitted to VRAM as it comes outFUN_801D0378
6Exitend-of-stream latch, or a pad press when fmv_id == 0; mute the CD mix, CdlPause, write the return scene, mode 2FUN_801CEA3C
Only the intro is skippable - the mid-game movies ignore the pad. Every retail movie takes the Iki path; the overlay also carries an STRv2/v3 decoder that only the dev slots select. The demux state machine, ring layout and MDEC feed are on cutscene internals.
What is on the disc: STR sectors and the Iki frame
A movie is a run of 2048-byte Mode 2 Form 1 sectors, each tagged with the frame it belongs to and its position within that frame. Concatenating the payloads of one frame's sectors in arrival order gives the demuxed bitstream.
| Offset | Size | Field |
|---|---|---|
0x00 | 2 | magic 0x0160 = video sector; anything else is skipped |
0x02 | 2 | type 0x8001 |
0x04 | 2 | chunk_number - position of this sector within the frame |
0x06 | 2 | chunks_per_frame |
0x08 | 4 | frame_number, wraps at 0xFFFF |
0x0C | 4 | frame_size_bytes across all chunks |
0x10 | 2 + 2 | width, height (multiples of 16) |
0x14 | 12 | header copy + padding, unused |
0x20 | 2016 | payload chunk |
The demuxed frame is the PSX Iki layout: a 10-byte header (mdec_code_count, magic 0x3800, width, height, lzss_size), then an LZSS-compressed table holding every block's quantisation scale and DC value, then an AC-only entropy stream. Legaia overwrites the fields a strict STRv2 parser expects to hold version and qscale with the frame width and height, which is why generic tools reject these movies. MdecDecoder::decode_frame in crates/mdec turns one frame into RGBA8 in six steps - header, LZSS table, AC bitstream, dequantise + IDCT, macroblock layout, 4:2:0 upsample + BT.601 - each written out with its formulas on cutscene internals.
XA audio and the sync clock
The soundtrack never touches the CPU in retail: the drive decodes the XA-ADPCM sectors as they stream past and feeds the SPU. The port decodes them itself and uses the result as the clock the picture follows.
- Where it is. Mode 2 Form 2 sectors with
submode & 0x24 == 0x24, sharing the movie's LBA range with the video; every movie carries exactly one track at(file 1, channel 0). - Why the extract is silent. The Form-1 extract under
extracted/MOV/truncates each audio sector from 2324 to 2048 bytes. Faithful playback reads the raw 2352-byte sectors from the disc image (cutscene_av::decode_str_av_from_disc). - Sync. The PCM is staged into
AudioOut::play_xa; the visible frame isaudio_position / frame_period(cutscene_av::due_video_frame). With no audio the same function paces off the wall clock. - Frame rate. STR carries no rate field; it is implied by sectors per frame at 2× (150 sectors/s). All six movies measure 10 sectors per frame = 15 fps (
analyze_str_timing).
XA channel selection
The disc's XA1.XA..XA34.XA files carry many channels each (voice banks, streamed music), so something has to pick a channel - but not for movies.
| Source | Channel mechanism | Who selects |
|---|---|---|
Movie (MV*.STR) | None: the read runs real-time mode without the sector filter, so the drive plays every ADPCM sector, and each movie interleaves one track | File + frame range from the dispatch table |
Clip (XA<n>.XA) | CdlSetfilter {file 1, chan} in the clip starter's CdSync state machine, from a 34-slot [CdlLOC][len] table at 0x801C6ED8 filled at boot | Per cue: slot (id − 0x100) >> 3, channel id & 7 |
The clip-side arithmetic (slot/channel mapping, the duration scale, the end-LBA clamp) is ported in legaia_engine_shell::xa_clip; the CD-control state machines stay hardware-side. Per-channel content extracts with xa demux-disc-all; the cue census lives on audio.
The engine player
legaia-engine play-str is the from-scratch player on its own. It decodes the video (and, from a disc image, the audio) up front, stages the PCM on the first redraw so cursor and picture start together, and on each redraw shows the frame due at the current position. The movie plays at its real rate, not the display refresh rate. Inside a game session play-window uses the same cutscene_av module: it pauses the scene BGM, suspends world ticks, shows one frame per redraw, then calls finish_cutscene().
# 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 a disc movie with its interleaved XA audio, in sync
legaia-engine play-str MOV/MV1.STR --disc "Legend of Legaia (USA).bin"
A TOML map (legaia-engine config dump-cutscene-map, then --cutscene-map on play / play-window) overrides the scene-to-movie resolver per scene; explicit entries win, missing keys fall through to the disc-decoded table.
The 3D opening: five scenes, zero input
Start a new game and the creation myth scrolls over a rendered grove, then the Seru, then the Mist, then a fly-in to Rim Elm. It looks like a movie; it is field scenes in the ordinary field mode, each carrying a timeline record - a field-VM script that stages the camera, pokes the background actors and ends with a scene change. There is no dedicated cutscene executor.
| Scene | What plays | Timeline spawned by | Next |
|---|---|---|---|
opdeene | Creation crawl (14 pages) + Seru-history crawl (8 pages) over the grove and villager tableau | Op 0x44 in the entry script | opstati |
opstati | Seru intro, 3 + 6 pages | Op 0x44 | opurud |
opurud | Mist story, 4 + 3 + 5 pages | Op 0x44 | map01 |
map01 | “Twilight of humanity” title card + 5-page crawl over the aerial approach to Rim Elm | Walk-on tile trigger | town01 |
town01 | Establishing pan, name entry, Vahn's walk-out | Walk-on tile trigger at (0x1D, 0x5B), one-shot gate | play |
A confirm press after the first crawl arms story flag 26, which fires the town01 packet - the intro skip. The engine drives the whole chain by executing the same records (World::step_cutscene_timeline), pinned against a cold-boot pixel capture.
The narration crawl
- Text. Inline ASCII pages inside the timeline record, each framed
0x1F … 0x00, introduced byCC F8 80 N(N= page count). A sibling opCC F8 89shows a static title card while the script continues. Parserlegaia_asset::cutscene_text; no text is committed. - Presentation. A bottom-up roller actor (handler
FUN_80037174) spawned as a child, so the camera cuts authored between crawl blocks play under the text. Retail's per-scene geometry (window top, visible lines, scroll cadence) comes from a 10-byte config op and is tabulated on internals. - “It was the Seru.” is not text at all: a pre-rendered 112×32 TIM in the
opdeenegeometry pack (PROT 0749), blitted as a screen-space quad. - Pacing. Retail counts every wait in display frames at 60 Hz; the engine sims at 100 Hz and drives the roller and the timeline off a 60 fps sub-clock, otherwise the crawl runs 1.67× fast.
Camera, actors and the engine port
Camera Configure (op 0x45) writes up to ten masked parameters - pitch, yaw, roll, an eye-space offset trio, the focus point and the GTE projection distance H - and either snaps or hands a mover actor a glide over apply display frames with one of four easing curves. The vignette actors are per-placement script channels the timeline halt-acquires and pokes beat by beat, waiting on a completion flag each raises. The port runs the record as a second field-VM context, merges each beat's parameters into a persistent camera state, spawns the channels (field_channels) and renders through the exact retail GTE model. The full model - record header, parameter table, mover curves, channel handshake and the engine's completion rules - is on cutscene internals.
Fades and the gold grade
- Screen fade (op
0x4C 0x12): a global multiply tint, optionally ramped over N frames. Every scene's entry script arms an instant black then a 68-frame ramp to neutral - the fade-in that opens the prologue. It darkens the 3D scene only; the crawl stays white. Enginefade::SceneTintRamp. - Effect colour (op
0x34sub-0): a separate ramp on the effect layer, driven in the crawl gaps. It is not a screen fade - the capture holds the lit tableau across it. - Sepia grade.
opdeene/opstati/opurudrender gold because every uploaded palette entry is rewrittenL = max(r,g,b) → (L, L−1, L>>1)at load - a palette-space luminance collapse, not a per-frame tint and not a depth cue. The engine applies the same law in its mesh shaders (Renderer::set_palette_grade), pixel-matched on the villager tableau. Detail on internals.
Battle intro and script helpers
The shatter or swirl before a fight is a third overlay, PROT 0979, sharing the movie player's slot. Its init picks a style from the battle flags byte, the formation's first monster id and (two arms) the scene index - style 2 is the ordinary shatter, 3 and 4 are reached by a handful of formations - and its per-frame tick owns the whole frame until the battle scene takes over. Port engine-vm::battle_intro_styles + engine-ui::battle_intro. The scripted op* / ed* scenes also lean on five shared per-frame helpers in the dialogue overlay (party-leader swap, position tween, teardown, particle emitter, colour rotate); both are tabulated on internals.
How we know
| Function | Address | What it proves | Source |
|---|---|---|---|
| Master dispatch | FUN_801CEA3C | 32-byte slot stride (sll v0,v0,0x5), decoder select, return-scene hand-off, intro-only skip | PROT 0970 static decompile, byte-identical to the playback RAM capture |
| Play loop | FUN_801CF098 | Seek math, SPU CD mix, per-frame pump, teardown | PROT 0970 |
| St demux | FUN_8005ECD4 / FUN_8005F024 | Ring slots, magic gate, end-frame latch | SCUS_942.54 |
| Iki decoder | FUN_801D0378 / FUN_801D0604 | LZSS DC table + AC scan; STRv2 path FUN_801D070C is dev-only | PROT 0970 |
| FMV trigger op | 0x801E30E4 in FUN_801DE840 | Writes _DAT_8007BA78 and _DAT_8007B83C = 0x1A; a label, not a callable | Field overlay 0897 dump; JT chain 0x801CEE60 → 0x801CF010 |
| Title attract trigger | FUN_801DD35C (0x801DDCF0) | Idle countdown underflow writes mode 26 with id 0 | PCSX-Redux watchpoint - boot |
| Per-scene triggers | man_field_scripts::scene_fmv_triggers | One trigger op per scene MAN, the table above | Disc-gated scene_fmv_triggers_disc |
| XA clip starter | FUN_8003D53C / FUN_8003D764 | CdlSetfilter {1, chan} for clips; no filter for movies | SCUS_942.54 |
| Clip table filler | FUN_801CFA78 | 34 [CdlLOC][len] slots at 0x801C6ED8 from \XA\XA%d.XA;1 | PROT 0895 init.pak |
| Per-movie single XA track | - | (file 1, chan 0) on all six movies | Raw-sector subheader scan |
| Record spawn | FUN_8003BDE0 | Exactly five spawns across the opening: three op-0x44, two tile triggers | Exec breakpoint, cold boot |
| Crawl roller | FUN_80037174 | Child context; geometry globals at _DAT_801C6EA4 + 0x4C..0x52 | SCUS_942.54 + pixel capture |
| Camera | FUN_801DE084 / FUN_801DD310 / FUN_800172C0 | Param-to-global map, mover curves, view build | Field overlay 0897; capture matches 2471 of 2480 sampled axis values |
| Gold grade | - | Every uploaded CLUT row rewritten entry-for-entry, zero mismatches; IR0 = 0 on every node | VRAM peek against disc TIMs during the opening |
| Battle intro | FUN_801CE8CC / FUN_801CF5BC | Style select, frame ownership | PROT 0979 |
Format references for the decoder: PSX-SPX (STR, BS compression, MDEC) and jPSXdec's PlayStation1_STR_format.txt - documentation only. Port files: crates/mdec/src/{lib,str_sector,strv2_table,strv2_decode}.rs, crates/xa/src/demux.rs, crates/engine-shell/src/cutscene_av.rs, crates/engine-core/src/mode.rs.
The per-STR trigger capture corpus
Nine save states, one per fmv_id 0..=8, captured immediately before each movie starts, pin the trigger-side state: _DAT_8007BA78 = the expected id, _DAT_8007B83C = 0x1A, BGM id 2000, active scene map01. The states were produced through the dev menu's register editor (FUN_801DBD04 family), not by stepping a scene's trigger op, so they prove the id/mode tuple across the whole range but not the per-scene assignment - that comes from the MAN walk above. There is no per-movie event record carrying post-play flags; the dev menu's “jump to beat” is the MAP CHANGE warp appliers plus the event-flag editor. Codified as capture_observations::cutscene_trigger_corpus.
History: superseded readings
- The dispatch table was read with a 64-byte stride and paired the wrong slot halves; the shift instruction pins 32 bytes. With the right stride the last three scenes play
MV4/MV5/MV6rather than “vestigial triggers at cut movies”. - A “compact MV table” at
0x801CAE40was the libcdCdlFILEdirectory cache read 8 bytes out of phase. - The confirm-press flag-26 packet was read as the required hand-off into
town01; it is the skip, and the natural chain arrives through the tile trigger. Op0x44was labelled COUNTER before the probe showed it spawning a record. - Reading the GTE matrix from a save state does not recover the cutscene camera - it is the last-rendered object's composed transform (row norms ≈ 6). The camera comes from the op-
0x45globals.
More on do-not-re-walk.