NPC CLUTs at VRAM row 479 Confirmed
Every villager, guard and shopkeeper gets its colours from a palette - on the PSX, a CLUT (colour look-up table) is literally a row of pixels somewhere in the console's 1024×512 video memory. Legaia parks its NPC palettes along row 479, near the bottom edge, as plain TIM images inside each scene's data. The two things worth knowing: the row is filled only when a battle loads, and several TIMs share it without clobbering each other.
At a glance
- Shape
- Standard TIM (magic
0x10) in a type-0x01streaming chunk; CLUT rect(0, 479, 256, 1) - Where on disc
- Tail of each scene's scene_tmd_stream PROT entries (town01: entries 0006..0009, one row-479 TIM each)
- Uploaded
- At battle init only, by the battle-init TIM walker; a freshly loaded town has the row empty
- Sampled via
- CBA cells
0x77C0..0x77CF(sixteen 16-colour slots across the row) - Parser / engine
legaia_scene_formats::scene_tmd_stream::battle_tim_chunks,legaia_tim::vram,legaia_tmd::vram_targeted- Confidence
- Confirmed - uploader chain traced; row contents byte-matched against emulator VRAM dumps before and after battle
- Used by
- Renderer, NPC browser
Layout
Each contributing TIM declares a CLUT block at framebuffer rect (0, 479, 256, 1): one
256-colour row that carves into sixteen 16-colour slots (slot N at fb_x N*16..N*16+16).
NPC meshes name a slot through their per-primitive CBA field. Contents are scene-specific: the human-villager
towns (town01/0b/0c/0d) share one palette set; balden, bubu1, urudre1, nilboa and chitei2 each carry their own.
On disc, each TIM sits inside a type-0x01 streaming chunk in the entry's tail:
| Field | Meaning |
|---|---|
| chunk header | (type << 24) | size; e.g. 0x01008220 = type 0x01 ("upload TIM"), payload 0x8220 bytes |
| flags | 0x08 = 4bpp with a CLUT |
| CLUT block | 0x20C bytes = 12-byte block header + 512 bytes of colours, landing at rect (0, 479, 256, 1) |
| image block | a standard TIM image block, typically a 256×256 4bpp page at fb_x=832 |
Reading trap: the type byte, and why the standard walker crashes here
In file order the chunk starts 20 82 00 01; the leading 0x20 is the
low byte of the size field, not a type byte. The type is the high byte of the LE u32
(0x01) - the same packing the standard asset-type dispatcher
uses, but with different semantics: the standard dispatcher's case 1 means TIM_LIST
(a [count + offsets + TIMs] pack), while the battle-init walker's case 1 means "single bare
TIM via LoadImage". Feeding these chunks to the standard streaming walker mis-reads the TIM
magic 0x10 as a 16-entry pack count and crashes.
legaia_container_formats::tim_scan detects these TIMs by the inner magic alone;
scene_tmd_stream::battle_tim_chunks is the walker that understands the wrapper. It tags each
chunk WalkSource::Tail (inside the walker's reach - every retail chunk) or
WalkSource::Continuation (past the first terminator, i.e. an over-read into the next PROT entry).
Sharing the row: merge-zeros
A town carries several row-479 TIMs - town01's entries 6..9 hold four, one per entry
(each entry has two TIM chunks; the other one puts its CLUT on row 473), all targeting the same VRAM cells.
Entries 7 and 8 are "full" (slots 0..14 populated); entries 6 and 9 are "partial" (slots 0..5 + 7, and
0..7, the rest zero-padded on disc). The engine's targeted upload resolves the race with
merge-zeros semantics: a
0x0000 halfword in a later upload never overwrites a non-zero halfword from an earlier one.
The row ends up as the union of every TIM's non-zero slots.
The semantics matter: with plain last-write-wins, the partial TIMs' trailing zeros would blank the full
TIMs' upper slots and the NPC meshes anchored there would lose their palettes. In the port the merge lives
in legaia_tmd::vram_targeted, which runs the CLUT pass second, after the image blocks.
The merge applies among the scene's own TIMs only. The shared init_data block's TIMs are
resident before the scene loads, so a scene palette's zeros replace their words, as the PSX
LoadImage call does. Merging across that line left an opaque near-black under the Sky Gardens
Tower's foliage palettes on row 484, and the vines drew on dark-brown cards.
When the upload happens
Retail uploads these TIMs only at battle init; the field / town scene loader never
touches them. The battle-init walker scans the streaming tail and DMAs every
type-0x01 chunk to VRAM with LoadImage, stopping at a zero-size header or
type-0x02 chunk. On a correctly-sized entry the terminator is followed only by padding, so
every TIM chunk the entry owns is reached.
- Emulator captures inside town01 with no battle yet: row 479 entirely zero.
- Captures mid- or post-battle: row 479 populated (PSX VRAM persists across scene transitions).
In the port, a field scene load (SceneLoadKind::Field) mirrors this lazy upload: it leaves
out every scene_tmd_stream entry - both its leading TMD (a battle character mesh) and its TIM
chunks - so field VRAM matches a retail town (row 479 empty). The battle load kind uploads them.
Not this format: the boot-resident band at rows 510/511
Rows 510 and 511 look like another NPC-palette band but are a different mechanism: the
flat-strip CLUTs of the boot-resident system-UI TIM bundle (the
timpack at raw PROT TOC
entries 0 and 1), uploaded once at boot and never evicted or scene-touched. The boot uploader flattens
every TIM's declared multi-row CLUT bank into a w*h × 1 strip at its declared origin:
| Row | fb_x | Source TIM (PROT.DAT offset) | Declared CLUT rect |
|---|---|---|---|
| 510 | 0..255 | menu-glyph / interior-page atlas 0x11218 | (0,510,16,16) |
| 510 | 256..319 | UI sprite strip 0x19438 | (256,510,16,4) |
| 511 | 0..255 | system-UI sprite sheet 0x018E0 | (0,511,16,16) |
| 511 | 256..303 | boot cursor 0x01858 | (256,511,16,3) |
| 511 | 304..319 | UI element 0x07B00 | (304,511,16,1) |
This is why field and dungeon environment meshes can sample CBA cells on row 510 even though no scene TIM ever uploads that row. For a parity oracle: row 479 is scene/battle-history-dependent; rows 510/511 fb_x 0..319 are static disc bytes, byte-identical across captures in every game phase.
Bundle details: readers and row patches
The bundle lives at raw TOC entry 0 (CDNAME init_data, PROT.DAT sectors 3..55
- the region the extraction index space skips, since extraction entry 0 is raw entry 2); a second
single-TIM pack at raw entry 1 is the GAME OVER overlay's, not boot-resident. Parser legaia_ui_formats::system_ui_bundle reads both packs
straight out of PROT.DAT and reproduces the full upload (legaia_ui_formats::interior_page
is the atlas-only reader). The pack's six 532-byte non-TIM members are bare row patches -
[u32, u32] preamble + TIM-style [u32 bnum][u16 x,y,w,h] blocks declaring
(960, 456..458/460..462, 256, 1) - that overlay the atlas image rows in place, byte-exact vs
live captures in every phase. Env-pack samplers pinned on the row: town01 slots 21/26/74 and rikuroa slots
50/51/63, CBA (64,510), texpage (960,256).
How we know
The battle-init walker and the boot uploader are traced in the disassembly, and both bands are byte-matched against emulator VRAM dumps: row 479 is empty in a town before any battle and populated during and after one, while rows 510/511 are identical in every phase.
Provenance: routines and captures
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Battle-init TIM walker | FUN_8001FE70 (via FUN_800520F0 → FUN_8001FA88) | Walks the streaming tail; LoadImage per type-0x01 chunk; stops at zero-size or type-0x02 | funcs/8001fe70.txt |
| Standard chunk dispatcher | FUN_8001F05C case 1 | Same type byte means TIM_LIST there - the two walkers' semantics differ | funcs/8001f05c.txt |
| Boot per-TIM uploader | FUN_800198E0 | Flattens each CLUT bank to a w*h × 1 strip - the rows 510/511 band | funcs/800198e0.txt |
| VRAM captures | row 479 = blob offset 0xEF800 | Row zero in pre-battle town saves, populated mid/post-battle; strips 510/511 byte-identical in every phase | mednafen automation (vram-dump, bracket-writer.py) |
See also
Source of record: docs/formats/npc-palette.md.