How it works

The PSX has dedicated 3D hardware called the GTE (Geometry Transformation Engine), a coprocessor that transforms and projects vertices, and a dedicated 2D framebuffer with its own 1 MB of VRAM (video memory). The way games typically draw a 3D model: walk the mesh primitive-by-primitive, push each one through the GTE for projection, then drop the resulting 2D primitive (as a "GP0" packet) into an ordering table (OT) that the GPU reads and rasterises. Textures and palettes (CLUTs) live as rectangular regions inside VRAM, addressed by their top-left corner.

Legaia's TMDs are almost the standard format Sony documented, but with a custom primitive-group header layout and a custom renderer. Each primitive group has an 8-byte header [u16 count, u16 flags, u8 olen, u8 ilen, u8 flag, u8 mode] followed by count × ilen*4 bytes of per-prim data. The vertex-index byte offset within each prim isn't a fixed field - it's looked up from a 6-entry descriptor table indexed by ((flags >> 1) - 8) >> 1. The descriptor table also encodes which OT-packet shape the renderer emits.

The clean-room engine port emulates PSX VRAM as a 1024×512 R16Uint texture. The fragment shader reads the texture coordinates and CLUT base from the per-primitive bind, decodes 4/8/15bpp, indexes the CLUT, and outputs the final colour. That gives the same per-prim mode-switching the PSX hardware does, but in a single GPU draw call.

Per-mode descriptor table

The renderer treats the 8-byte-stride table at 0x8007326C as a packed {u32 first; u32 second} per row, selects row = ((flags >> 1) - 8) >> 1, and reads byte3 = first >> 24 (the shape selector, & 3 = F/FT/G/GT) and byte4 = second & 0xFF (the base vertex-index offset in u16 units):

flagsrowraw 8 bytesbyte3 (shape)byte4 (vtx off)
0x10/11004 00 00 05 07 00 00 000x050x07
0x12/13109 00 00 07 06 00 00 000x070x06
0x14/15204 00 00 00 02 00 00 000x000x02
0x16/17306 00 00 02 06 00 00 000x020x06
0x18/19407 03 00 01 07 00 00 000x010x07
0x1A/1B509 03 00 03 0B 00 00 000x030x0B
0x20-27(re-uses rows 0-5 via the same (flags>>1)-8 math)

The low 2 bits of byte3 select the OT packet shape (0 = flat untextured, 1 = flat textured, 2 = gouraud untextured, 3 = gouraud textured); the quad bit (flags>>1)&1 picks tri vs quad. Byte1 of first is the per-vertex colour base: 0 = light-source-lit prim (no baked colours - the texture block sits at offset 0 and per-vertex normals trail the vertex indices), 3 = baked per-vertex colours precede the texture block. See formats/tmd for the full per-mode record layout, and Lighting below for what the colour block is for.

Lighting

Retail runs no light source on the field path. SCUS_942.54 has two TMD renderers over this table - FUN_8002735C and its light-source sibling FUN_80029888 - and between them they issue exactly one GTE colour op: DPCS (cop2 0x780010; real command 0x10, sf = 1), the depth cue. Neither ever issues NCDS / NCDT / NCS / NCT / NCCS / NCCT / CDP / CC, so no light matrix is consulted and no vertex normal is transformed. The GTE light matrix L (cr8-12) and light-colour matrix LC (cr16-20) are populated (FUN_8005B648 = SetLightMatrix, FUN_8005B678 = SetColorMatrix), but the only functions that consume them - via NCCS/NCCT - are the world-map slot-4 mesh handlers FUN_8004409C / FUN_8004423C / FUN_80044434 / FUN_800445B0.

Field shading is instead baked into the TMD. Every primitive carries a colour word [R][G][B][GP0 code], the code byte being one of 0x20 (F3), 0x24 (FT3), 0x28 (F4), 0x2C (FT4), 0x30 (G3), 0x34 (GT3), 0x38 (G4), 0x3C (GT4), each optionally | 2 for the semi-transparent variant. A flat prim stores one word; a gouraud prim one per corner (only the leading word carries the command byte). The renderer loads it into the GTE's RGBC, runs DPCS, and hands the result to the GPU as the packet colour, which the GPU blends with the texel:

out = texel * colour / 128

0x80 is therefore the neutral colour (texel unchanged), below darkens, and above brightens - up to 255/128 ≈ 2×. That factor-of-two headroom is why retail's field has more contrast than an unlit render: across the field scenes' environment packs, ~79% of colour components sit below 0x80, ~12% at it, and ~10% above. An untextured prim has no texel and is simply filled with the colour.

