At a glance

Movie player
PROT 0970 (cutscene_str), an overlay loaded at slot A 0x801CE818; game modes 26 (StrInit) and 27 (StrMode)
Movies
Six MOV/MV*.STR files, nine fmv_id slots (MV3.STR is split by frame range); MV1.STR is the title attract reel
Dispatch table
Overlay data at 0x801D0A6C, 23 × 32-byte slots; parser legaia_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; decoder crates/xa
Engine
SceneMode::Cutscene; player engine-shell::cutscene_av + legaia-engine play-str; opening timeline in engine-core
Confidence
Confirmed - static decompile of PROT 0970 plus RAM captures during playback and a cold-boot pixel capture of the opening
STR playback pipeline: one sector stream is split into video frames for the MDEC and XA audio for the SPU; the port paces the picture off the audio cursor CD-ROM CdlReadS, filter off video sectors (Form 1) St ring demux XA sectors (Form 2) drive plays them itself Iki bitstream LZSS DC table + AC VLC MDEC IDCT, YCbCr to RGB VRAM 32-px strips SPU CD mix volume 0x7F port: the audio cursor clocks the video frame index
Video and audio share one sector stream, so they are aligned by construction. The port replaces the MDEC chip with a software decoder and paces the picture off the audio cursor.

Two kinds of cutscene

KindExamplesRuns inSkippable
Pre-rendered movie (STR)Title attract reel, the seven mid-game moviesMovie overlay PROT 0970, modes 26/27Only the attract reel (fmv_id 0)
Scripted 3D sceneNew Game opening (opdeene → town01), door and story beatsField engine, mode 3, field-VM recordsOpening: confirm press after the first crawl
Battle-intro transitionThe shatter / swirl before a fightPROT 0979, same overlay slotNo

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.

ByteValueMeaning
00x4COuter opcode MENU_CTRL (high-nibble re-dispatch)
10xE2Sub-dispatcher 0xE, sub-op 2 = FMV trigger
2..3i16 LEfmv_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_idTrigger sceneFileReturn scene
0title screen (idle)MV1.STRmode 22 (memory-card init)
1town01MV2.STRtown0b
2garmelMV3.STR segmentmap01
3deroa, chitei2MV3.STR segmentchitei2
4dohatyMV3.STR segmentmap02
5(no MAN carries it)MV3.STR segmentstay in current scene
6town0dMV4.STRjou
7uruMV5.STRuru2
8jouineMV6.STRtown0e

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

1Mode 26master dispatch picks the 32-byte slot 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.

OffsetSizeField
0x002magic 0x0160 = video sector; anything else is skipped
0x022type 0x8001
0x042chunk_number - position of this sector within the frame
0x062chunks_per_frame
0x084frame_number, wraps at 0xFFFF
0x0C4frame_size_bytes across all chunks
0x102 + 2width, height (multiples of 16)
0x1412header copy + padding, unused
0x202016payload 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 is audio_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.

SourceChannel mechanismWho 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 trackFile + 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 bootPer 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.

SceneWhat playsTimeline spawned byNext
opdeeneCreation crawl (14 pages) + Seru-history crawl (8 pages) over the grove and villager tableauOp 0x44 in the entry scriptopstati
opstatiSeru intro, 3 + 6 pagesOp 0x44opurud
opurudMist story, 4 + 3 + 5 pagesOp 0x44map01
map01“Twilight of humanity” title card + 5-page crawl over the aerial approach to Rim ElmWalk-on tile triggertown01
town01Establishing pan, name entry, Vahn's walk-outWalk-on tile trigger at (0x1D, 0x5B), one-shot gateplay

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 by CC F8 80 N (N = page count). A sibling op CC F8 89 shows a static title card while the script continues. Parser legaia_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 opdeene geometry 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. Engine fade::SceneTintRamp.
  • Effect colour (op 0x34 sub-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 / opurud render gold because every uploaded palette entry is rewritten L = 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

FunctionAddressWhat it provesSource
Master dispatchFUN_801CEA3C32-byte slot stride (sll v0,v0,0x5), decoder select, return-scene hand-off, intro-only skipPROT 0970 static decompile, byte-identical to the playback RAM capture
Play loopFUN_801CF098Seek math, SPU CD mix, per-frame pump, teardownPROT 0970
St demuxFUN_8005ECD4 / FUN_8005F024Ring slots, magic gate, end-frame latchSCUS_942.54
Iki decoderFUN_801D0378 / FUN_801D0604LZSS DC table + AC scan; STRv2 path FUN_801D070C is dev-onlyPROT 0970
FMV trigger op0x801E30E4 in FUN_801DE840Writes _DAT_8007BA78 and _DAT_8007B83C = 0x1A; a label, not a callableField overlay 0897 dump; JT chain 0x801CEE60 → 0x801CF010
Title attract triggerFUN_801DD35C (0x801DDCF0)Idle countdown underflow writes mode 26 with id 0PCSX-Redux watchpoint - boot
Per-scene triggersman_field_scripts::scene_fmv_triggersOne trigger op per scene MAN, the table aboveDisc-gated scene_fmv_triggers_disc
XA clip starterFUN_8003D53C / FUN_8003D764CdlSetfilter {1, chan} for clips; no filter for moviesSCUS_942.54
Clip table fillerFUN_801CFA7834 [CdlLOC][len] slots at 0x801C6ED8 from \XA\XA%d.XA;1PROT 0895 init.pak
Per-movie single XA track-(file 1, chan 0) on all six moviesRaw-sector subheader scan
Record spawnFUN_8003BDE0Exactly five spawns across the opening: three op-0x44, two tile triggersExec breakpoint, cold boot
Crawl rollerFUN_80037174Child context; geometry globals at _DAT_801C6EA4 + 0x4C..0x52SCUS_942.54 + pixel capture
CameraFUN_801DE084 / FUN_801DD310 / FUN_800172C0Param-to-global map, mover curves, view buildField 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 nodeVRAM peek against disc TIMs during the opening
Battle introFUN_801CE8CC / FUN_801CF5BCStyle select, frame ownershipPROT 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/MV6 rather than “vestigial triggers at cut movies”.
  • A “compact MV table” at 0x801CAE40 was the libcd CdlFILE directory 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. Op 0x44 was 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-0x45 globals.

More on do-not-re-walk.

See also