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 (the GTE) and painted by the GPU into one megabyte of video memory. The thing to know first: almost nothing is lit at run time. Every polygon carries a brightness the artists painted, and the console multiplies the texture by it. This page is the tour; the function-by-function evidence is on renderer internals, and how one pixel gets its colour is on shading and palettes.
At a glance
- Mesh format
- Legaia TMD: magic
0x80000002, primitives in groups behind an 8-byte header, laid out by a six-row descriptor table atDAT_8007326C. - Leaf that draws
- The per-primitive dispatcher
FUN_80043390- a 20-slot jump table picked byflags >> 1. The table-driven rendererFUN_8002735Cexists, but sits behind an actor field no sampled mode raises. - Lighting
- Baked colour per polygon, through one GTE depth-cue op. The exception is the light-source rows (group flags
0x10..=0x17), which the GTE shades from a scene light. - Screen
- 320×224 drawn, 228 lines displayed; projection centre
OFX 160, OFY 114- dead centre of the display window. - Depth
- No depth buffer. Primitives go into ordering-table buckets by depth and are painted far to near.
- Port defaults
- Shading defaults to retail (baked colour plus the light-source rows); rasterisation defaults to clean (the GTE register limits, vertex snap and dither are opt-in). Affine UVs are always on.
- In the port
legaia-tmd(prim walk, mesh build),engine-render(wgpu, native),site/js/webgl-shaders.js(browser), shared kernels inengine-core/engine-ui.
How a frame is drawn
A town is a few hundred small meshes - houses, fences, terrain tiles, the party. For each primitive the dispatcher asks the GTE to rotate, translate and project the corners (RTPT), drop it if it faces away (NCLIP), average its depth (AVSZ3/AVSZ4) and run the colour through the depth cue (DPCS). The result is wrapped in a GPU packet and linked into the ordering-table bucket its depth picks. Textures and palettes are rectangles inside video memory, addressed by their top-left corner, and the GPU looks them up per pixel as it paints.
The field's ground plane is a separate pass in the field render library (PROT 0900): a per-tile emitter over the camera's visible tile window, which sends most floor cells to the farthest bucket so everything else draws over them.
The descriptor table
A mesh mixes flat and smooth-shaded (gouraud), textured and plain polygons. Each group's 8-byte header [count][flags][olen][ilen][flag][mode] is followed by count × ilen × 4 bytes of primitives. The table row is ((flags >> 1) − 8) >> 1; because it shifts twice, each row covers four flags values - a triangle pair and a quad pair - and the legal span is 0x10..=0x27.
flags (tri · quad) | Row | Shape | Vertex offset | Colour |
|---|---|---|---|---|
0x10/11 · 0x12/13 | 0 | FT (flat textured) | 0x07 | none - a normal index trails the vertices; light-source row |
0x14/15 · 0x16/17 | 1 | GT (gouraud textured) | 0x06 | none - a normal per corner; light-source row |
0x18/19 · 0x1A/1B | 2 | F (flat) | 0x02 | baked colour word |
0x1C/1D · 0x1E/1F | 3 | G (gouraud) | 0x06 | baked, one per corner |
0x20/21 · 0x22/23 | 4 | FT | 0x07 | leading colour block |
0x24/25 · 0x26/27 | 5 | GT | 0x0B | leading colour block, one per corner |
Bit (flags >> 1) & 1 picks quad over triangle. Typed lookup: legaia_tmd::descriptor::Descriptor::for_flags; byte layouts per mode on the TMD format page.
Lighting: painted, with one exception
Legaia's towns look lit - shadowed alleys, bright plazas, a glow around lamps - but almost none of it is computed at run time. The artists painted a colour word into every polygon, and the GPU multiplies the texture by it: out = texel × colour / 128. 0x80 is neutral; across the field environment packs about four in five components sit below it, which is where retail's contrast comes from. A renderer that adds its own "helpful" light makes the game look wrong.
- The depth cue. Before the multiply,
DPCSpulls the colour word toward a far colour byIR0. An unfogged field scene passesIR0 = 0, so the cue is the identity - a retail capture's corner colours leave the op byte-unchanged. - The light-source rows. Rows 0 and 1 carry a normal instead of a colour word. The dispatcher sends them (kinds 8..11) to its
NCCS/NCCThandlers, which shade each corner from the scene light against its world normal: back colour plus the first light's intensity, times the object colour. Under the scene-load light a face turned away keeps an eighth of its texel; a face square to it a little over the whole. Most packs hold few or none;cave01's rock walls are nothing else, and their dark and bright sides are this light. - Where the light comes from. Every scene load resets the light angles to
(0x994, 0x9CC, -0x62C)and the back colour to0x202020; field-VM op4C 8Asets other values (town01uses a white back colour, which is why its few lit rows look unlit). The kingdom overworlds carry no light-source rows at all.
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 that reason. Formulas and register sources: internals.
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 - unlike the projection distance H, which is set per phase (256 in battle, 512 in the field). OFY = 114 is 228 / 2, the centre of the display window; it only looks "six pixels high" against a 240-line frame, which retail never draws.
The port keeps a 320×240 logical screen, because every 2D rectangle it draws is a retail draw-area coordinate copied verbatim, and moves the GTE origin onto row 114 with a constant bias on clip y. The residual is the frame height: retail's picture fills 224/240 of the port's, so everything reads about 7% smaller.
On the overworld a second table bends the world: a screen-Y curvature table, indexed by depth and added to each vertex's screen Y, gives the continent its rounded horizon (world map).
Depth without a depth buffer
Retail sorts whole polygons by depth and paints far to near. Legaia's artists leaned on that: decals lying exactly on walls, floor tiles overlapping their neighbours, walls modelled once per visible side. A depth-tested renderer turns each of these 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 | The decal's mean depth is local, the base's averages deeper - the decal lands in a nearer bucket | A small depth lift so the retail winner stays the winner |
| Same bucket | Head insertion: the earliest-emitted primitive draws last, on top. The field emits its placed objects before its map cells, so a sign placed on a wall cell paints over it | Equal keys draw later-submitted-first; equal coplanar draws rank placed objects ahead of map cells |
| Double-sided pair | NCLIP rasterises only the camera-facing copy | Pairs are marked at mesh build; only the facing copy draws |
| Far-bucket floor cell | Painted under everything else in the frame | Drawn first, under every other primitive |
No distance culling
The port adds no frustum cull, draw distance or level of detail of its own; a field scene's draw list is resolved once at load. What it removes is what retail removes. Retail's field render library never draws the whole map: it walks only the map cells inside the camera's visible tile window, clipped to the walk region, and culls placed actors against the same view. Both hosts reproduce that crop while the camera holds retail's own framing; under the port's wider default camera, the zoom and tilt knobs or the debug orbit, the map is drawn whole, because the window was sized for retail's frustum and would open black edges. The per-primitive rejects (back faces, a near-plane test) apply everywhere. The browser host also leaves out a draw that cannot land a fragment, a body wholly off screen outside PSX mode: the picture stays the one every body drawn would give, and the frame-hash oracle pins that.
The GPU polygon-size limit
One more per-primitive reject is the GPU's, not the game's. The prim leaves hand the GPU the GTE's screen coordinates with no clip of their own, and the GPU skips any polygon whose corners lie more than 1023 pixels apart horizontally or 511 vertically. A corner just in front of the eye, or behind it, projects far off screen, so in battle this removes the primitives of a body whose limbs reach past the camera - a summon close-up that seats a monster between the eye and the caster - where a per-pixel clip would paint them as long shards. Both hosts run the same span test in the vertex stage beside the near reject (prim_near_reject::gpu_span_rejected), armed in battle and on the field; a split quad is dropped only when both halves exceed the limit.
A frame captured in PCSX-Redux follows that emulator's limit instead, which drops both more and less. It measures a corner pair only from a corner with a negative coordinate, and one pair past the limit drops a whole quad, where the console skips one triangle; a primitive with no negative coordinate is drawn at any size. The rikuroa tunnel capture shows both: the emulator leaves out a near wall quad (one corner above the screen's top) and draws the floor quad beside it, whose halves span 693 rows with every corner below the top, as the light wedge in the frame's bottom-left. When the port scores such a capture it applies the emulator's rule in place of the console's, on the packet's own screen axes (Y down).
The GTE's register limits
The screen corners themselves come out of saturated registers: the GTE clamps a corner's eye-space X and Y to 16 bits and its depth SZ to 0..65535 before it divides, and the primitive leaves draw whatever it wrote. The field camera folds a 6x world scale into its rotation, which brings those limits within reach of ordinary geometry. The ice floor in Noa's cavern (noaru) is a bowl twenty thousand units across: its far rim saturates SZ and lands further out than a plain projection puts it, its off-screen corners saturate X or Y and land further in, and the floor's texture is laid across the six screen-sized quads those corners make. A corner at or behind the eye falls under the same limits: its depth saturates at zero, the capped divide puts it at twice its eye-space offset from the screen centre, and the GPU draws the primitive to that point, where a near-plane clip would trim the primitive at the eye. Under PSX mode both hosts apply the same limits in the vertex stage before the vertex snap, seating a corner behind the eye at that screen point; a corner inside them is untouched, and clean rendering is unchanged. The near reject runs at the GTE's own sixfold field depth; the near tunnel wall in rikuroa that the reference capture leaves out is the capturing emulator's polygon limit, above.
RTPS corner passes through. On the field SZ saturates at an eye depth of about 10 900 port units; in battle the divide cap is what shows, on a camera-locked part close to the eye (Puera's edge glow). Under PSX mode the port applies the same clamps on both passes ahead of the vertex snap, from one per-frame word (camera_frame::gte_saturation).Camera-facing decoration cells
About a third of the field maps carry decoration cells whose record flags (+0x12 & 0x380) drop camera pitch, yaw or roll from the cell's matrix: the candle glows in rugi stay square to the lens, the Voz forest trees (vell, vozz) and the billboards of deene and retona turn with the view. The field decoration pass FUN_801F7088 builds that matrix per cell; drawn under the full camera instead, the glows lie flat as ellipses. Both play hosts rebuild a flagged cell every frame through one kernel (gte::decoration_cell_basis).
Where the camera transform comes from
Once a frame, one routine (FUN_800172C0) turns the field camera's globals into the GTE's rotation and translation registers. The field controller ends on it, and every minigame overlay and the world-map renderer call it too. It builds the light matrix first, then the view:
screen = proj(H) * ( S * Rot(pitch, yaw, roll) * (v - focus) + tr_eye )
Sis a per-mode uniform scale held in a base matrix at0x8007BF10: a live town holds 6× (GTE4096is 1.0). The eye offsettr_eyeis in unscaled units, so a renderer that draws geometry at 1× reproduces the frame by dividing the eye trio byS- the perspective divide is invariant under a uniform scale of the whole eye-space vector.- There is no eye-back depth constant in the chain. The live eye trio is the whole offset.
- The focus is X and Z only. The focus globals hold the negated anchor, and in the field only X and Z are ever written; the Y global reads zero on every sampled frame while the player's footing does not. The camera's vertical framing rides the composed eye Y.
- The d-pad ring goes with the framing. Retail rings the pad heading at 45 degrees against the camera, so “up” is always away from the lens (field locomotion).
A probe that samples the registers every frame confirms the formula across three field states, including frames where the camera's staging values differ from the live ones.
The field ground: two emitters and a far bucket
A field scene's floor is not a mesh. The field render library (PROT 0900) walks the map cells inside the camera's visible tile window and emits one textured quad per cell, with its four corner heights read from the collision grid's floor tiers. The library ships that emitter twice - a depth-cued body and a flat one that writes the raw colour word - and ordinary field play takes the flat one.
0x1000. Bit 0x8000 then decides whether it competes for depth or simply lies under the whole frame.- The window is the crop. Four signed bytes in the scratchpad give the tile window as offsets around the camera's tile, so the pass runs a fixed-size double loop - 32 by 48 cells in one measured scene - and nothing outside it exists that frame.
- A far-bucket cell is painted under everything, nearer or not. A depth buffer agrees for a flat cell but not for a sloped one: where a cell's corners are a cliff apart it is a near-vertical sheet of ground texture that retail never shows, because the cliff mesh in front paints over it. The port carries the bit per cell and its ground shader pushes such cells to the back of the depth range, flat ones by a hair and sloped ones into a thin far slice.
- Off retail’s vantage the floor is tested a few units lower. The two buckets are only faithful under retail’s steep follow camera: the scenes keep meshes under the floor (the sky shell beneath
taiku2’s throne room, a brick triangle under atown01path) that a swung, tilted or pulled-back camera would see painted over the ground. So whenever the view leaves retail’s framing - the default Far distance preset included - both hosts depth-test every far-bucket cell as a level plane 8 units below it, along each pixel’s own ray: a flat cell as the plane under itself, a sloped one as the plane under its corner farthest from the eye. A sloped cell left in the far slice there lost to the beach 192 units below atown01plateau ramp. At retail’s framing, which is what the comparison corpus scores, the buckets stay as retail draws them. - Why the hair matters. A decal authored on the floor plane - a subtractive shadow strip a tenth of a unit above the ground - ties with the floor at float rounding and loses every pixel without it.
What retail removes before the GPU sees it
Retail has no near-plane clip and no depth buffer, so it drops whole objects and whole primitives instead. The port reproduces each of these cuts through one shared kernel per rule, because a per-pixel clip draws things retail never shows.
| Cut | Retail rule | What goes wrong without it |
|---|---|---|
| Back faces | Every per-primitive handler runs NCLIP and rejects a non-positive area; a quad survives when either of its triangles faces the eye. A mask lowered by colour-word bit 0x08000000 makes a body double-sided, and no placed field object carries it | A sky dome seen from outside paints its outer shell over the scene; a semi-transparent battle body draws about twice as bright because both shells add |
| Placed object near the eye | The actor draw walk skips a placed object whose origin's view depth is below 0xA0 (160 units), or behind the eye - unless its map record sets the exemption bit (record +0x12 & 0x20, copied to actor +0x52), as the two sky shells outside the window of the credits’ edkorout shot do | A cutscene camera parked inside a mist shell draws the shell's inside across the frame |
| Primitive near the eye | After the depth average, a primitive whose ordering-table depth is under a per-scene floor is dropped: a mean depth under 128 on the field, under 64 in battle | A wall the camera is pressed against stretches across the screen |
| Oversized polygon | The GPU's own limit (above) | Long shards from a limb that reaches past the camera |
Effect quads that split into triangles. A colour word on texture bank 3 sends an effect quad through a leaf that cuts it into sixteen GT3s. The leaf first rejects the whole quad - a back-facing one (unless the word is double-sided), or one whose four projected corners all lie off one edge of the frame - and then each kept triangle takes only the near reject, links on its own depth average and maps its texels affinely on its own. The port splits a mesh part's quads on such a word too: V-Windhash's blade trail is four quads that run past the screen, and drawn as two triangles each its texture smeared across the frame. Split, the newest pair lands on retail's sub-triangle corners with matching colours.
Which passes cull back faces is itself a table: the field, the dance hall and battle bodies do, and so does an effect mesh (a procedural ring, disc or sprite quad, or a summon or move part's library mesh) whose colour word lacks the double-sided bit, in battle and in the field alike; the world map, the battle stage and the other minigame venues draw both sides. The word, not the winding, decides an effect sheet: the camera-facing steam sheets on the Sol Tower stairs carry the double-sided bit and keep both faces on both hosts, and a field light cone with the same bit is never dropped by the field pass's cull. The debug orbit camera is exempt from the near cuts.
Two “is it on screen” tests that are not the same test
Retail has two GTE-backed visibility probes, and they are not interchangeable. The field one projects a box around an actor and accepts any corner inside a real rectangle. The battle one tests only the box's horizontal span against the screen band - it reads no Y at all, so a body a full screen above the viewport still counts as visible. The battle probe also has no reference of any kind anywhere on the disc, so nothing consults its verdict; the port's lack of a cull there is parity.
Drawing a battle body, and its shadow
Monsters, the party and a summon's parts all go through one draw (FUN_80048A08) once per frame: compose the actor matrix under the camera, decode the pose, apply the render scale, then hand each object of the mesh to a primitive leaf.
- Rotted limbs draw dark. The colour word is reloaded for each object, because a party member's status can override it per object. Three Rot bits each map to an object range of that character's battle mesh through a five-byte row per character; a dimmed object draws with red and green quartered and blue halved, pushed three quarters of the way toward a blue-black by the depth cue. It is the only per-object colour rule in the draw.
- The battle shadow is procedural. Unless the body is an after-image ghost, the draw ends with a disc under the actor: a 24-column ring of outer radius four tenths of the actor's size, built in the XZ plane at height zero and subtracted from the frame. It is drawn only while the actor is neither pitched nor rolled, and it dims when the actor is airborne. In battle the angles it tests are the battle actor's own, copied across every animation pass, so a body a cast module tips casts no shadow: Kemaro's frozen victim, Mushura's banking creature, Barra's diving pair.
- The field shadow is a texture. A field actor's blob is a different routine (
FUN_8001C394): a 3×3 grid of points 32 units apart is projected at the actor's feet and its four cells are drawn as textured quads, tiling one 16×16 dark blob from the menu-glyph page at half-and-half blend. A depth bias sorts the blob behind the actor standing on it and over the ground. Jumping and scripted vanishes switch it off through one flag bit.
Both shadows are checked against retail captures packet for packet. In the port the field blob's corners take their depth six units nearer the eye along the view axis, so a depth test does the job retail's ordering does: under the actor, over the ground. The pull runs along the eye ray because a height lift fails under a level camera: in the dance hall six units of height came to under a unit of depth and the stage floor covered every blob. Retail links that floor's quad under a dancer into the blob's own ordering-table slot and draws the floor first.
The blob's darkening is blend arithmetic, not its texel. The blob texel is a grey of 3 on the 5-bit scale under the half-and-half mode, and the GPU averages in 5 bits, (B + F) >> 1: a floor at 3 stays 3 and one at 4 reads 3. On the dance hall's dark floor the blob is all but invisible, on the console and on the emulator the states were captured with.
The shatter renderer: a body that flies apart
A body whose break-up word is non-zero leaves the ordinary primitive dispatcher and is drawn by a sibling pair (FUN_80029888 per object, FUN_80029DD8 per primitive). It is not a whole-model spin: every triangle spins about its own centroid and is pushed away from a burst centre, so the mesh comes apart in shards. A quad is two triangles, each with its own centroid, and there is no back-face test, so a shattering body draws both sides.
- Flags shape the burst. One row of flags turns off the spin, makes each shard shrink as the word rises, or holds the shards in place. Three more sweep a front across the body along a camera axis: only the triangles past the front come apart, which is how a summon creature assembles from the floor up.
- Who drives it. On the field, a script op ramps the word from
0to0x1000(script VM). In battle the cast modules do: six summons break up or assemble their own creature, and two of them do it to the monsters (cast modules). Effect parts pass the same gate, so a summoning mound or a crystal bursts the same way. - In the port.
render-kernels::shatteris the integer kernel, shared by both play hosts for battle bodies and field NPCs; a body whose word is zero takes the plain path untouched. The field's view-space sweep frame, the per-triangle depth window and the light-source rows' colour are not modelled.
The frame as a texture, and the grade
On the console the framebuffer is VRAM: the display area is a rectangle inside the same image textures are read from, so a primitive can sample pixels the GPU drew a moment earlier. The field-to-battle transitions depend on it - the curtain slices a captured field frame into row and column strips, each an ordinary textured quad whose texture page is the capture.
- Where retail parks the capture follows from the curtain's own texture-page words: a 320×240 15-bit image at VRAM
(320, 0)with a second copy at(320, 256), to the right of the two display buffers. - The port adds the missing direction. A modern GPU renders to a target unrelated to the software VRAM page, so a shared kernel reads the frame back and writes it in. The 15-bit quantisation round-trips exactly; the one deviation is a point-sampled downscale when the window is larger than 320×240.
- The colour grade is off everywhere but the prologue. The opening's gold sepia is applied by retail to the loaded assets - every palette entry and every resident colour word is rewritten - and the port's shaders apply the same laws per decoded texel. Every other scene renders bit-identical to the ungraded pipeline (cutscene).
Faithful and default are different axes
Shading - which polygon is which colour - defaults to retail. The PS1's rasterisation artefacts default off and sit one switch away. The enhancements default on in the play hosts and leave the faithful render pixel-identical when switched off.
| Knob | Default | Retail is | What it gates |
|---|---|---|---|
set_psx_mode (--psx-render, LEGAIA_PSX_RENDER=1) | off | on | the GTE register limits, sub-pixel vertex snap, each field and battle primitive's draw at its ordering-table link, 4×4 ordered 15-bit dither and the GPU's 5-bit blend arithmetic, nothing else |
set_semi_blend | on | on | semi-transparency; off draws water and glows solid |
| Affine UVs | always | always | a static shader qualifier, not a switch |
Enhanced lighting (I) | on in both play hosts | off | time-of-day mood, lamp lights at glowing props, emissive primitives, halos |
Volumetric fog (F9) | off in both play hosts while the look matures | off | ground-hugging fog volumes |
Occlusion fade (F4) | on in both play hosts | off | see-through walls between the follow camera and the player |
Where the enhanced lamps sit. A prop's lamp lights are read from its mesh posed on the first frame of its clip, the pose the window draws before the clip player runs: read unposed, a clip prop's parts all sit at its origin, and a lamp the clip lifts onto a post or a roof would light the ground under it. The lamp shadow pass recovers each draw's model the same way the shading does, grouping the matrix product identically; grouped differently, it rounded ground under its own depth on some frames and tiles under a lamp blinked dark.
Retail dithers. The draw-environment initialiser stamps the GPU's dither bit to 0, but the frame driver re-stamps it every frame from a global that boots at 1 and a script opcode can change. The port's clean default is a choice about the look, not a reading of the executable.
A battle frame can lose it. The dither bit lives in the GPU's draw mode, so a draw-mode packet in the ordering table moves it for everything drawn after. The wash a cast arms is drawn through the fade quad, whose packet clears the bit in the farthest bucket; the ground grid's own pair of packets sets it again. A frame that arms the wash over a stage the grid does not draw, such as the closing arms of the Ozma summon, is drawn with no dither at all. The port follows the same rule in PSX mode on both hosts.
Semi-transparency is per texel on a textured primitive: palette entries with the STP bit blend, others draw opaque. The four blend equations are on shading.
PSX mode blends at the GPU's 5-bit precision
The GPU blends on 5-bit channels and truncates, so a float blend over the same two pixels keeps the half step that mode 0's average drops and the fraction that mode 3's quarter drops. A fixed-function blend cannot floor, and it does not need to: every PSX-mode write leaves a channel whose 5-bit value is its top five bits, so a blend only has to land its result in the right 8-wide bin. Each blend mode's fragment entry writes the term that does that, and the background brings its own low bits:
| Mode | Console | Term the port writes |
|---|---|---|
| 0, half and half | (B + F) >> 1 | 4F over a background factor a hair under one half |
| 1, add | min(B + F, 31) | 8F |
| 2, subtract | max(B − F, 0) | 8F |
| 3, add a quarter | min(B + (F >> 2), 31) | 8 × (F >> 2) |
Mode 0's factor sits just under one half so a background at the top of an odd bin does not round into the next. A second rule set matches the PCSX-Redux software GPU the retail captures come from, which differs in places (it halves untextured flat polygons and sprites as (B >> 1) + (F >> 1) and sums a Gouraud foreground before its dither). Because a blended channel can sit anywhere in its bin, the native target's present and capture read the frame as the 15-bit framebuffer it models, unless an enhancement pass (SSAO, bloom, fog) has laid a smooth term over it. The browser play page writes the console's terms.
VRAM in the port
The PS1 keeps every texture and palette in one shared 1 MB image, and 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". The port keeps that image as a real 1024×512 16-bit texture and decodes 4/8/15-bit texels and palettes per pixel in the fragment shader, so one draw call carries every texture mode exactly as the hardware samples it.
Most of the port's VRAM work is deciding which bytes to write: 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. The scene builder unions the palette rows and texture pages its meshes actually sample, uploads shared blocks first, then images, then palettes (upload passes). Every colour stays a PSX framebuffer value end to end; the output is never sRGB-encoded.