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_asset::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:
| Offset | Size | Field | Example / meaning |
|---|---|---|---|
+0x00 | u32 | chunk header | (type << 24) | size; e.g. 0x01008220 = type 0x01 ("upload TIM"), payload 0x8220 bytes |
+0x04 | u32 | TIM magic | 0x00000010 |
+0x08 | u32 | TIM flags | 0x00000008 = 4bpp + CLUT |
+0x0C | u32 | CLUT block size | 0x20C = 12-byte header + 512 data |
+0x10 | 2 × u16 | fb_x, fb_y | 0, 479 |
+0x14 | 2 × u16 | num_colors, num_cluts | 256, 1 |
+0x18 | 512 | CLUT data | 256 BGR555 halfwords |
| after | - | image block | Standard TIM image block, typically 256×256 4bpp 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_asset::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, all targeting
the same VRAM cells. Two are "full" (slots 0..14 populated) and two are "partial" (slots 0..7-ish, the rest
zero-padded on disc). The 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. The engine implements the
merge in legaia_tmd::vram_targeted::build_vram_targeted_from_buffers, which runs the CLUT pass
second, after image blocks.
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).
Engine port: field-mode vs battle-mode dispatch
SceneResources::build_targeted_with_options(..., SceneLoadKind::Field) mirrors retail's
lazy upload by excluding every scene_tmd_stream entry's contributions - both the leading TMD
(a battle character mesh, never drawn from a field scene) and its type-0x01 TIM chunks. Field-mode VRAM
then matches retail town saves (row 479 zero), and the TMD pool excludes battle meshes that would fail the
prim filter for sampling missing CLUT rows. SceneHost::enter_field_scene uses Field.
SceneLoadKind::Battle (the legacy default of build_targeted) uploads every
type-0x01 chunk eagerly and parses every embedded TMD - VRAM inflated vs retail's field state, but every
battle character mesh stays renderable for tests and diagnostic surfaces. Disc-gated regression tests hold
the town01 prim keep-ratio at 99.3% in battle mode and 100% in field mode.
History: the falsified "two-list" shape of 0006_town01
An earlier reading gave 0006_town01.BIN a "two-list" shape whose second list the battle-init
walker never reached. Falsified: the second pair of chunks is PROT entry 0007's own tail,
seen through a superseded over-reading entry-size expression, and the walker reaches it when it walks entry
0007. See scene-bundles § one entry, one stream
and do-not-re-walk.
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 sits at raw entry 1. Parser legaia_asset::system_ui_bundle reads both packs
straight out of PROT.DAT and reproduces the full upload (legaia_asset::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
| 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.