Renderer
How Legaia puts 3D on screen. Every town, cave and arena is a set of meshes in a custom variant of Sony's TMD format, pushed through the PlayStation's geometry coprocessor and painted by the GPU into one megabyte of video memory. The thing to know first: there is no light source. Every polygon carries a painted brightness, and the console multiplies the texture by it. Ported
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 inlegaia-tmdandengine-ui.- Confidence
- Confirmed - disassembly of the GTE op stream, save-state register files, GP0 draw-list pins (how we know).
One primitive's journey
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.
flags | Row | Shape | Vertex offset | Colour base |
|---|---|---|---|---|
0x10/11 | 0 | FT (flat textured) | 0x07 | 0 - lit, normals trail the indices |
0x12/13 | 1 | GT (gouraud textured) | 0x06 | 0 |
0x14/15 | 2 | F (flat) | 0x02 | 3 - baked colours first |
0x16/17 | 3 | G (gouraud) | 0x06 | 3 |
0x18/19 | 4 | FT | 0x07 | 3 |
0x1A/1B | 5 | GT | 0x0B | 3 |
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). NoNC*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 fourNC*prim handlers, and noMVMVAanywhere 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.0x80is neutral, lower darkens, higher brightens up to 2×. Across the field environment packs roughly four fifths of components sit below0x80- that headroom is why retail has more contrast than an unlit render. - Depth cue.
DPCSblends the colour toward the far colour byIR0; an unfogged field scene passesIR0 = 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
| Quantity | Retail value | Read from |
|---|---|---|
OFX / OFY | 160 / 114 | GTE control file in a save state |
| Drawing area | 320 × 224 | GPU clip registers |
| Draw offset | (0, 4) / (0, 244) | alternating double-buffer halves |
| Display window | 320 × 228 scanlines | DisplayVStart/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.
| Step | What happens | Where |
|---|---|---|
| Collect targets | Parse every TMD in the scene; union the CLUT rows and texture-page rectangles its prims 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 block that overlaps a sampled page and does not overlap a sampled CLUT row | build_vram_targeted |
| CLUT pass | Write each TIM CLUT block that overlaps a sampled row, unconditionally | same |
| Verdict per prim | Ok / MissingClut / ClutDepthMismatch / MissingTexturePage; bad prims are dropped at mesh build | Vram::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.
| Knob | Default | Retail is | Gates |
|---|---|---|---|
set_psx_mode (LEGAIA_PSX_RENDER=1) | off | on | sub-pixel vertex snap + 4×4 ordered 15-bit dither, nothing else |
set_semi_blend | on | on | ABE semi-transparency; off draws water and glows solid |
| affine UVs | always | always | @interpolate(linear) is a static qualifier, not a switch |
set_dynamic_lighting (--dynamic-lighting) | off | off, pixel-identical | soft 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-window | off, pixel-identical | screen-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.
| Case | Retail outcome | Port policy |
|---|---|---|
| Decal on a base surface | Decal's mean Z is local, base's averages deeper - decal lands in a nearer bucket | Non-commensurate depth lift so the retail winner stays the winner |
| Same bucket | Head insertion: earliest-emitted prim draws last, on top | Equal keys draw later-submitted-first |
| Double-sided pair | NCLIP rasterises only the camera-facing copy | Pairs 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.
| Surface | Draw-list assembly |
|---|---|
Native play-window | engine-shell window/field_render.rs, window/geometry.rs |
| Browser play page | web-viewer 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-viewer minigames_dance.rs |
| Browser fishing venue | web-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 / data | Address | What it proves | Dump |
|---|---|---|---|
| TMD renderer | FUN_8002735C | 60 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 sibling | FUN_80029888 | Same op set; no NC* either | funcs/80029888.txt |
| Descriptor table | DAT_8007326C | Six 8-byte rows; byte3 shape, byte4 vertex offset, byte1 colour base | SCUS data |
| Per-prim dispatch | FUN_80043390 | Stages far / back colour per object | funcs/80043390.txt |
| Light matrices | FUN_8005B648 / B678 | Populated; the only NC* consumers FUN_8004409C / 423C / 4434 / 45B0 execute on no observed scene | funcs/8004409c.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 prim'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 0x4000-entry drop ramp and the 0x2000-entry screen-Y curvature table the overworld adds to SY | funcs/800271a8.txt |
| Field drop shadow | FUN_8001C394 | A 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 0x200000 | funcs/8001c394.txt |
| TMD pointer table | FUN_80026B4C → 0x8007C018 | Registered meshes by index; party in slots 0..4; 28-byte OBJECT copies at actor+0x44 | funcs/80026b4c.txt |
| Colour space | tests::color_space | Attachment never sRGB; a known BGR555 texel presents at retail's byte | engine-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
| 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) 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
0xE2texture 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-texturesis 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).
| Instructions | Purpose |
|---|---|
RTPS / RTPT | Rotate-translate-perspective |
NCLIP, AVSZ3 / 4 | Back-face sign, OT bucket |
MVMVA | Matrix × vector + translation |
NCDS / NCDT / NCS / NCT / NCCS / NCCT / CDP / CC | Normal-colour family (unused on the field path) |
DCPL, DPCS / DPCT, INTPL | Depth-cued blends |
SQR, OP, GPF / GPL | Squares, cross product, IR × IR0 |
MFC2 / MTC2 / CFC2 / CTC2, LWC2 / SWC2 | Register 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.