DPCS blends that colour toward the far colour (RFC/GFC/BFC, cr21-23) by IR0: out = c + (fc - c) * IR0. Both are staged per drawn object, and by FUN_80043390, which also stages the ambient / background colour (RBK/GBK/BBK, cr13-15 - consumed only by the NC* ops, so inert on the field path). An unfogged field scene passes IR0 = 0, making the depth cue the identity: a retail town0c capture's GTE register file shows RGB.Raw8 = 30 30 30 34 (a GT3 prim) and an RGB_FIFO of 0x30, 0x60, 0x30 - the prim's three baked corner colours, out of the depth-cue op byte-unchanged.

TMD pointer table

FUN_80026B4C writes registered TMDs to *(int **)(idx * 4 + 0x8007C018). Consumers in retail (4 functions, all setup-not-render):

FunctionRole
FUN_80021B04Actor-spawn helper, builds per-actor OBJECT pointer table.
FUN_80024D78Per-actor OBJECT-table rebuild.
FUN_8001EBECPer-frame OBJECT[10/11] swap (pose select for player TMDs).
FUN_8001E890“DATA_FIELD player loader”. The retail-PROT branch targets PROT 876 (player_data), which is a streaming-format VAB+TIM_LIST+SEQ payload - not a TMD pack. The dev string data\field\player.lzs maps to that same PROT 876 entry. The DAT_8007C018[0..4] character TMDs actually come from PROT 0874 (befect_data) section 0; see formats / world-map-overlay § Disc-side source of [0..4]. What FUN_8001E890 does end up writing into DAT_8007C018[0..2] is the post-install group-count cap (entry[+0x08] = 10) and the equipment-conditional patch dispatch into FUN_8001EBEC.

The per-actor OBJECT[i] is a 28-byte struct copied into actor[0x44][i+1] from tmd + 12 + i*28 - sizeof(OBJECT) = 28.

VRAM emulation in the engine port

crates/engine-render emulates a 1024×512 R16Uint VRAM page so the per-prim CBA/TSB selectors (CBA = the prim's CLUT base address, TSB = its texture-page selector) plus 4/8/15bpp + CLUT decoding can happen in a fragment shader. The viewer uploads every sibling TIM (a TIM is the PS1's standard texture-image format, carrying its own VRAM destination coordinates) so multi-page meshes render correctly.

CLUT data scatters across the entries of PROT.DAT (the disc's single big archive; nearly all game data lives in its 1233 numbered entries) - many character meshes reference CLUT rows that live in different PROT entries from their TMD source. This is the problem the rest of this section is about.

Engine-side scene loads resolve it from the disc: SceneResources::build_targeted walks the scene's own entries plus the shared and boot-resident blocks, with no hand-supplied directory. The asset-viewer's --vram-extra-dir is a viewer flag for browsing extracted tim_scan/ dirs that are not tied to a CDNAME scene; it is not on the engine's scene-load path. See Asset loader → "CLUT-data scattering".

Targeted VRAM upload

The TIM corpus on a single PROT entry can run into the hundreds. Uploading every TIM into the 1 MB VRAM clobbers regions a different mesh references as its CLUT row, and the paletted decode reads image pixels as palette entries (rainbow noise). The asset viewer and the tmd CLI both go through legaia_tmd::vram_targeted::build_vram_targeted: for every TIM, the image block and CLUT block are decided independently against the prim-target rectangles for the current TMD - a TIM can contribute one block, both, or neither.

legaia_tim::vram::Vram::prim_texture_status then classifies each prim's (cba, tsb, uv) lookup as Ok / MissingClut / ClutDepthMismatch { populated_width, expected_width } / MissingTexturePage so the viewer can drop bad prims at mesh-build time and the CLI can explain why a prim was dropped (the most common case is a 4bpp prim referencing a CLUT row that's been populated as a 256-entry 8bpp palette by a different TIM).

The same filter is wired into engine-side scene loads through ResolvedTmd::build_filtered_vram_mesh, so battle / field actor meshes inherit the same cleanup the asset viewer has.

Engine-side targeted upload + shared blocks

SceneResources::build_targeted is the engine-side mirror of the asset-viewer's targeted-upload path: it parses every TMD in a scene, collects the union of all prim-target rectangles (CLUT rows + texture-page UV bboxes), then walks every TIM and decides per-block whether to write it. This matches what the retail field loader does - DMA only the texture bytes the current scene's meshes need - and avoids the CLUT-row collisions that drop 80%+ of textured prims under the naive "upload every TIM" path.

build_targeted also accepts a list of shared CDNAME blocks via the FIELD_SHARED_BLOCKS constant (init_data + player_data). These are the blocks the retail engine keeps resident across field-scene transitions.

