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_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; decodercrates/xa - In the port
crates/mdec(decoder, and thestr_avdemuxer both play hosts open a movie through),crates/xa,engine-shell::cutscene_av; opening timeline inengine-core- Confidence
- Confirmed - disassembly of PROT 0970 plus RAM captures during playback and a cold-boot pixel capture of the opening (evidence)
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 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_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 | taiku | 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. 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
- Mode 26Pick the 32-byte slot
0x801D0A6C + fmv_id × 0x20, clear the letterbox bands, select the Iki decoder (FUN_801CEA3C). - Seek
CdSearchFileresolves the path; seek(start_frame − 1) × 10sectors in and open the SPU CD input (FUN_801CF098). - Stream
CdlReadSwith real-time XA on and the sector filter off, into a 32-sector ring. - DemuxVideo sectors gather per frame in ring slots; XA sectors are played by the drive and never touch the CPU.
- DecodeThe Iki bitstream becomes MDEC codes, DMA'd to the chip; each 32-px strip is blitted to VRAM as it comes out.
- 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.
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:
- Header10 bytes: code count, magic
0x3800, width, height, LZSS size. - DC tableLZSS inflates to one 6-bit qscale + 10-bit DC per 8×8 block.
- AC codesRun/level VLC per block, ended by EOB; an escape carries a raw 16-bit code.
- IDCTDequantise through the zigzag order, then a separable 8×8 inverse DCT.
- MacroblocksSix blocks each - Cr, Cb, Y0..Y3 - laid out column by column.
- 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 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_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
0x21ends 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 its0x1Flead 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
opdeenegeometry 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. 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 four CLUT-cell cyclers in the scene’s effect scripts rewrite the CLUT rects(0, 473, 320, 2)and(0, 476, 320, 34)asL = 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.
- 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 ineddoman); the port holds the record in both places, so each shot lasts as long as in the retail film. - The window shot. In
edkoroutthe 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]), raising0x225. - Mei below the garden gate (
P2[4], a walk-on band), raising0x226. 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), raising0x227. - 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.
| Style | Look | Length | Fade tail |
|---|---|---|---|
| 0 | Scatter: 1280 particles carrying patches of the field | 132 frames | black (white on one sub-arm) |
| 1 | Spin-up | 132 frames | white |
| 2 | Tile shatter - the default, so an ordinary random encounter shatters | 132 frames | black |
| 3 | Curtain: the capture cut into row and column strips | 132 frames | black |
| 4 | Swirl | 252 frames | white |
- 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.