Renderer internals
The evidence and the dense tables behind the renderer tour: which mesh leaf a retail frame really enters, the light-source formula, the per-primitive dispatch table, the port's VRAM upload passes, and the routine-by-routine provenance.
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:
+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:
- Intensity
IR = clamp0(L · n >> 12)- light matrix times the TMD normal - Ambient
IR' = clamp0((BK << 12) + LC · IR >> 12)- back colour plus the light colour - Object colour
out = RGBC · IR' >> 12, saturated per channel
| Input | Source |
|---|---|
RGBC | The 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 |
LC | Static block 0x800704EC: every row (4096, 0, 0), so all channels follow the first light alone |
L | Built 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.
| Kinds | Banks | Colour op | Reached by |
|---|---|---|---|
| 0..7 | - | - | NULL slots in every bank |
| 8..11 | bank-invariant | NCCS / NCCT (lit) | the field's light-source rows |
| 12..19 | bank 0 opaque; banks 1..3 add the depth cue | DPCS / DPCT | the 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
| Step | What happens | Where |
|---|---|---|
| Collect targets | Parse every TMD in the scene; union the palette rows and texture-page rectangles its primitives sample | SceneResources::build_targeted |
| Shared blocks first | Upload the boot-resident UI pack and the character pack (PROT 0874) so scene-local TIMs win any collision | FIELD_SHARED_BLOCKS |
| Image pass | Write each TIM image that overlaps a sampled page and no sampled palette row | build_vram_targeted |
| Palette pass | Write each TIM palette block that overlaps a sampled row, unconditionally | same |
| Verdict per primitive | Ok / MissingClut / ClutDepthMismatch / MissingTexturePage; bad ones are dropped at mesh build | Vram::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.
| Surface | Draw-list assembly |
|---|---|
Native play-window | engine-shell window/field_render.rs, window/geometry.rs |
| Browser play page | web-play play.rs, play_battle_render.rs |
| Browser field-scene viewer | web-viewer field_scene.rs; scene_geom.rs for the world map |
| Browser dance hall | web-minigames dance_presentation.rs |
| Browser fishing venue | web-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 / data | Address | What it shows | Dump |
|---|---|---|---|
| TMD renderer | FUN_8002735C | 60 GTE ops per group; the only colour op is DPCS. Three jal sites, all behind the +0x42 test | funcs/8002735c.txt |
| Light-source sibling | FUN_80029888 | Same op set over the same table; behind +0x7A | funcs/80029888.txt |
| Descriptor table | DAT_8007326C | Six 8-byte rows; byte3 shape, byte4 vertex offset, byte1 colour block | SCUS data |
| Per-prim dispatch | FUN_80043390 | Kind by flags >> 1; stages far / back colour per object | funcs/80043390.txt |
| Lit handlers | FUN_8004409C / 423C / 4434 / 45B0 | The only light-matrix consumers disc-wide (five NCC* sites, no MVMVA selecting the light matrix); run on cave01, never on a kingdom overworld | funcs/8004409c.txt |
| Scene light reset | FUN_8003AEB0 | Angle trio (0x994, 0x9CC, -0x62C), back colour 0x202020 | funcs/8003aeb0.txt |
| Screen constants | GTE control file, GPU registers | OFX/OFY, clip, offsets, display window constant across a save-state corpus | gte_projection_real.rs |
| Depth cue identity | town0c capture | A GT3 primitive's corner colours leave DPCS byte-unchanged at IR0 = 0 | mednafen state |
| Dither bit | 0x8002004C, 0x80017208, 0x8001D520, 0x801E350C | Init stamps 0; frame driver re-stamps from _DAT_8007BA66 (boots 1); a field op writes it | funcs/80016b6c.txt |
| Overworld curvature | FUN_800271A8 | Two 0x8000 buffers: a quadratic drop ramp and the 0x2000-entry screen-Y curvature table the overworld leaves add to SY | funcs/800271a8.txt |
| Field drop shadow | FUN_8001C394 | A 3×3 grid around the actor's feet, four POLY_FT4 cells of a 16×16 blob on page 0x001F, CLUT 0x7F86 | funcs/8001c394.txt |
| TMD pointer table | FUN_80026B4C → 0x8007C018 | Registered meshes by index; party in slots 0..4 | funcs/80026b4c.txt |
Details
TMD pointer table and who reads it
| Function | Role |
|---|---|
FUN_80021B04 | Actor-spawn helper; builds the per-actor OBJECT table |
FUN_80024D78 | Per-actor OBJECT-table rebuild |
FUN_8001EBEC | Per-frame OBJECT[10/11] swap - pose select for the party |
FUN_8001E890 | Targets 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
0xE2texture 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-texturesis 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.