Which leaf a frame enters

The mesh-chain walk is a three-way choice, opened at every site by the same test on the drawn actor's +0x42 halfword:

Actor draw 3 SCUS brackets FUN_8002735C table-driven renderer test +0x7A light-source sibling gate FUN_80029888 light-source sibling FUN_80043390 per-prim dispatcher +0x42 != 0 +0x42 == 0 != 0 == 0
Dashed arms are the ones no sampled mode takes. A census over a battle, a world map, an in-engine cutscene and the slot machine counted thousands of actor draws, every one with +0x42 == 0 and +0x7A == 0, and thousands of dispatcher entries against zero renderer entries.

The gate is a property of the actor, not of the mode: the disc ships writers of +0x42 (the actor allocator behind a dev counter, and move-VM op 0x10 from script data), so a state that raised it would enter the table-driven renderer in any mode. The third bracket, FUN_80048A08, is what the arts after-image renderer draws each motion-trail ghost through.

The light-source rows

Group flags 0x10..=0x17 (descriptor rows 0 and 1) carry no colour word. The dispatcher picks a body by flags >> 1 (srl s5,s7,0x11 at 0x800435A4), so these become kinds 8..11 - the only handlers with a GTE light op. A corner's colour is NCCS / NCCT:

  1. IntensityIR = clamp0(L · n >> 12) - light matrix times the TMD normal
  2. AmbientIR' = clamp0((BK << 12) + LC · IR >> 12) - back colour plus the light colour
  3. Object colourout = RGBC · IR' >> 12, saturated per channel
InputSource
RGBCThe dispatcher's colour argument times the object's +0x18 colour word, >> 7 per channel; both field sweeps pass 0x808080
BK_DAT_8007B788's low three bytes, each << 4; scene load sets 0x202020, op 4C 8A changes it
LCStatic block 0x800704EC: every row (4096, 0, 0), so all channels follow the first light alone
LBuilt each frame by FUN_800172C0 from the angle trio _DAT_8007B780..84 (half-turn added on X), folded with the draw's rotation - so intensity is against the world normal

Exec breakpoints on cave01 count hundreds of kind-8 and kind-9 entries per second, every one returning into the decoration pass; the port's frame of that state, shaded this way, matches retail to within image noise. On a kingdom overworld the same breakpoints catch nothing - with a control group of the overworld's own overlay leaves firing on the same frames - and no TMD in the kingdom bundles or the party pack carries a light-source group. Both hosts shade lit meshes through one kernel, engine-field::field_lit_mesh, with one shaded copy per mesh and rotation.

Per-primitive dispatch table

FUN_80043390 decodes a kind (0..19) and tail-calls a 20-slot × 4-bank jump table: 0x8007657C on the SCUS path, 0x801F8968 when the world-map overlay is paged in. Each handler runs RTPT/RTPS → NCLIP → AVSZ3/AVSZ4 → packet into the ordering table. Even kinds are triangles, odd kinds quads.

KindsBanksColour opReached by
0..7--NULL slots in every bank
8..11bank-invariantNCCS / NCCT (lit)the field's light-source rows
12..19bank 0 opaque; banks 1..3 add the depth cueDPCS / DPCTthe hot path: field, battle, summons

The bank is an offset the dispatcher itself writes (0x00, 0x50, 0xA0, 0xF0) from its blend argument and tint bits; it is not the blend equation, which is stored separately. The overworld swaps in eight overlay-resident replacements for kinds 12..19 (PROT 0901), so a sweep bounded to the SCUS span misses them. The body at 0x80044798, between the lit set and the fog banks, is a read-back helper, not a table entry.

The port's VRAM upload passes

StepWhat happensWhere
Collect targetsParse every TMD in the scene; union the palette rows and texture-page rectangles its primitives sampleSceneResources::build_targeted
Shared blocks firstUpload the boot-resident UI pack and the character pack (PROT 0874) so scene-local TIMs win any collisionFIELD_SHARED_BLOCKS
Image passWrite each TIM image that overlaps a sampled page and no sampled palette rowbuild_vram_targeted
Palette passWrite each TIM palette block that overlaps a sampled row, unconditionallysame
Verdict per primitiveOk / MissingClut / ClutDepthMismatch / MissingTexturePage; bad ones are dropped at mesh buildVram::prim_texture_status

Retail's loader uploads every scene TIM; the VRAM parity oracle switches to that (upload_all_tims) while the render path stays targeted. Untextured primitives skip VRAM entirely and draw from their colour block on the vertex-colour pipeline.

Colour space

