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_801DE840 as a spawned context
Camera
Op 0x45: 10 params into globals; mover actor FUN_801DD310; port engine-vm::camera_mover
Roller
Handler FUN_80037174; geometry at _DAT_801C6EA4 + 0x4C..0x52; port CutsceneNarration
Grade
Palette law (L, L−1, L>>1) on every CLUT entry; port Renderer::set_palette_grade
Confidence
Confirmed; the gold-grade CLUT rewrite is capture-observed with no statically visible writer loop
Four parallel contexts during an opening scene: the timeline record, the narration roller it spawns, the camera mover it stages, and the per-actor channels it pokes - all ticked by one per-frame actor walk spawn spawn cut glide SceneChange per-frame actor walk FUN_8002519C - every live context gets a slice each frame timeline record narration roller camera mover actor channels crawl 1 scrolls crawl 2 snap dolly, apply 840 frames
The timeline never blocks on the roller or the mover: it spawns them and keeps executing, so camera cuts play under the scrolling text. It blocks only before a new crawl block, on a channel's completion flag, and before its terminal scene change.

Movie overlay: ring, demux and MDEC feed

StageMechanism
Decoder selectDAT_801E09FC: Iki by default; dispatch slots 9/10 (dev files) select the STRv2/v3 path
Ring32 sectors in the streaming asset buffer at _DAT_8007B85C + 0x10000, set up by StSetRing / StSetStream
ReadSetmode 0xE0 (Speed | RT | Size1: real-time XA on, sector filter off) then CdlReadS
DemuxData-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
ConsumeStGetNext / StFreeRing - the retail counterpart of the port's StrFrameAssembler
DecodeIki: LZSS qscale/DC table, then a GTE leading-zero-count VLC scan into the MDEC-code double buffer
FeedDMA-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)
AddressStrideContents
0x801CAE0824 Blibcd CdlFILE cache ([CdlLOC][size][name[16]]) for the last directory searched - the MOV dir while a movie plays; not a movie structure
0x801CCA8056 B × 6ISO9660-shape directory records of the six movie files
0x801CE810variablePath strings: \DATA\MOV.STR;1, \DATA\MOV15.STR;1, \MOV\MV1A.STR;1, \MOV\MV6..MV1.STR;1
0x801CE8ACvariablePost-movie return-scene labels: town0b map01 chitei2 map02 jou uru2 town0e
0x801D0A6C32 B × 23Dispatch 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

StepWhat happens
1 Header10 bytes: mdec_code_count, magic 0x3800, width, height, lzss_size
2 LZSS tablelzss_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 stream16-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 + IDCTcoef[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 MacroblocksSix blocks each: Cr, Cb, Y0..Y3; laid out column-major, 16 px down then the next column
6 Colour4: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.

FieldSizeMeaning
name_len1Name length in characters
namename_len × 2SJIS record name
C0 + bytes1 + C0Skipped
C1 + u16s1 + 2·C1Block if ANY flag set
C2 + u16s1 + 2·C2Require 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.

ParamGlobalRole
0 / 1 / 2_DAT_8007B790/92/94Pitch / yaw / roll (GTE angles, 4096 = 360°); eight scenes stage a non-zero roll
3 / 4 / 5_DAT_800840B8/BC/C0Eye-space offset trio (dx, dy, depth) - slot 5 is how far the eye sits behind the focus
6 / 7 / 8_DAT_80089118/1C/20Focus point; GTE translation is (−X, +Y, −Z)
9_DAT_8007B6F4H, 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.

CurveShapeFormula for offset k = end − start
1 (and any other)Linear(k·t)/d
2Quadratic ease-out(n + (n/d)(d − t))/d, n = k·t
3Quadratic ease-in((k·t)/d · t)/d
4Ease-in-outCurve 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:

w3Effect
0Seed 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)
1w0 == 0 pauses the live roller; otherwise +0x52 = stop after N lines
2Resume
3Unlink the child
SceneEnter / exit YSpacingSpeed
opdeene~188 / ~64, up to 8 lines visible18 px0.5 px/frame
opstati, map01~203 / 12816 px0.5 px/frame
opurud~187 / 12816 px1.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-0x44 spawn 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 opdeene beat sets only H - cleared on scene entry.
  • Completion is the choreography wrap: a backward jump onto an already-executed PC. Real waits (0x4A, flag handshakes, the 0x49 name-entry suspend) halt at their own PC.
  • A byte & 0x7F < 0x20 at the PC is a dialog transition, not an opcode; an 0x80-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 49 selects 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 9F is 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_8003CF04 is a list finder over 0x8007C34C, not a kill function.

Fades, effect colour and the gold grade

OpBytesTargetEffect
4C 12[r][g][b][ramp u16]Multiply tint DAT_8007BCB8..BA, neutral 0x80; ramp jobs via FUN_8003C5F0Screen 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

PieceWhereWhat it does
Style selectFUN_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 tickFUN_801CF5BCOwns the whole frame for DAT_801D2458 frames, then hands off to the battle scene
Emittersrender trackThe 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_801D27E0dialogue overlayParty-leader swap state machine (6 states on actor +0x54); writes DAT_80084597
FUN_801D5C08dialogue overlayPosition tween: +0x9C += (+0x9E) × frame-skip, lerp +0x14 to +0x24, done bit 8 at t ≥ 0x1000
FUN_801D5D60dialogue overlayScripted-element teardown: restore the camera, clear enable flags once done
FUN_801D6058dialogue overlayAmbient particle emitter gated on _DAT_8007B854: single jittered spawns or 24 random bursts
FUN_801D5E20dialogue overlayRotates a mesh's own colour words

The full per-style emitter decode is in docs/subsystems/cutscene.md.

How we know

ClaimEvidence
Ring / demux state machineFUN_8005BBF8, FUN_8005EDC4, FUN_8005EB68, FUN_8005ECD4, FUN_8005F024, FUN_8005EF40, FUN_8005EE4C (SCUS_942.54 St library)
MDEC feed boundaryFUN_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 + gatesFUN_8003BDE0; decoder man_field_scripts::partition_record_span
Spawn routesOp 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 0xF8FUN_8003C83C(0xF8) → _DAT_8007C364
CameraFUN_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
RollerFUN_80037174, template DAT_801F28A0, allocator FUN_80020DE0, page measure FUN_8003CA38; per-scene geometry from pixel capture
Caption TIMPROT 0749 at LZS offset 0x01EC30; zero UI text/blit draws in the caption window; string absent from a full RAM dump
ChannelsFUN_8003A1E4 per placement from FUN_8003AEB0; anim tick FUN_8003BC08 → FUN_80021DF4; disc-gated opdeene_field_channels (13 channels)
Fade opsFUN_8003C5F0; op-0x34 arm at 0x801E1FB0; disc-gated opening_fade_from_black
Gold gradeVRAM CLUT peek vs disc TIMs, zero mismatches; render-node walk IR0 = 0; engine ground pixel-matched
Battle introFUN_801CE8CC, FUN_801CF5BC, DAT_801D2458 (PROT 0979)
Chain oraclesopening_full_chain_e2e, opdeene_timeline_execution, opdeene_narration_playback, town01_opening_name_entry_wiring (disc-gated); cutscene_timeline_synthetic, cutscene_framing_tests (disc-free)

See also