player_data (PROT 876) is a streaming file - VAB + an empty TIM_LIST + a SEQ trailer - and carries neither the character meshes nor the player textures. Both come from PROT 0874 instead: §0 is the 5-TMD character mesh pack that populates DAT_8007C018[0..4], and §2 is the field-character texture pack whose entries 1/2/3 are the Vahn/Noa/Gala atlas pages at texpage (832, 256) with per-character CLUTs on row 478. See character-mesh § Textures and world-map-overlay § Disc-side source of [0..4]. (An earlier reading placed the player atlas in PROT 876 at fb=(768, 0) with CLUT (0, 500); that is falsified - don't re-derive it.)

init_data (PROT 0) holds shared UI / sprite tiles. The shared blocks are uploaded first, so scene-local TIMs win any slot collision (mirrors the retail boot-then-scene order).

SceneHost::enter_field_scene calls build_targeted with the field shared blocks by default; the legacy SceneResources::build / build_with_shared paths remain for tests and engines that want the unfiltered upload for diagnostic purposes.

Render vs parity: targeted vs DMA-every-TIM. The targeted upload is a render optimisation - it writes only the texture bytes the current meshes sample. The retail field loader DMAs every scene TIM to VRAM regardless of which prim samples it. For the VRAM parity oracle, BuildOptions { upload_all_tims: true } switches build_targeted to build_vram_full_from_buffers: every parseable collected TIM is written to its header destination (images first as sequential DMA, then CLUTs with merge-zeros to preserve the row-479 palette split). On town01 this lifts oracle coverage from ~4% (targeted) to ~38% of the runtime texture region, with wrong (engine-only) texels dropping from ~11.5k to ~250. The flag defaults false, so the render path is unchanged.

The TIM scan walks both raw entry bytes and any LZS-decompressed sections (via legaia_asset::tim_scan::scan_entry), so battle / level-up bundles that pack their character TIMs inside an LZS container don't need a raw-byte fallback path.

legaia-engine info --scene <name> --tmd-stats reports per-TMD kept / miss_clut / depth_mm / miss_page counts so future regressions in the targeted-upload pipeline are visible without firing up the windowed viewer. --vram-png / --vram-bin write the engine VRAM as a 1024x512 PNG / raw BGR555 blob; --runtime-vram <bin> (paired with mednafen-state vram-dump --out-bin) reports per-region pixel-coverage statistics against the runtime ground truth, and --vram-diff-png writes a colour-coded diff (red = runtime has, engine missing; green = engine extras; blue = both populated but different).

Field colour-mesh pipeline (untextured prims)

Untextured TMD prims (flat / gouraud per-vertex-colour primitives, no UVs) don't go through the VRAM-textured mesh builder. The per-prim colour block is decoded (F4/G3/G4 layouts + the 00 01 03 02 quad winding remap; no per-prim normal), legaia_tmd::mesh::tmd_to_color_mesh builds a ColorMesh from those prims, and the renderer's vertex-colour pipeline (scene_color_mesh_pipeline, Renderer::upload_color_mesh, Scene::color_draws - flat face-shaded, no VRAM lookup) draws them. A mixed mesh renders both halves: the colour mesh is disjoint from the VRAM mesh (tmd_to_color_mesh skips textured groups), so textured prims go to the VRAM pipeline and untextured prims to the colour pipeline at the same placement. In town01 this covers the untextured props. One further case goes through the textured pipeline with a flat-material trick: a mesh sampling texpage (960, 256) + CLUT (64, 510), whose source is the boot-resident system-UI TIM bundle (the prot::timpack at raw PROT TOC entry 0, below the extraction index space; the atlas at PROT.DAT[0x11218] supplies the page and, via FUN_800198E0's flat-strip CLUT semantics, the 256-entry strip on row 510). The pre-pass uploads the whole pack (legaia_asset::system_ui_bundle, underlaid beneath scene uploads - boot-then-scene order; the six bare row-patch members keep vram_oracle_e1 byte-exact). The same CBA/texpage pattern recurs in other scenes' env packs (rikuroa env slots 50/51/63, town01 slots 21/26/74); the prims sample a constant mid-grey texel patch. A placement test pins the behaviour. See row-510/511 strip band.

Two-pass upload ordering

Inside build_vram_targeted_from_buffers the targeted upload runs in two passes:

  1. Image pass writes every useful TIM image block (image overlaps a mesh's tex page region AND does NOT overlap another mesh's CLUT row).
  2. CLUT pass writes every useful TIM CLUT block (CLUT overlaps a mesh's CLUT row), unconditionally with respect to image-page collisions.

The ordering matters: a per-prim clut_collides_page suppression heuristic drops legitimate palette rows whenever any mesh's UV bbox happens to brush the CLUT row's y-coordinate - the town01 character TMDs' 256-pixel-wide palette at y=479 overlaps a separate scene mesh's texture-page rectangle, dropping 388 prims as MissingClut. The image-then-CLUT pass order keeps the palette rows that PSX games place on the bottom of texture pages coherent without any per-prim heuristic.

CLUT-trace + VRAM-oracle diagnostics

Two legaia-engine subcommands surface where the engine's loader still has gaps against a captured runtime VRAM:

  • legaia-engine clut-trace --scene <name> --disc <bin> [--runtime-vram <bin>] walks every dropping MissingClut prim, groups by (cba, depth), and reports which PROT entries carry a TIM whose CLUT block covers each missing row by rectangle containment (PSX TIMs commonly pack 16 distinct 16-entry palettes into one 256-wide CLUT block, so a CBA's 16-pixel slot sits inside a wider supplier rect). --runtime-vram distinguishes "row absent from engine but present at runtime" (engine loader gap) from "row absent from runtime too" (mesh references unreachable CLUT - likely a sub-pack walker port needed).
  • legaia-engine vram-oracle --scene <name> --disc <bin> --runtime-vram <bin> [--diff-png <path>] [--tiles] rebuilds the scene's engine VRAM and reports per-band overlap counts plus an optional 64x64-tile breakdown. --diff-png writes the same colour-coded diff as info --vram-diff-png. The standalone VRAM build picks its load kind via oracle_load_kind, mirroring the live enter_field_scene choice: world-map scenes (map\d\d) build with SceneLoadKind::WorldMap so the kingdom slot-0 terrain atlas (opaque to the generic TIM scanner) lands in VRAM, instead of reporting the grass/water pages as a phantom gap (roughly doubles map01 texpage residency).

Both work without any pre-extracted tim_scan/ tree - they operate straight off PROT.DAT + CDNAME.TXT (extracted-root or in-place disc image).

CLUT-depth-mismatch threshold

Vram::prim_texture_status flags ClutDepthMismatch when a CLUT row is populated past what the prim's color depth could legitimately fill: for 4bpp prims the threshold is 16 * 16 = 256 entries (16 distinct 16-entry palettes packed in one row, picked by the prim's CBA low 6 bits - the standard Legaia character-TIM layout); for 8bpp it's 2 * 256 (one palette plus slack for stray pixels). Anything past that indicates another TIM's image bytes have spilled onto the CLUT row, and the paletted decode would index into pixel data. The targeted-upload path in build_targeted prevents this spillage, so engine-side scenes hit the mismatch threshold only when a regression breaks the per-TIM block-arbitration.

Texture-window register (GP0 0xE2)

Renderer::set_texture_window(mask_x, mask_y, off_x, off_y) maps to GP0(0xE2) "Texture Window setting": four 5-bit values in 8-pixel steps that clamp / wrap texture-coordinate sampling to a smaller window inside the texture page. Default is all-zero (no-op). Retail Legaia leaves the register at zero almost everywhere; the API is wired primarily so future runtime LoadImage / DMA-to-VRAM trace work can replay the register state faithfully. The fragment shader applies the per-pixel coord = (coord & ~(mask*8)) | ((offset & mask)*8) transformation before texture-page lookup.

Full-scene colour grade

Renderer::set_color_grade(gold, strength) stages a per-frame (gold_rgb, strength) into every field MeshUniforms; the textured / VRAM / colour mesh shaders' apply_grade cross-fades each shaded pixel toward the per-channel multiply rgb · gold by strength (0 = no-op; text/UI overlays use separate shaders and are never graded).

For the opening prologue's gold sepia (the opdeene / opstati / opurud cutscene legs) the multiply is superseded by the palette-collapse mode (Renderer::set_palette_grade): retail applies the grade to the loaded assets - every uploaded CLUT entry rewritten to L = max(r, g, b) → (L, max(L−1, 0), L >> 1) and the loaded TMD colour words collapsed to gold · max(rgb) - and the engine's shaders apply the identical law per decoded texel / packet colour, with runtime-neutral 0x80 words kept neutral and the view-depth cue ramp inert (see cutscene for the capture that pins it). The gold coefficients (1.0, 0.94, 0.43) are the measured amber-family ratio, stored as display ratios as-is - see Colour space. Driven by World::scene_color_grade - only the prologue cutscene legs grade; every other scene renders with both grade paths off, bit-identical to the ungraded pipeline.

The scripted screen fade (field-VM op 0x4C 0x12 global tint - the scene-entry fade-from-black) reuses this same staging rather than adding a shader term: the host multiplies the fade tint into the staged grade gold (at full strength) and into the depth-cue far colour, so both branches of the shaders' cue mix carry it and the product distributes to the final pixel. A neutral tint stages the identity values - byte-identical to the fade-free path. See cutscene.

Retail's per-render-node depth cue additionally crushes far-field blue (B/R down to ~0.13). Renderer::set_depth_cue_ramp(far, near_z, far_z, max_ir0) stages that pull as a view-depth IR0 ramp: each fragment's projected view depth maps to ir0 = clamp((z − near_z) / (far_z − near_z), 0, 1) · max_ir0, and the shaders blend toward the far term in retail's order - DPCS runs on the packet colour before the GPU texel multiply, so a textured prim's far term is texel · far / 128 and an untextured prim pulls to the far colour directly. Driven by World::scene_depth_cue on the same prologue gate (fade::DepthCueRamp carries the calibration); cleared on every other scene, where the ramp-off path is pixel-identical to the pre-ramp render. See cutscene for the calibration measurements and the per-node residual.

Colour space: PSX framebuffer values end to end

Every colour the engine handles - texels, CLUT entries, vertex colours, menu inks, grade coefficients - is a PSX framebuffer value: display-referred, exactly what the console clocks out to the display. Nothing on the path converts colour spaces, and nothing may.

  • The swapchain is presented through a UNORM view (choose_surface_format), never sRGB. An sRGB attachment treats the shader's output as linear and applies the linear→sRGB transfer on store, lifting every midtone: retail's mid-grey (5-bit 16 → byte 132) presents as 190. That is a visible, global wash-out.
  • Sampled RGBA textures (TIM-decoded uploads, the font atlas) are Rgba8Unorm for the same reason - an sRGB source would be decoded to linear on sample and written verbatim into the UNORM attachment, darkening instead.
  • The last stage of every 3D shader is psx_dither, which quantises to 5 bits and expands with (c5 << 3) | (c5 >> 2). That quantisation only reaches the screen if the attachment stores the byte unmodified.
  • PSX semi-transparency (psx_blend) blends raw 5-bit values on retail, so the fixed-function blend must run in the same display-referred space.

Pinned by tests::color_space: the attachment is never sRGB for any surface the adapter might offer, and a known BGR555 texel presents at the byte retail puts on the wire - checked against an sRGB target too, which lifts it out of tolerance.

Asset-viewer flat-shaded fallback

asset-viewer tmd <PATH> --no-textures (alias --flat-shaded) suppresses the VRAM path entirely and renders unlit flat geometry. Useful for inspecting mesh silhouettes without battling palette guesses (the runtime LoadImage trace for field / town scenes is not yet captured, so some palette rows always render as garbage in textured mode).

tmd CLI VRAM diagnostics

tmd prims <PATH> --vram-dir extracted/tim_scan/<entry> simulates the targeted upload and adds a per-prim verdict trailer (-> Ok / -> MISSING CLUT (row N) / -> DEPTH MISMATCH (row N populated with K entries; prim expects M) / -> MISSING TEXTURE PAGE (tpage 0xNN)).

tmd vram-dump <PATH> -o vram.png [--vram-dir ...] [--annotate] exports the post-upload software VRAM as a 1024x512 PNG with optional red CLUT-row + green texture-page outlines, so collisions are obvious without firing up the GUI.

Render kernels by surface

The port draws through five surfaces, not two, and every one of them assembles its own draw list before a shader ever runs. Two are easy to forget because they live in the minigames page rather than in something called a renderer — and both resolve EnvDraws and instance environment-pack meshes exactly like the field hosts do.

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-hall venueweb-viewer minigames_dance.rs
browser fishing venueweb-viewer minigames_fishing_scene.rs

A kernel wired into one surface and not another is invisible in a diff, because no file holds two of the columns. engine-render links wgpu, so the browser crate cannot depend on it: every kernel that lives there is native-only as code, and a browser twin has to be a second implementation in GLSL or JS. Kernels living in engine-core, engine-vm or legaia-tmd are one implementation all five surfaces can call — and those are the ones a checker can pair by name.

Not every asymmetry is a defect. set_psx_mode and dynamic lighting are opt-in and default off, so a browser with no toggle renders the same default the native window does; what it lacks is the switch, not the faithful output.

Placement rotation is three angles on every surface

A .MAP object record carries three authored angles (+0x08 pitch, +0x0A yaw, +0x0C roll) and retail composes all three as Rx * Ry * Rz. The yaw-only builders take a negated yaw because their inline Ry is transposed and the two negations cancel — a cancellation specific to Ry, so a tilted placement cannot go through them at all and has to be handed a whole model matrix.

Measured across 49 field scenes: 94 of 1667 placements carry a nonzero pitch or roll, and the distribution is lumpy rather than uniform — juui1 tilts all nine of its by a quarter turn about X. Terrain cells tilt too, and more often than the placed layer. A surface that reads only the yaw does not lose a curiosity; on those scenes it loses the scene.

Rendering knobs: what is faithful, what is a choice

Simulation is faithful, with no opt-out. Shading defaults to retail; rasterisation defaults to clean.

That is the whole story, and it is worth being precise, because “faithful” and “default” are not the same axis. Three render toggles exist, each with a different default:

KnobDefaultOff/on is retail?Gates
Renderer::set_psx_modeoffOn is retailvertex snap + 15-bit dither, and nothing else
Renderer::set_semi_blendonOn is retailABE semi-transparency. Independent of psx_mode
Renderer::set_dynamic_lightingoffOff is retail, pixel-identical to the faithful renderthe opt-in soft-light enhancement

Two things routinely get mis-stated about this table. Lighting is not a psx_mode knob, and the default is already faithful - the game's field/town meshes go through the VRAM-mesh and vertex-colour pipelines, which draw the TMD's baked colour words with no light source at all, exactly as retail does (see Lighting). And affine UVs are not gated either - they are always on: @interpolate(linear) is a static qualifier on the vertex-output struct, not a uniform-driven branch, so every path interpolates UVs affinely on every frame.

set_psx_mode - vertex snap + dither

Renderer::set_psx_mode(true) enables the two strict-PS1 rasterisation artefacts that are not on by default. Default is off; in legaia-engine play-window, opt in with LEGAIA_PSX_RENDER=1.

  • Sub-pixel vertex snap ("vertex jitter"). Clip-space x / y are snapped to integer pixel positions inside the vertex shader (NDC → pixel grid → NDC round-trip). Reproduces the GTE's per-vertex sub-pixel-truncation jitter that gives PSX rendering its characteristic shimmer on slow-moving geometry.
  • 15-bit ordered dithering. Packing the 24-bit shaded colour into the 15-bit (BGR555) framebuffer, the PSX GPU adds a signed 4×4 ordered-dither offset per pixel before truncating each channel to 5 bits. The shader helper PSX_DITHER_WGSL (prepended to every shaded 3D shader) reproduces it and mirrors the unit-tested CPU psx_dither module; the composed shaders are naga-validated in the test suite (a GPU-free guard that the WGSL stays well-formed).

Retail's dither law, stated separately from the port's default

The two are different claims and get confused, so they are written apart here.

Retail law: dither is on at boot and script-controlled. The GPU's dtd bit lives in the DRAWENV byte at +0x2A of each of the two draw environments that the frame-begin driver swaps. Four sites, all read off the disassembly:

SiteInstructionEffect
0x8002004Csb zero, 0x2a(a0)DRAWENV pair initialiser (FUN_80020038) stamps dtd = 0
0x80017208 / 0x80017210lbu v1, -0x459a(v1) / sb v1, 0x2a(v0)frame-begin driver FUN_80016B6C re-stamps dtd from _DAT_8007BA66 every frame, indexing the pair by gp+0x434 at stride 0x74
0x8001D520sh s2, -0x459a(at), s2 = 1boot (FUN_8001D424) writes 1 to _DAT_8007BA66
0x801E350Clbu v1, 0x1(s6) / sh v1, -0x459a(v0)field-VM opcode takes a one-byte script operand into _DAT_8007BA66, then advances the VM PC by 3

So the initialiser's dtd = 0 never survives a frame: the per-frame refresh overwrites it from the global, the global boots at 1, and a scene script can flip it at will. FUN_80026CE4 reads the same global as an lh and passes it to the mode-0x15 STR packet submit, so the FMV blit path honours the same bit.

Port default: off, by choice. Renderer::set_psx_mode gates the engine's dither, and it is opt-in. That is a project decision about the default look, not a reading of the executable - the retail bit above is what the toggle reproduces when you turn it on.

Affine UV interpolation (always on)

Per-vertex UVs interpolate linearly in screen space, with no perspective-correct division, on every path in every mode. This reproduces the texture warping you see on retail surfaces with steep depth gradients: GP0(0x24)-class triangle commands transmit only (u, v) per vertex, and the rasteriser does not divide by 1/w. WGSL @interpolate(linear) gives the same behaviour. The baked per-prim colour carries the same qualifier, for the same reason - PSX gouraud interpolation is affine in screen space too.

Texture page (tsb) and CLUT base address (cba) stay @interpolate(flat) - they are per-primitive in retail because GP0(0x24) sets them once per draw call, not per vertex.

Lighting: baked by default, dynamic by opt-in

PSX texture blending is the lighting. Each vertex carries the TMD prim's baked colour word, and the fragment shader modulates the texel by it exactly as the GPU does - texel * colour / 128 - then applies the DPCS depth cue. This is retail's field lighting (see Lighting): there is no light source, and no synthetic Lambert. Helpers psx_modulate / psx_depth_cue live in the shared shader prelude and are mirrored, and pinned, by the CPU psx_light module.

Renderer::set_dynamic_lighting is the one lighting knob, and it is off by default - off is retail: the disabled path is pixel-identical to the faithful baked-shading render, so the parity oracles are unaffected. Enabled, the VRAM / colour mesh shaders layer a soft warm directional light (off the smoothed per-vertex normals) plus a screen-centred light pool over the baked colours, with the gain capped at ~1.3×. That is explicitly a non-retail enhancement.

The viewer-only exception, so nobody re-derives the wrong conclusion. There is a fixed directional light with a max(dot(n, l), 0.0) diffuse term in the shader set - but it lives only in MESH_SHADER_SRC, the bare-geometry preview pipeline behind the asset-viewer's raw-TMD view. Those meshes carry neither texture nor colour, so there is nothing of the game's own shading to show; the light is a viewer aid, not a claim about retail, and no game path uses it.

set_semi_blend - semi-transparency (ABE / STP / ABR)

Independent of psx_mode, and on by default. Retail's GPU always blends ABE prims, so field water (e.g. the Hunter's Spring fountain), glass and additive effects composite correctly in the clean “enhanced” render too; only the strict-PS1 artefacts (vertex jitter / affine UVs / 15-bit dither) ride psx_mode. Turning it off draws every ABE prim fully opaque - the harsh-edged “solid water” look.

A prim is semi-transparent when its packet ABE bit is set (TMD group mode byte bit 1; packed into bit 15 of the per-vertex TSB attribute so it reaches the shader with no new vertex format). The blend equation comes from the texpage ABR bits (TSB bits 5..=6): mode 0 0.5B + 0.5F, 1 B + F, 2 B - F, 3 B + 0.25F.

For textured prims the choice is per texel via the BGR555 STP bit, so the engine runs an opaque pass (discarding STP texels of semi prims) plus a per-ABR-mode blend pass over re-drawn semi triangles; untextured ABE prims blend all pixels via a per-vertex blend word (psx_blend::pack_blend_word). Blend ordering is per primitive, far-to-near on the clip-space w depth key (psx_blend::prim_depth_key - the GTE avg-Z the retail OT bins on), with equal keys drawing later-submitted-first (the retail LIFO OT-bucket order). Full detail in docs/subsystems/renderer.md.

GTE math module + cop2 register-state emulator

A fixed-point GTE math module at crates/engine-render/src/gte.rs mirrors the retail accumulator shape (q3.12 rotation matrices, q19.12 translation vectors, i64-widened multiply-add) and exposes the higher-level primitives - a Camera bundle running RTPT end-to-end with PSX-correct saturation, nclip back-face rejection, avsz3 / avsz4 OT-bucket selection, and a small CPU rasterizer scaffold for validating captured traces. Production rendering still uses f32 wgpu math.

The same module ships a register-state emulator Gte mirroring the PSX cop2 register file: V0..V2 input vectors, MAC0..MAC3 wide accumulators, IR0..IR3 saturated shorts, the SXY / SZ / RGB FIFOs (3 / 4 / 3-deep), OTZ, and the FLAG sticky-saturation register with hardware-matching bit positions (gte::flag_bits). Control registers cover the rotation / light / light-color matrices, translation, H, OFX/OFY, ZSF3/ZSF4, DQA/DQB, and the back_color / far_color triplets. Each op charges its hardware cycle count (Nocash PSX reference table) into Gte::cycles; CopOp::cycles() exposes the table directly.

InstructionsPurpose
RTPS / RTPTRotate-translate-perspective (single / triple vertex).
NCLIPSigned area of the SXY-FIFO triangle (back-face cull).
AVSZ3 / AVSZ4OT-bucket selection from the SZ FIFO.
MVMVAGeneric matrix × vector + translation, with the SF / LM flags.
NCDS / NCDTNormal-color depth shading (single / triple).
NCS / NCTNormal-color, no depth-fade.
NCCS / NCCTNormal-color color, double light pass.
CDP / CCColor depth-cued (no normal pass) / color color (no normal, no depth).
DCPL, DPCS / DPCT, INTPLDepth-cued colour blends + far-color interpolation.
SQR, OP, GPF / GPLIR squares, rotation-diagonal cross product, general-purpose IR×IR0 multiply / accumulate.
MFC2 / MTC2 / CFC2 / CTC2, LWC2 / SWC2Register-transfer + memory ops (read_data/write_data, read_ctrl/write_ctrl; memory behind the Cop2Mem trait - VecMem for captured RAM snapshots, NullMem for tests).

Trace capture & replay harness

A companion module at crates/engine-render/src/gte_trace.rs turns the cop2 emulator into a regression-test harness. GteSnapshot::capture serialises the entire register file (data + control + cycle counter) to a plain struct that round-trips through restore; diff returns a typed list of per-field divergences. TraceRecorder wraps a live Gte - engines configure it with rotation matrices and vertex inputs, then call record(op) per cop2 operation; the recorder pushes one TraceStep per op containing the before / after snapshots.

Recorded traces serialise to JSON via Cop2Trace::write_json_pretty and round-trip through read_json. Cop2Trace::replay runs each step against a fresh emulator and surfaces any per-field divergence as a StepMismatch with the op name + diff list. The legaia-engine gte-replay --trace FILE subcommand drives this from the CLI: pass a captured retail RAM trace and the harness reports any cop2 emulator regression.

The per-mode descriptor table from DAT_8007326C is also exposed as a typed lookup at crates/tmd/src/descriptor.rs: Descriptor::for_flags(flags) returns the resolved PacketShape (one of F3 / FT3 / G3 / GT3 / F4 / FT4 / G4 / GT4) and the per-prim vertex-index offset. The lookup matches the older legaia_prims::vertex_offset_bytes free function on every valid flags value - both read the same on-disc table - but exposes the shading mode (flat vs gouraud) and texture flag as typed fields so consumers can branch on them without re-deriving the bit math.

No distance culling: every loaded body is drawn

The engine draws every mesh a scene loads, every frame. There is no frustum cull, no draw-distance heuristic, no per-object radius test, and no LOD: the field draw lists (field_placement_draws, field_terrain_draws, the ground heightfield, the posed props, the NPCs) are resolved once at scene load and submitted whole on every frame. A town is a few hundred draws of a few thousand triangles - the budget the port is not on is the PSX's.

The one thing that can still remove geometry is the projection's own clip volume, so the clip planes are sized to hold an entire scene from any vantage rather than to frame the current view:

  • window::SCENE_FAR = 1e6 for every camera. A field map is 256 × 256 tiles of 128 units (~23k units on the diagonal), and the overworld walk camera composes a 6× world scale onto psx_camera_mvp, so eye-space depth there runs to ~140k. Raising the far plane costs no depth precision - projected depth is 1 - near/z to within O(near/far), i.e. the near plane sets the resolution.
  • window::scene_clip_planes(distance) gives the orbit-family cameras (orbit_camera_mvp, world_map_camera_mvp, walk_view_camera_mvp, cutscene_camera_mvp) a near plane of distance * 0.005 clamped into [0.05, 8] - a few units in front of the lens on any scene-sized framing, small enough that a wall or floor body the camera sweeps over is never clipped, while the asset-viewer's unit-radius TMD previews keep a sub-unit plane.

Both are pinned by camera_tests in window.rs: a full-size field map's corners must project inside the depth range even though the camera frames only a small player-sized box, and the near plane must stay within a few units of the lens at every framing distance the engine uses.

The site play page (site/js/play-app.js) draws the whole scene every frame, unconditionally, matching this renderer - OCCLUDER_CULL = false. A per-frame occlusion cull exists in the code (drop a body the eye-to-player segment pierces, since the page has a single follow camera where retail authors one per scene) but is deliberately disabled: even the exact segment-vs-world-AABB form culls legitimate bodies, because the placement boxes are axis-aligned over whole terrain tiles, walls, and buildings - as the camera orbits or the player walks, the lens-to-player segment sweeps through a neighbour's box and blinks it out. The branch is never taken.

Stage geometry detector (legacy, signal only)

A "12-byte fixed prefix 00 F0 84 7F 01 F0 1F 00 00 F1 00 00 repeated at 20-byte stride" detector lives at crates/asset/src/stage_geom.rs. It's not real stage geometry - it's Legaia TMD primitive data from a lit textured triangle group (descriptor rows 0/1, whose byte1 = 0 puts the texture block at prim offset 0): the "fixed prefix" is the prim's 12-byte leading texture block ([u v cba][u v tsb][u v pad], with a constant CLUT/texpage across the table) and the 20-byte stride is the group's ilen = 5 prim size.

The detector is preserved as a signal during exploration ("this buffer contains a TMD with effect-style primitives") but for actual geometry extraction use the TMD parser (crates/tmd::legaia_prims).

See also