Every colour is a PSX framebuffer value - display-referred, what the console clocks out. The swapchain is a UNORM view, never sRGB (an sRGB attachment would lift retail's mid-grey from 132 to 190); the last shader stage quantises to 5 bits and expands (c5 << 3) | (c5 >> 2); semi-transparency blends in the same space.

Five surfaces, one engine

engine-render links wgpu, so the browser cannot depend on it; kernels in engine-core, engine-ui, engine-vm or legaia-tmd are the ones every surface shares.

SurfaceDraw-list assembly
Native play-windowengine-shell window/field_render.rs, window/geometry.rs
Browser play pageweb-play play.rs, play_battle_render.rs
Browser field-scene viewerweb-viewer field_scene.rs; scene_geom.rs for the world map
Browser dance hallweb-minigames dance_presentation.rs
Browser fishing venueweb-minigames fishing_scene_web.rs

Placement rotation is three angles on every surface (Rx · Ry · Rz); a yaw-only builder loses maps like juui1, which tilts all nine placements a quarter turn about X. The battle backdrop is one TMD retail draws twice, the second copy under a per-stage transform; both hosts append the copy at build time.

How we know

Function / dataAddressWhat it showsDump
TMD rendererFUN_8002735C60 GTE ops per group; the only colour op is DPCS. Three jal sites, all behind the +0x42 testfuncs/8002735c.txt
Light-source siblingFUN_80029888Same op set over the same table; behind +0x7Afuncs/80029888.txt
Descriptor tableDAT_8007326CSix 8-byte rows; byte3 shape, byte4 vertex offset, byte1 colour blockSCUS data
Per-prim dispatchFUN_80043390Kind by flags >> 1; stages far / back colour per objectfuncs/80043390.txt
Lit handlersFUN_8004409C / 423C / 4434 / 45B0The only light-matrix consumers disc-wide (five NCC* sites, no MVMVA selecting the light matrix); run on cave01, never on a kingdom overworldfuncs/8004409c.txt
Scene light resetFUN_8003AEB0Angle trio (0x994, 0x9CC, -0x62C), back colour 0x202020funcs/8003aeb0.txt
Screen constantsGTE control file, GPU registersOFX/OFY, clip, offsets, display window constant across a save-state corpusgte_projection_real.rs
Depth cue identitytown0c captureA GT3 primitive's corner colours leave DPCS byte-unchanged at IR0 = 0mednafen state
Dither bit0x8002004C, 0x80017208, 0x8001D520, 0x801E350CInit stamps 0; frame driver re-stamps from _DAT_8007BA66 (boots 1); a field op writes itfuncs/80016b6c.txt
Overworld curvatureFUN_800271A8Two 0x8000 buffers: a quadratic drop ramp and the 0x2000-entry screen-Y curvature table the overworld leaves add to SYfuncs/800271a8.txt
Field drop shadowFUN_8001C394A 3×3 grid around the actor's feet, four POLY_FT4 cells of a 16×16 blob on page 0x001F, CLUT 0x7F86funcs/8001c394.txt
TMD pointer tableFUN_80026B4C → 0x8007C018Registered meshes by index; party in slots 0..4funcs/80026b4c.txt

Details

TMD pointer table and who reads it
FunctionRole
FUN_80021B04Actor-spawn helper; builds the per-actor OBJECT table
FUN_80024D78Per-actor OBJECT-table rebuild
FUN_8001EBECPer-frame OBJECT[10/11] swap - pose select for the party
FUN_8001E890Targets PROT 876 (player_data: VAB + TIM list + SEQ); it does not load the character meshes, which come from PROT 0874 section 0 (character mesh)

Battle player meshes are assembled per character from the player battle files' equipment sections, not loaded whole (battle data pack).

Colour grade, fades and the depth-cue ramp

The prologue's sepia is a palette collapse: four CLUT-cell cyclers in the prologue's effect scripts grey and re-tint the CLUT rects (0, 473, 320, 2) and (0, 476, 320, 34) to L = max(r,g,b) → (L, (L·247) >> 8, L >> 1), and the scripts' two 4C E6 ops rewrite every resident TMD colour word to (V, V·246 >> 8, V·112 >> 8), V = min(max(rgb), 0xF8) − 30; the port's shaders apply the same laws per texel and packet colour (cutscene). The scripted screen fade (4C 12) multiplies its tint into the grade and the depth-cue far colour. set_depth_cue_ramp stages retail's far-field blue crush as a view-depth IR0 ramp on the prologue only. UI is never graded.

Texture window, palette depth, diagnostics
  • GP0 0xE2 texture window. Four 5-bit values in 8-pixel steps; retail leaves it zero almost everywhere. Shader: coord = (coord & ~(mask × 8)) | ((offset & mask) × 8).
  • Palette depth. A 4bpp primitive's CLUT row may hold sixteen packed 16-entry palettes; an 8bpp row two 256-entry palettes. Past that, another TIM's image has spilled onto the row.
  • Flat-material trick. Primitives sampling texpage (960, 256) + CLUT (64, 510) read a constant mid-grey patch of the boot-resident system-UI bundle (row-510 strip).
  • CLIs. legaia-engine clut-trace, vram-oracle --runtime-vram, info --tmd-stats; tmd prims --vram-dir, tmd vram-dump --annotate. asset-viewer tmd --no-textures is the one place a synthetic light exists, as a bare-geometry preview.
GTE emulator and trace harness

crates/render-kernels/src/gte.rs is a bit-exact software model of the geometry coprocessor - not on the render path (that is f32 wgpu math) but the oracle it is measured against: q3.12 matrices, q19.12 translations, i64 multiply-add, RTPT with PSX saturation, and a full cop2 register file (vectors, MACs, IRs, the SXY/SZ/RGB FIFOs, FLAG at hardware bit positions). It covers the transform family (RTPS/RTPT, NCLIP, AVSZ3/4, MVMVA), the normal-colour family (NCDS.. NCCT, CDP, CC), the depth-cue family (DCPL, DPCS/DPCT, INTPL) and register transfers. legaia-engine gte-replay --trace FILE replays a captured retail trace against it.

Readings this page replaced

The field was once read as entirely unlit: a cold-boot town01 sweep saw no lit handler, but town01 sets a white back colour so its lit rows are indistinguishable from baked ones; cave01's dark walls settle it. The world-map slot-4 data was once presumed the lit handlers' consumer; it is an animation bank, and breakpoints confirm no overworld execution. The player texture atlas was once placed in PROT 876 (it is PROT 0874 section 2). FUN_8003DAA8 was read as a present driver (it is the CD load-kick driver). The draw-environment initialiser's dtd = 0 misled a first reading that retail does not dither. See do-not-re-walk.

See also