Shading and palettes
How Legaia turns a texture into the colour you see on screen - and why an exported texture's palettes can look wrong. A texel is only an index; its colour comes out of video memory, not the texture's file; the GPU then multiplies it by a colour painted into the mesh. Only a small set of mesh rows is lit by a light source; everything else is painted.
At a glance
- Texel
- A 4-bit (or 8-bit) index. 4bpp: the low nibble is the left texel.
- Palette
- 16 (or 256) 15-bit colours plus an STP bit, read from the VRAM cell the primitive names at draw time - not from the texture's own file.
- Transparency
0x0000is never drawn;0x8000is opaque black.- Shading
out = texel × colour / 128.0x80is neutral; most field surfaces sit below it, so they draw darker than their texture.- Light source
- Only on the light-source mesh rows (TMD group flags
0x10..=0x17), shaded by the GTE light. Everything else uses its baked colour plus the depth cue. - Blending
- Per texel: STP colours of a semi-transparent primitive blend with the screen in one of four modes.
- Output
- 15-bit framebuffer, dithered by retail (on at boot, script-controlled).
- In the port
- Shading defaults to retail; rasterisation (vertex snap, dither) defaults to clean, one switch away.
The chain, step by step
texel × far / 128, not the far colour itself.- IndexThe GPU fetches the texel's index from the texture page.
- Palette16 halfwords at VRAM
((CBA & 0x3F) × 16, CBA >> 6)- whatever the last upload left there. - KeyA
0x0000entry skips the texel, opaque primitive or not. - Colour wordOne per primitive (flat) or per corner (gouraud), baked by the artists.
- Depth cue
c + (far − c) × IR0 / 4096; unfogged field scenes useIR0 = 0. - BlendSTP texels mix with the screen:
B/2+F/2,B+F,B−ForB+F/4. - Dither4×4 ordered offset, then each channel cut to 5 bits.
Why a texture's palettes can look wrong
An export decodes the image through one of the file's own palettes. Three Legaia habits make that differ from the game:
- One sheet, many palettes. A 4bpp UI or character sheet is one grid of indices that every sprite or polygon reads through its own palette. Each palette recolours the whole image, so in any one palette most of the sheet shows colours it is never drawn with. The status badges (Venom, Toxic, Stone...) are all different colours in game, each through its own palette.
- A palette from another file. A draw names a VRAM cell, and that cell can belong to a different texture: three of the status badges use palettes uploaded by a small palette-only file that sits just before the UI sheet on the disc.
- A palette overwritten before use. Uploads are last-write-wins. The battle ASCII font's own palette is overwritten at boot by the menu glyph atlas's palette strip, so the game always draws it through a cell of that strip.
A multi-row palette block also lands in VRAM as one flat strip on a single row, so "palette N" of a TIM is the cell 16×N entries along that row, not row N.
The asset viewer's TIM catalog resolves these from your disc: its Palette list offers As the game draws it for the menu / battle UI sheet (every sprite through the palette the game uses for it, taken from the widget-class table), lists the palettes video memory really holds on the texture's row, and notes when a texture's own palette is never used or is all zeros. The ROM patcher's texture editor reads the same map.
Why the game looks darker (or brighter) than the texture
Every mesh polygon carries a colour painted by the artists, one per corner on smooth-shaded polygons. The GPU multiplies the texture by it, with 0x80 meaning "unchanged". A texel of (200, 100, 50) under 0x60 shows as (150, 75, 37); under 0xC0 it clamps to (255, 150, 75). Roughly four in five colour components in the field scenes sit below 0x80. Menu and HUD sprites are drawn at exactly 0x80, which is why a UI sheet decoded through the right palette matches the screen and a field texture does not.
The light-source rows are the exception. They carry a normal instead of a colour word, and the GTE computes their colour from the scene light: the back colour (0x20 a channel unless a script changes it) plus the light's intensity against the face, times the object colour. A face turned away from the light keeps about an eighth of its texel. cave01's rock walls are built entirely from such rows; most scenes hold few or none (formula).
Battles add two runtime terms: an ambient that the ground grid is shaded from (it dims while a summon is on screen and fades in at the start of every fight), and a per-actor tint that darkens distant bodies, colours status ailments and draws a rotted limb dark.
Palettes that move
| Mechanism | What moves | More |
|---|---|---|
| Palette walk (frames copied into the live cell) | water, waterfalls, the world-map ocean | World map |
| Hue / brightness cycler | "pulsating flesh", lightning flashes | field ambient FX |
Scripted palette family (field-VM 4C 61) | fades and one-shot palette stamps | Script VM |
| Prologue palette collapse | every uploaded palette rewritten to gold sepia | Cutscene |
An export shows the disc state, so a palette that looks like a strip of near-identical ramps is often a set of animation frames waiting to be walked into the live cell.
Editing a texture
- Edit indices, not one palette's colours. The same index means a different colour in every palette; check the region in the palette the game uses before repainting it.
- Keep the transparency key.
0x0000is "not drawn" everywhere; use0x8000for black. - Don't pre-brighten. A dark look in game is the mesh colour or the depth cue, applied on top of your texture; brightening the file brightens every polygon that uses it.
- All-zero palettes are placeholders filled in video memory by another texture of the same scene.
- Animated palettes overwrite the file's colours in the cells they walk.
Replacing a texture on your disc is done in the ROM patcher; the byte-level rules are on the TIM page.
In the port
The renderer's two halves point opposite ways. Shading defaults to retail: the baked colour word, the depth cue and the light-source rows, with no synthetic light on any game path (the play hosts layer the optional enhanced lighting over it). Rasterisation defaults to clean: vertex snap and the 15-bit dither are behind set_psx_mode. Affine UVs are not gated - they are always on, because they are the faithful behaviour.
- Palette maps
legaia_ui_formats::tim_palette_context(which cell draws which texel; what the boot VRAM holds)- VRAM + palette decode
engine-render(1024×512 R16Uint texture, decode in the fragment shader)- Multiply, cue, blend, dither
psx_modulate/psx_depth_cue/psx_blend/psx_ditherin the shader prelude, CPU mirrorpsx_light- Light-source rows
render-kernels::field_light,engine-field::field_lit_mesh- Battle colour
engine-battle-vm::battle_ground_grid,battle_actor_tint- Animated palettes
engine-field::clut_cell_fx,legaia_scene_formats::clut_walk