At a glance

Retail renderer
One TMD routine in the main executable (plus a light-source sibling used only by the world map), steered by a six-row descriptor table.
Lighting
None on the field path. GPU rule: out = texel × colour / 128, after one GTE depth-cue op.
Screen
320×224 drawn, 228 lines displayed; projection origin OFX 160, OFY 114 - dead centre of the display window.
Depth
No depth buffer. Primitives are binned by mean Z into an ordering table and painted back to front.
Port defaults
Shading retail (baked colour, no light); rasterisation clean (vertex snap + dither opt-in); affine UVs always on; semi-transparency always blended.
Engine
crates/engine-render (wgpu, native), site/js/webgl-shaders.js (browser); shared kernels in legaia-tmd and engine-ui.
Confidence
Confirmed - disassembly of the GTE op stream, save-state register files, GP0 draw-list pins (how we know).

One primitive's journey

retail (SCUS_942.54) TMD prim groupheader + prims GTE: RTPT, DPCSproject + cue GP0 packettri / quad Ordering tablebucket = mean Z GPU into VRAMtexel x colour / 128 port (engine-render, site WebGL) mesh buildertmd_to_vram_mesh vertex shaderpsx_camera_mvp vertex attrsuv colour cba tsb depth keyprim_depth_key fragment shader1024x512 VRAM
Retail (top) runs the GTE and hands packets to the GPU; the port (bottom) does the same arithmetic in shaders, keeping PSX VRAM as a real texture so palette and texture-page lookups happen per pixel exactly as the hardware does.

A town is a few hundred small meshes - houses, fences, terrain tiles, the party. For each primitive group the game asks the GTE to rotate, translate and project the corners, wraps the result in a GPU packet, and drops it into an ordering-table bucket chosen by depth. When the frame is complete the GPU walks the table far to near and paints. Textures and palettes live as rectangles inside video memory, addressed by their top-left corner.

The descriptor table

A mesh can mix flat and smooth-shaded, textured and plain polygons. Each group's 8-byte header [count][flags][olen][ilen][flag][mode] is followed by count × ilen × 4 bytes of prims; the row ((flags >> 1) − 8) >> 1 of a six-row table says how each prim is laid out and which packet to build.

flagsRowShapeVertex offsetColour base
0x10/110FT (flat textured)0x070 - lit, normals trail the indices
0x12/131GT (gouraud textured)0x060
0x14/152F (flat)0x023 - baked colours first
0x16/173G (gouraud)0x063
0x18/194FT0x073
0x1A/1B5GT0x0B3

Bit (flags >> 1) & 1 picks quad over triangle; 0x20..0x27 re-use the same rows. Typed lookup: legaia_tmd::descriptor::Descriptor::for_flags. Byte layouts per mode: TMD format.

Lighting: painted, not computed

Legaia's towns look lit - shadowed alleys, bright plazas, a glow around lamps - but none of it is computed at run time. The artists painted a brightness into every polygon, and the console multiplies the texture by it. Any "helpful" light a renderer adds makes the game look wrong.

  • One colour op. The two TMD renderers issue exactly one GTE colour instruction, DPCS (depth cue). No NC* op ever runs on the field path, and the light matrices - which are populated - are read by exactly four routines in the whole game. A disc-wide census of GTE opcodes settles the set rather than leaving it open: five normal-colour instructions across 84 images, every one inside those four NC* prim handlers, and no MVMVA anywhere selecting the light matrix (29 select the rotation matrix). Those four handlers exist in the executable and do not run: exec breakpoints on all four catch nothing executing across two kingdom overworlds, an ordinary field scene or a battle - in a run where a control breakpoint on the handlers the overworld does use fires on the same frames. The world map was the presumed consumer and is not one.
  • The colour word. Every prim carries [R][G][B][GP0 code]; gouraud prims carry one per corner. 0x80 is neutral, lower darkens, higher brightens up to 2×. Across the field environment packs roughly four fifths of components sit below 0x80 - that headroom is why retail has more contrast than an unlit render.
  • Depth cue. DPCS blends the colour toward the far colour by IR0; an unfogged field scene passes IR0 = 0, so the cue is the identity and a capture's corner colours leave the op byte-unchanged.

The trap that keeps a synthetic light alive in a port: an unbound colour attribute defaults to white, and white is texel × 255/128 - a missing colour stream reads as "too bright", not as "unlit". Both site shader paths upload the packet colour for exactly this reason.

The screen

QuantityRetail valueRead from
OFX / OFY160 / 114GTE control file in a save state
Drawing area320 × 224GPU clip registers
Draw offset(0, 4) / (0, 244)alternating double-buffer halves
Display window320 × 228 scanlinesDisplayVStart/End = (28, 256)

All four hold across field, battle, battle load and the dance minigame, on both buffer halves - unlike H, which is written per phase (256 in battle, 512 in the field). OFY = 114 is 228 / 2: centre of the display window, not an offset. It reads as "six pixels above centre" only against a 240-line frame, which retail never draws.

