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_game_tables::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
In the port
crates/mdec (decoder, and the str_av demuxer both play hosts open a movie through), crates/xa, engine-shell::cutscene_av; opening timeline in engine-core
Confidence
Confirmed - disassembly of PROT 0970 plus RAM captures during playback and a cold-boot pixel capture of the opening (evidence)
video: Mode 2 Form 1, magic 0x0160 audio: Mode 2 Form 2, XA (file 1, channel 0) STR sectors interleaved run Frame assembly 10 sectors each Entropy decode LZSS DC + AC VLC Dequant + IDCT 8x8 blocks YCbCr to RGB 4:2:0, BT.601 Frame shown strips into VRAM XA ADPCM stereo 37.8 kHz Retail: drive feeds the SPU CPU never sees the samples Port: audio cursor is the clock frame due = position / frame period picks the frame
Video and audio share one sector stream, so they are aligned by construction. Retail hands the picture to the MDEC chip and the sound to the drive; the port decodes both in software and lets the soundtrack's playback position decide which frame is on screen.

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 field script is suspended 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
5taikuMV3.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. Retail writes the return-scene globals whether or not the picture played, so the port moves the player on even when a movie is skipped or cannot be decoded.

Playing a movie: the retail path

  1. Mode 26Pick the 32-byte slot 0x801D0A6C + fmv_id × 0x20, clear the letterbox bands, select the Iki decoder (FUN_801CEA3C).
  2. SeekCdSearchFile resolves the path; seek (start_frame − 1) × 10 sectors in and open the SPU CD input (FUN_801CF098).
  3. StreamCdlReadS with real-time XA on and the sector filter off, into a 32-sector ring.
  4. DemuxVideo sectors gather per frame in ring slots; XA sectors are played by the drive and never touch the CPU.
  5. DecodeThe Iki bitstream becomes MDEC codes, DMA'd to the chip; each 32-px strip is blitted to VRAM as it comes out.
  6. ExitEnd of stream, or a pad press when fmv_id == 0: mute the CD mix, pause, write the return scene.

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. While a movie plays, RAM also holds the CD library’s cache of the MOV directory, the overlay’s path-string table (including the dev paths that do not ship) and its return-scene label table.

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.

+0x00magic 0x0160u16
+0x02type 0x8001u16
+0x04chunk no.u16
+0x06chunks / frameu16
+0x08frame numberu32
+0x0Cframe size (bytes)u32
+0x10width, heightu16 × 2
+0x14header copy, padding12 B
+0x0032-byte headerabove
+0x20payload chunk - concatenate a frame's chunks in order2016 B

The demuxed frame is the PSX Iki layout: a 10-byte header, then an LZSS-compressed table holding every block's quantisation scale and DC value, then an AC-only entropy stream. Legaia puts the frame width and height where a strict STRv2 parser expects version and qscale, which is why generic tools reject these movies. One frame decodes in six steps:

  1. Header10 bytes: code count, magic 0x3800, width, height, LZSS size.
  2. DC tableLZSS inflates to one 6-bit qscale + 10-bit DC per 8×8 block.
  3. AC codesRun/level VLC per block, ended by EOB; an escape carries a raw 16-bit code.
  4. IDCTDequantise through the zigzag order, then a separable 8×8 inverse DCT.
  5. MacroblocksSix blocks each - Cr, Cb, Y0..Y3 - laid out column by column.
  6. ColourUpsample 4:2:0 chroma, convert BT.601 YCbCr to RGB.

