Cutscene internals
The register-level companion to cutscenes: how the movie overlay pumps sectors through its ring and feeds the MDEC chip, how a frame decodes step by step, and how the scripted opening's timeline, narration roller, camera mover, actor channels and gold grade work in retail and in the port. Read the main page first; this one assumes its vocabulary.
At a glance
- Movie overlay
- PROT 0970 at
0x801CE818; play loop 1236 bytes; ring = 32 sectors at asset buffer+0x10000 - Frame decoder
- Iki: 10-byte header, LZSS qscale/DC table, AC VLC stream; port
legaia_mdec::MdecDecoder - Opening timeline
- Partition-2 named records in each scene MAN, run by the field VM
FUN_801DE840as a spawned context - Camera
- Op
0x45: 10 params into globals; mover actorFUN_801DD310; portengine-vm::camera_mover - Roller
- Handler
FUN_80037174; geometry at_DAT_801C6EA4 + 0x4C..0x52; portCutsceneNarration - Grade
- Palette law
(L, L−1, L>>1)on every CLUT entry; portRenderer::set_palette_grade - Confidence
- Confirmed; the gold-grade CLUT rewrite is capture-observed with no statically visible writer loop
Movie overlay: ring, demux and MDEC feed
| Stage | Mechanism |
|---|---|
| Decoder select | DAT_801E09FC: Iki by default; dispatch slots 9/10 (dev files) select the STRv2/v3 path |
| Ring | 32 sectors in the streaming asset buffer at _DAT_8007B85C + 0x10000, set up by StSetRing / StSetStream |
| Read | Setmode 0xE0 (Speed | RT | Size1: real-time XA on, sector filter off) then CdlReadS |
| Demux | Data-ready callback gates each sector on magic 0x160 + stream number, skips to start_frame, accumulates sequential chunks into per-frame slots (status 2 = complete; a full ring drops the frame), latches end_frame on chunk 0 |
| Consume | StGetNext / StFreeRing - the retail counterpart of the port's StrFrameAssembler |
| Decode | Iki: LZSS qscale/DC table, then a GTE leading-zero-count VLC scan into the MDEC-code double buffer |
| Feed | DMA-0 into the MDEC; an out-slice callback blits each 32-px strip into the slot's VRAM rect; sync waiters spin on a 0x100000-iteration budget |
The five MDEC register/DMA helpers (feed, reset, two sync waiters, timeout dump) are the port boundary: they describe the chip, so the port has no counterpart for them. Everything above them - loop, ring, pump, slice callback, output control word - is a decision about the bitstream and has a crates/mdec counterpart.
The STRv2/v3 path (dev slots only)
This decoder is a bit-prefix lookup, not a run/level VLC. A table unpacked at runtime into DAT_801E0A00 by FUN_801F1A00 - called once per movie even for Iki slots that never read it - stores pre-baked MDEC output codes, one to three per hit plus a bit length, in four regions: luma DC, chroma DC, AC primary, AC secondary. Only the DC coefficients (raw 10-bit in v2; size-prefixed predicted differences per channel in v3), the 0x7C1F escape codes and the end padding are computed. Ported as legaia_mdec::strv2_table (unpacker, mdec strv2-table <overlay>) and strv2_decode::decode_frame; with no retail movie on this path there is no golden decode, so its tests pin the code paths against the disassembly.
Overlay residency window (captured during playback)
| Address | Stride | Contents |
|---|---|---|
0x801CAE08 | 24 B | libcd CdlFILE cache ([CdlLOC][size][name[16]]) for the last directory searched - the MOV dir while a movie plays; not a movie structure |
0x801CCA80 | 56 B × 6 | ISO9660-shape directory records of the six movie files |
0x801CE810 | variable | Path strings: \DATA\MOV.STR;1, \DATA\MOV15.STR;1, \MOV\MV1A.STR;1, \MOV\MV6..MV1.STR;1 |
0x801CE8AC | variable | Post-movie return-scene labels: town0b map01 chitei2 map02 jou uru2 town0e |
0x801D0A6C | 32 B × 23 | Dispatch table - STR FMV table |
MOV15.STR is a 15 fps test file and MV1A.STR an alternate cut of MV1; neither ships. The 8-bit ADPCM decode path (BitsPerSample::Eight) has no example in the movie corpus and is covered by synthetic tests only.
Decoding one Iki frame
| Step | What happens |
|---|---|
| 1 Header | 10 bytes: mdec_code_count, magic 0x3800, width, height, lzss_size |
| 2 LZSS table | lzss_size bytes inflate to block_count × 2; control bits LSB-first, 0 = literal, 1 = back-reference (length byte +3, 1- or 2-byte offset +1, overlapping copies allowed). Block i: (table[i] << 8) | table[i + block_count] = 6-bit qscale, 10-bit signed DC |
| 3 AC stream | 16-bit LE words, MSB-first; PSX VLC run/level codes per block, EOB 10, escape 000001 + 16-bit raw run << 10 | level. A full block still ends with an explicit EOB |
| 4 Dequantise + IDCT | coef[0] = DC × Q[0]; coef[zigzag[i]] = (level × Q[i] × qscale + 4) >> 3, unclamped; two-pass separable IDCT with IDCT_C pre-scaled by 2048, full i64 rows, one >> 24 after the columns |
| 5 Macroblocks | Six blocks each: Cr, Cb, Y0..Y3; laid out column-major, 16 px down then the next column |
| 6 Colour | 4:2:0 upsample; R = Y' + (91881·Cr >> 16), G = Y' − ((22554·Cb + 46802·Cr) >> 16), B = Y' + (116130·Cb >> 16) with Y' = Y + 128; row-major RGBA8 out |
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. XA decode is bit-exact against a lossless reference of a real track; sector layout and filter coefficients are on XA audio.
The opening timeline
A timeline is a partition-2 named record in the scene MAN, executed by the ordinary field VM. Two things spawn one: op 0x44 in the scene's entry script (operand = a global record index re-based into partition 2), or a walk-on tile trigger (kind-1 record [tile_x][tile_z][p2_record][gate] in the scene .MAP's trigger block; the entry seat lands on the tile and fires the same tick). Both end in FUN_8003BDE0, which tests the record's story-flag gates against the bitmap at DAT_80085758 and spawns a VM context.
| Field | Size | Meaning |
|---|---|---|
name_len | 1 | Name length in characters |
| name | name_len × 2 | SJIS record name |
C0 + bytes | 1 + C0 | Skipped |
C1 + u16s | 1 + 2·C1 | Block if ANY flag set |
C2 + u16s | 1 + 2·C2 | Require ALL flags set |
| script | - | Entry PC; opdeene record 18 opens with an effect-colour reset then GFLAG_SET 26 |
Nearly every op in the record carries the cross-context target 0xF8, which resolves to the player / camera-anchor actor - so the timeline drives the camera and the lead actor rather than itself. On-disc records have no end opcode: they park in a Nop+JmpRel-to-self loop or loop back to their top as a resident driver, and retail leaves them spinning invisibly.
Camera Configure (op 0x45)
A big-endian 10-bit mask selects which of ten signed-16 parameters follow; each is written to a camera struct at 0x801C6EA8 and committed by FUN_801DE084, which either snaps (apply == 0, killing any glide in flight) or hands a mover actor ten (start, end) pairs plus one shared progress, duration and curve.
| Param | Global | Role |
|---|---|---|
| 0 / 1 / 2 | _DAT_8007B790/92/94 | Pitch / yaw / roll (GTE angles, 4096 = 360°); eight scenes stage a non-zero roll |
| 3 / 4 / 5 | _DAT_800840B8/BC/C0 | Eye-space offset trio (dx, dy, depth) - slot 5 is how far the eye sits behind the focus |
| 6 / 7 / 8 | _DAT_80089118/1C/20 | Focus point; GTE translation is (−X, +Y, −Z) |
| 9 | _DAT_8007B6F4 | H, the projection distance (zoom) |
The view builder composes screen = H · (R · (v − focus) + tr_eye) / Ze, with a constant 6× world scale folded into R. The mover advances t by the frame-skip factor each frame, so apply is a duration in display frames; every axis uses the same curve, angles included, with no shortest-arc handling.
| Curve | Shape | Formula for offset k = end − start |
|---|---|---|
| 1 (and any other) | Linear | (k·t)/d |
| 2 | Quadratic ease-out | (n + (n/d)(d − t))/d, n = k·t |
| 3 | Quadratic ease-in | ((k·t)/d · t)/d |
| 4 | Ease-in-out | Curve 3 to the midpoint over d >> 1, then curve 2 |
Port: legaia_engine_vm::camera_mover, and play-window eases the rendered pose through CutsceneCameraInterp in display-frame time. Because retail scales the world 6× and the engine renders at 1×, tr_eye is divided by 6 - the perspective divide makes both project identically. Reference frame: focus (8640, 0, 10304), pitch 180, yaw −2967, H 792, tr_eye (260, 1293, 17145) projects the party to (172, 180), matching the capture.
The narration roller
Spawn op CC F8 80 N allocates a child actor from template DAT_801F28A0 with handler FUN_80037174, points its script pointer at the page-count byte, and the parent measures the pages to skip past the block. Config op CC F8 E8 w0 w1 w2 w3 (four signed-16 words) is mode-selected by w3:
w3 | Effect |
|---|---|
| 0 | Seed geometry: +0x4C window top Y (default 64), +0x4E visible lines of 16 px (default 8, bottom clamped ≤ 232), +0x50 scroll divisor (px/frame = frame-skip / divisor, default 4) |
| 1 | w0 == 0 pauses the live roller; otherwise +0x52 = stop after N lines |
| 2 | Resume |
| 3 | Unlink the child |
| Scene | Enter / exit Y | Spacing | Speed |
|---|---|---|---|
opdeene | ~188 / ~64, up to 8 lines visible | 18 px | 0.5 px/frame |
opstati, map01 | ~203 / 128 | 16 px | 0.5 px/frame |
opurud | ~187 / 128 | 16 px | 1.0 px/frame |
Lines draw centred, all glyphs at once, scrolling upward in a clipped window. The single-line balloon (4C E1, centred at Y = 180, 120-frame timer) is a different op and never carries the crawl. Port CutsceneNarration + RollerParams::for_scene.
Per-actor channels
Retail spawns one script context per partition-1 placement at scene entry (FUN_8003A1E4); its script id is partition-0 count + placement index, the id space cross-context ops resolve through. The timeline halt-acquires channels 0x05..0x0F (a sweep of 4C 85), pokes each beat (4C 45 param, 4B animate, 23/A3 move), resumes with B2 <id> 0A, and waits at B3 <id> <bit> until the channel's own script raises its completion flag. Even a halted channel keeps animating, because the per-actor anim tick runs independently of the parked script PC.
Port legaia_engine_core::field_channels: one FieldChannel per placement, stepped a slice per tick; pokes run against the resolved context; a failing cross-context flag test parks the timeline (bounded by CHANNEL_WAIT_PARK_TIMEOUT, falling back to a step-past). The player channel 0xF8 is modelled directly: ExecMove arms a countdown and the halt-acquire parks until it drains, so door records flow on to their scene change. Idle clips come from the scene's ANM bundle, whose descriptor-count seed is not uniform - the prologue scenes resolve only at count ≥ 5, so the render searches [3, 5, 6, 7].
Engine execution rules
- Only cutscene-class records (the opening chain, gated walk-on beats) install as the modal timeline with camera seize and locomotion lock; an ordinary mid-play op-
0x44spawn becomes a concurrent helper context that leaves the camera alone but still refuses the pad until it ends - retail's script runner raises the player's engaged bit for every context it steps. - Narration blocks are data, so the stepper installs the pages and advances the PC past the block; it holds only for a scene's last crawl (so the scene change waits) and when a roller is still scrolling.
- Camera beats merge into a persistent parameter set - one
opdeenebeat sets onlyH- cleared on scene entry. - Completion is the choreography wrap: a backward jump onto an already-executed PC. Real waits (
0x4A, flag handshakes, the0x49name-entry suspend) halt at their own PC. - A byte
& 0x7F < 0x20at the PC is a dialog transition, not an opcode; an0x80-bit op whose target matches no spawned channel is skipped by width; resolved-channel busy-waits fall through because engine pokes complete synchronously. - Both the roller and the timeline step off a 60 fps sub-clock inside the 100 Hz sim, matching retail wall-time within ~4%.
Two ops that do not do what their dump suggests
4C 49selects a write variant on two bits of_DAT_1F800394(bit 25 delta, bit 24 player-relative+0x4A = value + anchor[+0x16], else default), always advancing 6 bytes. The field-overlay dump's absolute-jump arm never applies on the opening path.4C 9Fis a retire sweep, not a callback registration: it sets the kill bit on every live actor whose per-frame handler matches, then advances the script two bytes. It is inert during the opening and fires zero times under a live probe.FUN_8003CF04is a list finder over0x8007C34C, not a kill function.
Fades, effect colour and the gold grade
| Op | Bytes | Target | Effect |
|---|---|---|---|
4C 12 | [r][g][b][ramp u16] | Multiply tint DAT_8007BCB8..BA, neutral 0x80; ramp jobs via FUN_8003C5F0 | Screen fade over the 3D scene; persists across scene changes; text unaffected |
34 sub-0 | [op0][r][g][b][ramp u16] | Spawns a colour tween on the effect actor at _DAT_8007B62C; op0 itself carries the blend (&1 ? 2 : 1) and the push kind (8 on &2, 0 on &4, else 2) | Walks the effect colour out and back in as a pair of tweens; not a screen fade - the tableau stays lit through it. An all-zero operand clears the effect outright (the arm drops the actor pointer and never spawns), and a pure-white target under blend 2 loses an eighth of its duration |
Every scene's entry script arms 4C 12 00 00 00 00 00 then 4C 12 80 80 80 44 00 - instant black, 68-frame ramp to neutral. New Game sets system flag 0x52F to arm that handshake, and the engine pre-runs the entry script's load-frame slice so black is on screen before the first render. Port: fade::SceneTintRamp in World::presentation.tint; the effect beat is a pool colour tween read back as World::screen_tint_pushes, which is the one model - a live capture of a retail beat reads a three-argument push per frame, the push's shape exactly, while the global multiply tint stays neutral throughout. The three arguments are an ordering-table bucket, a semi-transparency equation and a GP0 colour word (red in the low byte); both hosts draw the frame's pushes through one shared emitter, having simulated the envelope and drawn nothing for as long as the pool had no consumer.
The gold grade on opdeene / opstati / opurud is a palette-space law applied to the loaded assets: every CLUT row the bundle uploads is rewritten entry for entry from the disc value to (L, max(L−1, 0), L >> 1) with L = max(r, g, b), STP bit preserved. Packet colours split by source - the ground kernel's runtime neutral 0x80 stays neutral and draws gold purely through its collapsed CLUT, while each resident TMD's authored colour words are rewritten by the scripts' own two 4C E6 HSV ops (saturation -0x100, then hue +0x38 / saturation +0x90 / value -0x1E) to (V, V·246 >> 8, V·112 >> 8) with V = min(max(r,g,b), 0xF8) − 30 - a retail opdeene state holds every resident word on that curve. No depth cue runs: IR0 is 0 on every render node throughout the opening.
The port applies the same laws in its mesh shaders (palette_law_word / prologue_sepia_word, CPU mirrors in lockstep tests) because a 4/8bpp texel is a palette entry; exact-neutral words stay neutral, and the screen tint rides the same uniform. The ground matches the capture at G/R 0.890, B/R 0.46..0.48. The far spires read brighter in the engine (B/R ≈ 0.27 vs 0.15) because lit prims with no baked colour are fed neutral and skip the collapse, where retail shades them through its dim scene ambient - an engine boundary, not a missing law.
History: the depth-cue and tint models
The grade was first modelled as a gold far colour with a per-node depth-cue pull, then as a render-time pixel multiply; the CLUT peek falsified the first (IR0 = 0 everywhere) and the ground's green cast (G/R ≈ 1.07) the second. A signature scan across PROT 0970, 0897 and the executable finds no CLUT-rewrite arithmetic loop - the rewrite is a table/DMA upload - so the “overlay load hook” reading is also closed. The op-0x34 ramp was read as a between-beat black fade and, before that, as a white flash with a 50% wash; the cold-boot capture holds the lit tableau across the span. Both dormant approximations (apply_grade, fade::DepthCueRamp) remain as bypassed plumbing.
Battle intro and script helpers
| Piece | Where | What it does |
|---|---|---|
| Style select | FUN_801CE8CC (PROT 0979) | Reads battle flags DAT_8007BD60 bit 0x80, first monster id DAT_8007BD0C, scene index DAT_80084540; default style 2, style 3 for three formations, 4 for one; two delay-slot stores land on both branch arms, which is why the flags-set path defaults to 1 |
| Transition tick | FUN_801CF5BC | Owns the whole frame for DAT_801D2458 frames, then hands off to the battle scene |
| Emitters | render track | The fade's second argument is a blend mode, not a depth; style 2's emitter is not a GTE emitter. Port engine-ui::battle_intro |
FUN_801D27E0 | dialogue overlay | Party-leader swap state machine (6 states on actor +0x54); writes DAT_80084597 |
FUN_801D5C08 | dialogue overlay | Position tween: +0x9C += (+0x9E) × frame-skip, lerp +0x14 to +0x24, done bit 8 at t ≥ 0x1000 |
FUN_801D5D60 | dialogue overlay | Scripted-element teardown: restore the camera, clear enable flags once done |
FUN_801D6058 | dialogue overlay | Ambient particle emitter gated on _DAT_8007B854: single jittered spawns or 24 random bursts |
FUN_801D5E20 | dialogue overlay | Rotates a mesh's own colour words |
The full per-style emitter decode is in docs/subsystems/cutscene.md.
How we know
| Claim | Evidence |
|---|---|
| Ring / demux state machine | FUN_8005BBF8, FUN_8005EDC4, FUN_8005EB68, FUN_8005ECD4, FUN_8005F024, FUN_8005EF40, FUN_8005EE4C (SCUS_942.54 St library) |
| MDEC feed boundary | FUN_801CFD84, FUN_801CFFDC, FUN_801CF56C, FUN_801D0100, FUN_801D0198, FUN_801D0248, FUN_801CFEE0 - carry the MDEC_in_sync / MDEC_rest:bad option strings |
| Record header + gates | FUN_8003BDE0; decoder man_field_scripts::partition_record_span |
| Spawn routes | Op 0x44 at FUN_801DE840 (ra 0x801DF098); tile trigger FUN_801D1EC4 → FUN_801D5630 → FUN_8003BDE0 (ra 0x801D218C); exec breakpoint = 5 hits across the opening |
Cross-context 0xF8 | FUN_8003C83C(0xF8) → _DAT_8007C364 |
| Camera | FUN_801DE084, FUN_801DD310 (mover), FUN_801DC0BC (per-frame), FUN_800172C0 (view), FUN_80026988 (rotation, LUT 0x80070A2C; FUN_8001CF50 is the per-node camera-relative variant), base matrix DAT_8007BF10; live capture 2471/2480 axis values |
| Roller | FUN_80037174, template DAT_801F28A0, allocator FUN_80020DE0, page measure FUN_8003CA38; per-scene geometry from pixel capture |
| Caption TIM | PROT 0749 at LZS offset 0x01EC30; zero UI text/blit draws in the caption window; string absent from a full RAM dump |
| Channels | FUN_8003A1E4 per placement from FUN_8003AEB0; anim tick FUN_8003BC08 → FUN_80021DF4; disc-gated opdeene_field_channels (13 channels) |
| Fade ops | FUN_8003C5F0; op-0x34 arm at 0x801E1FB0; disc-gated opening_fade_from_black |
| Gold grade | VRAM CLUT peek vs disc TIMs, zero mismatches; render-node walk IR0 = 0; engine ground pixel-matched |
| Battle intro | FUN_801CE8CC, FUN_801CF5BC, DAT_801D2458 (PROT 0979) |
| Chain oracles | opening_full_chain_e2e, opdeene_timeline_execution, opdeene_narration_playback, town01_opening_name_entry_wiring (disc-gated); cutscene_timeline_synthetic, cutscene_framing_tests (disc-free) |