The port keeps a 320×240 logical screen because every 2D rect is a retail draw-area coordinate copied verbatim, and puts the GTE origin on row 114 via a constant bias on clip y (GTE_OFY_NDC_BIAS) in both hosts. The residual is the frame height itself: retail's picture fills 224/240 of the port's, so everything reads about 7% smaller.

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, so a single draw call is not evidence of a single draw.

VRAM: choosing what to upload

The PS1 keeps every texture and palette in one shared 1 MB image. A polygon does not say "use texture X" - it says "read pixels from this rectangle and look them up in the palette at that row". Legaia's palettes are scattered across many PROT entries, and uploading every texture a scene owns lets one image overwrite another mesh's palette row: rainbow noise. Most of the port's VRAM work is deciding which bytes to write.

StepWhat happensWhere
Collect targetsParse every TMD in the scene; union the CLUT rows and texture-page rectangles its prims 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 block that overlaps a sampled page and does not overlap a sampled CLUT rowbuild_vram_targeted
CLUT passWrite each TIM CLUT block that overlaps a sampled row, unconditionallysame
Verdict per primOk / MissingClut / ClutDepthMismatch / MissingTexturePage; bad prims are dropped at mesh buildVram::prim_texture_status

Image-then-CLUT ordering matters: a per-prim collision heuristic drops legitimate palette rows whenever any mesh's UV box brushes the row. The retail loader itself DMAs every scene TIM; the VRAM parity oracle switches to that (upload_all_tims) while the render path stays targeted.

Untextured prims (fences, crates, painted props) skip the VRAM path: their colour block becomes a ColorMesh drawn by the vertex-colour pipeline, and a mixed mesh draws both halves at one placement. The fragment shader decodes 4/8/15 bpp and the CLUT per pixel, so one draw call carries every texture mode.

Colour space

Every colour the engine handles 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); textures are Rgba8Unorm; the last shader stage quantises to 5 bits and expands (c5 << 3) | (c5 >> 2); semi-transparency blends in the same space.

Knobs: what is faithful, what is a choice

Shading - which polygon is which colour - is retail by default. The PS1's rasterisation artefacts are off by default and one switch away. "Faithful" and "default" are different axes, so each knob lists both.

KnobDefaultRetail isGates
set_psx_mode (LEGAIA_PSX_RENDER=1)offonsub-pixel vertex snap + 4×4 ordered 15-bit dither, nothing else
set_semi_blendononABE semi-transparency; off draws water and glows solid
affine UVsalwaysalways@interpolate(linear) is a static qualifier, not a switch
set_dynamic_lighting (--dynamic-lighting)offoff, pixel-identicalsoft warm directional + screen-centred pool, gain ≤ 1.3×; sub-toggle: point lights + shadow maps at the scene's lamp props
set_occlusion_fade (F4)off in the renderer, on in play-windowoff, pixel-identicalscreen-door dissolve of fragments between the follow camera and the player

Retail dithers. The GPU's dtd bit is stamped 0 by the draw-environment initialiser but re-stamped every frame from a global that boots at 1 and a script opcode can flip. The port's clean default is a project choice about the look, not a reading of the executable.

Semi-transparency. A prim is see-through when its ABE bit is set; the blend equation comes from the texpage ABR bits (0.5B+0.5F, B+F, B−F, B+0.25F). Textured prims decide per texel via the STP bit, so the engine runs an opaque pass then a per-mode blend pass, ordered far to near on the same mean-Z key retail bins on, later-submitted-first on ties.

Depth without a depth buffer

Retail sorts whole polygons by mean Z and paints far to near. Legaia's artists leaned on that: decals lying exactly on walls, floor tiles overlapping neighbours, walls modelled once per visible side. A depth-tested renderer turns each into flicker, so the port reproduces the painter's outcome without the painter's algorithm.

CaseRetail outcomePort policy
Decal on a base surfaceDecal's mean Z is local, base's averages deeper - decal lands in a nearer bucketNon-commensurate depth lift so the retail winner stays the winner
Same bucketHead insertion: earliest-emitted prim draws last, on topEqual keys draw later-submitted-first
Double-sided pairNCLIP rasterises only the camera-facing copyPairs marked at mesh build; only the facing copy draws

A disc census finds hundreds of double-sided pairs per cave or town pack, dozens of exactly-coplanar decals inside single meshes, and hundreds of overlapping terrain-tile pairs. Shared by the native renderer and the site's WebGL viewers.

No distance culling

There is no frustum cull, draw-distance heuristic or LOD: field draw lists are resolved once at scene load and submitted whole every frame. SCENE_FAR = 1e6 for every camera (the overworld composes a 6× world scale, so eye depth reaches ~140k); the near plane is distance × 0.005 clamped to [0.05, 8]. A segment-vs-AABB occluder cull exists and is disabled by design - placement boxes span whole tiles, so the lens-to-player segment blinks out neighbours.

Five surfaces, one engine