Formulas for every step are on cutscene internals; the port's decoder is psx_mdec::MdecDecoder.

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_scene_formats::cutscene_text; no text is committed.
  • Any record context opens one. The crawl op is not the modal timeline’s alone. Terra’s speech to Noa on Mt. Rikuroa after the Genesis Tree revives is a partition-2 record the scene’s entry script spawns, which the engine runs as a helper context beside the field, and its arm opens the roller the same way. Both installs parse the record’s narration blocks, and an executed 0x21 ends a spawned record for good (script VM).
  • 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. Lines are centred and enter and leave a clip window a pixel at a time. The roller measures each page with its 0x1F lead byte still on, which adds one pixel, then draws the text without it, so every line sits one pixel left of true centre; the port places lines the same way, and on Ozma's crawl in East Voz Forest the text columns now line up with retail's exactly. As for the clip window: the roller links a draw-area pair after its lines, which runs before them, and a second pair restoring the full area ahead of them.
  • “It was the Seru.” is not text at all: a pre-rendered 112×32 image in the opdeene geometry pack (PROT 0749). Two move-VM stager parts draw it, spawned as children down a chain the opening timeline installs: both camera-locked sprites over the same rect, one the face and one a shadow a pixel down-right in a different blend mode, each starting fully cued to black and ramping to full colour - the fade-in. A cold-boot capture holds both nodes on the actor lists and their two packets in the ordering table, shadow first. The engine spawns and draws the same two parts (engine-effects::effect_sprite_arm).
  • Pacing. Retail counts every wait in display frames at 60 Hz, through the adaptive frame-skip factor: a logic tick that spans two or three display frames credits two or three. One engine tick is one vsync, and the script's frame delta is the game frame's length on the vsyncs that are game frames and zero between them, so a wait's sum moves exactly as retail's does (the wait law).

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. Nothing latches: a shot the record never hands back stands while the record holds the player, then the follow ease takes it on the first step the player walks. Talks and doors end on their own shots that way, and the port leaves them standing and eases off them the same way.

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 four CLUT-cell cyclers in the scene’s effect scripts rewrite the CLUT rects (0, 473, 320, 2) and (0, 476, 320, 34) as L = max(r,g,b) → (L, (L·247)>>8, L>>1) - 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. A packet built at draw time keeps its colour, because it is made after the rewrite and outside the resident meshes it walks: the caption sprites above are such packets, so both hosts’ shaders skip the grade for them. Detail on internals.

The ending credits

The credits are the same machinery run the other way round: each ed* scene’s entry script spawns one long vignette record, and the ending theme plays on across every scene change.

ending theme: one track, playing across every load vignetterecord runs, pad held hand-off72 vsyncs black: field init130 vsyncs next vignettenew scene retail: the load itself, whatever the scene’s size port: loads at once, then holds the same 130 black
One of the eight scene changes, as a per-vsync film of retail reads it. Without the hold the port reached the last vignette some 17 seconds ahead of the score.
  • The pad is held. Retail raises the player’s engaged bit for as long as a vignette runs, so no walk input reaches the field; the port’s helper pad lock does the same, and the mouse camera knobs and the distance preset sit out while the vignette’s shot owns the camera.
  • Each scene load is a black hold. Between two vignettes retail spends its field init on a black screen: a per-vsync film reads exactly 130 vsyncs at all eight changes, whatever the size of the scene it loads. The port loads at once, which put the last vignette some 17 seconds ahead of the music, so while the credits theme plays a scene change holds the frame black for those 130 vsyncs. Everywhere else a load stays instant.
  • Screen widgets belong to the scene. The masks, panels and credit sprites are nodes on the scene’s actor list, so a load frees them; kept, a vignette’s corner panel drew the next scene’s video memory over it.
  • A record waits where retail waits. Besides waiting for a clip to end, a record can spin on an actor’s morph envelope (the lily closing in edlast) or on an NPC still mid-arc (the white flash in eddoman); the port holds the record in both places, so each shot lasts as long as in the retail film.
  • The window shot. In edkorout the camera glides through a wall to the sky: a scripted shot keeps the eye depth’s sign through zero, and the two sky shells outside carry the map flag that exempts them from the near-eye object cut, so the sky shows through the window instead of black.

Rim Elm’s day before the hunt

After the naming, town01 plays its story beats in a fixed order, each a scene record gated on the flags the one before raised:

  • The Elder at the Genesis Tree (P2[3]), raising 0x225.
  • Mei below the garden gate (P2[4], a walk-on band), raising 0x226. She walks out of her house and her door closes behind her.
  • The measuring at Vahn’s house (P2[5], spawned by the front door), raising 0x227.
  • The hunters’ return (P2[6]), spawned only by Tetsu’s talk after a spar fought once the measuring is done. Stepping out of the house starts nothing; a retail state outside the house after the measuring has the return’s flag clear. A spar fought before the measuring never counts in the original, which then cannot go on; the port’s Tetsu spar order fix lets it count.

The garden’s blackboard (the panoramic map) is a talk that leaves the field view and comes back through a staged mode switch; the talk waits on it rather than ending. While the overview runs, the villagers round the map are parked off the map and re-seated on the way out. Detail and addresses on the cutscene reference.

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 render-kernels::battle_intro_styles + render-kernels::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.

From field to battle: one overlay owns the frame

Between the encounter and the battle scene the screen shatters, scatters or swirls. For those two to four seconds nothing else is drawn: the transition overlay's init captures the field's last frame, switches the game to its own mode, and from then on every visible pixel is a transition primitive sampling that capture.

init style, duration, capture frame, mode 9 tick once per frame phase 0, 1, 2 cue, meshes, music + bundle style emitter 0..4 primitives over the capture per-style fade to white or to black mode 0x14 battle timer done, ready == 3
The loading happens under the effect: the battle's meshes, music and scene bundle are staged by the phase counter while the style plays.
StyleLookLengthFade tail
0Scatter: 1280 particles carrying patches of the field132 framesblack (white on one sub-arm)
1Spin-up132 frameswhite
2Tile shatter - the default, so an ordinary random encounter shatters132 framesblack
3Curtain: the capture cut into row and column strips132 framesblack
4Swirl252 frameswhite
  • Which style. The init reads the battle flags byte, the formation's first monster and, for two arms, the scene index. Three formations get the curtain and one gets the swirl. Two of its stores sit in branch delay slots and land on both arms, which is why a flagged battle defaults to style 1 and not 2.
  • The base colour is black, not the field. The capture is taken once and the frame is cleared; a particle at rest reproduces its patch exactly only over that cleared frame, and each patch that flies away leaves black behind. Drawing the live field underneath gets both halves wrong - resting patches read at double brightness, and the tail shows a still-animating field. The port emits an opaque backdrop quad on every frame of the window to stand in for the clear.
  • The duration is the denominator of everything. Each fade is a lead before the end, the ready bits rise 30 and 6 frames before it, and the shatter's tiles start on random delays that reach about frame 84. A transition shorter than that leaves part of the grid parked; one shorter than twenty frames draws as a whitening fade over a statically re-tiled screen.
  • The same white ramp ends two ways. The fade helper's second argument is a blend mode: under additive it whites out, under subtractive it blacks out. Read as a depth, every style would half-blend and never reach either end.
  • Sound. Phase 0 writes the battle-start cue straight into slot 0 of the pending effects ring, and for an ordinary fight re-applies the live music level to the field track over a ramp. Phase 2 starts the battle track during the effect, which is where the port starts it too.

Port: the state machine is the encounter session's transition phase (battle_intro_transition::tick_transition); the per-record simulation of each style and the packet builders are shared by both hosts. The particle fields, the tile shatter and the early swirl are held to retail packet for packet by the kernel differential: a tile that has started to fly fades toward black by its progress, as retail's depth-cue bank fades it. The late swirl is not carried faithfully: retail draws it through the shatter renderer, triangles spun about their own centres and pushed apart, where the port turns whole quads about the origin. Both sides drop a quad whose projected corners outrun the GPU polygon-size limit, which is what removes a swirl band flying into the lens.

What is still open, and how it is known

One port difference remains: the engine’s cutscene camera arms its glides per component, where retail re-seeds all ten camera axes on every apply beat. The XA clip-table writer and the clip-cue census are settled on audio; the dev movie paths in the overlay do not ship on the disc.

Every claim here traces to the PROT 0970 disassembly, save-state captures taken during playback, and cold-boot probes of the opening; the per-claim source list (decoder formulas, the XA layout, the playback clock, the mode handlers) is on cutscene internals and in the full doc.

See also