A rendering fix that lands on one surface and not the others is invisible in a code diff. engine-render links wgpu, so the browser cannot depend on it; kernels in engine-core, 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-viewer play.rs, play_battle_render.rs
Browser field-scene viewerweb-viewer field_scene.rs; scene_geom.rs for the world map
Browser dance hallweb-viewer minigames_dance.rs
Browser fishing venueweb-viewer minigames_fishing_scene.rs

Placement rotation is three angles on every surface (Rx · Ry · Rz); a yaw-only builder loses the scene on maps like juui1, which tilts all nine placements a quarter turn about X.

How we know

Function / dataAddressWhat it provesDump
TMD rendererFUN_8002735C60 GTE ops per group; the only colour op is DPCS (cop2 0x780010). Three jal sites, all behind a test on the drawn actor's +0x42 that no sampled mode's drawn actors raise - though the disc ships writers of it (the actor allocator, behind a dev counter; move-VM op 0x10, from script data)funcs/8002735c.txt
Light-source siblingFUN_80029888Same op set; no NC* eitherfuncs/80029888.txt
Descriptor tableDAT_8007326CSix 8-byte rows; byte3 shape, byte4 vertex offset, byte1 colour baseSCUS data
Per-prim dispatchFUN_80043390Stages far / back colour per objectfuncs/80043390.txt
Light matricesFUN_8005B648 / B678Populated; the only NC* consumers FUN_8004409C / 423C / 4434 / 45B0 execute on no observed scenefuncs/8004409c.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 prim'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 0x4000-entry drop ramp and the 0x2000-entry screen-Y curvature table the overworld adds to SYfuncs/800271a8.txt
Field drop shadowFUN_8001C394A 3x3 grid 0x20 apart around the actor's feet, four POLY_FT4 cells of the 16x16 blob on page 0x001F, CLUT 0x7F86, ABR 0; drawn for every actor whose flags carry 0x01020000 and not 0x200000funcs/8001c394.txt
TMD pointer tableFUN_80026B4C → 0x8007C018Registered meshes by index; party in slots 0..4; 28-byte OBJECT copies at actor+0x44funcs/80026b4c.txt
Colour spacetests::color_spaceAttachment never sRGB; a known BGR555 texel presents at retail's byteengine-render tests

Full write-up - the per-prim dispatch table, the 2D packet emitters, the billboard projector, the transition emitter and the GTE emulator: docs/subsystems/renderer.md.

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) and writes the group-count cap; 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

Renderer::set_color_grade(gold, strength) cross-fades each shaded pixel toward rgb · gold; UI is never graded. The prologue's sepia uses the palette-collapse mode instead: retail rewrites every uploaded CLUT entry to L = max(r,g,b) → (L, max(L−1,0), 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 shaders apply the same laws per texel and packet colour (cutscene). The gold coefficients (1.0, 0.94, 0.43) are the multiply grade's and play no part in that mode. The scripted screen fade (4C 12) multiplies its tint into the grade gold 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 gate only.

Texture window, CLUT depth threshold, 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).
  • Depth-mismatch threshold. A 4bpp prim's CLUT row may hold 16 packed 16-entry palettes (256 entries, picked by the low 6 bits of CBA); an 8bpp row two 256-entry palettes. Past that, another TIM's image has spilled onto the row.
  • Flat-material trick. Prims 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 (which PROT entries cover a missing CLUT row), vram-oracle --runtime-vram (band overlap + 64×64 tile diff), info --tmd-stats; tmd prims --vram-dir, tmd vram-dump --annotate; asset-viewer tmd --no-textures is the one place a synthetic light exists.
GTE emulator and trace harness

crates/engine-render/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. Fixed-point accumulator shape (q3.12 matrices, q19.12 translations, i64 multiply-add), a Camera running RTPT with PSX saturation, nclip, avsz3/4, and a full cop2 register file (vectors, MACs, IRs, the SXY/SZ/RGB FIFOs, FLAG with hardware bit positions, cycle counts).

InstructionsPurpose
RTPS / RTPTRotate-translate-perspective
NCLIP, AVSZ3 / 4Back-face sign, OT bucket
MVMVAMatrix × vector + translation
NCDS / NCDT / NCS / NCT / NCCS / NCCT / CDP / CCNormal-colour family (unused on the field path)
DCPL, DPCS / DPCT, INTPLDepth-cued blends
SQR, OP, GPF / GPLSquares, cross product, IR × IR0
MFC2 / MTC2 / CFC2 / CTC2, LWC2 / SWC2Register transfer + memory (Cop2Mem trait)

gte_trace.rs records a before/after snapshot per op and replays a JSON trace against a fresh emulator; legaia-engine gte-replay --trace FILE drives it from a captured retail RAM trace.

History: readings this page replaced

The player texture atlas was once placed in PROT 876 (it holds no character TIM; the atlas is PROT 0874 section 2). FUN_8003DAA8 was read as a present driver (it is the CD load-kick driver). The "stage geometry" detector in crates/asset/src/stage_geom.rs matches lit textured TMD prims (12-byte texture block, ilen = 5), not stage geometry; it survives as an exploration signal. The draw-environment initialiser's dtd = 0 misled a first reading that retail does not dither. See do-not-re-walk.

See also