At a glance

Shape
Standard TIM (magic 0x10) in a type-0x01 streaming 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:

+0x00chunk headeru32
+0x04TIM magic 0x10u32
+0x08flags 0x08u32
+0x0CCLUT bnum 0x20Cu32
+0x10fb_x 0, fb_y 4792×u16
+0x14256 colours, 1 CLUT2×u16
+0x18CLUT data: 256 BGR555 halfwords512 B
afterTIM image block…
FieldMeaning
chunk header(type << 24) | size; e.g. 0x01008220 = type 0x01 ("upload TIM"), payload 0x8220 bytes
flags0x08 = 4bpp with a CLUT
CLUT block0x20C bytes = 12-byte block header + 512 bytes of colours, landing at rect (0, 479, 256, 1)
image blocka 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.

TIM A (full) slots 0..14 populated zero TIM B (partial) slots 0..7 zero - does not overwrite VRAM row 479 union of non-zero slots
Later uploads merge into the row; only non-zero halfwords land. The result is one fully populated palette row.

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:

Rowfb_xSource TIM (PROT.DAT offset)Declared CLUT rect
5100..255menu-glyph / interior-page atlas 0x11218(0,510,16,16)
510256..319UI sprite strip 0x19438(256,510,16,4)
5110..255system-UI sprite sheet 0x018E0(0,511,16,16)
511256..303boot cursor 0x01858(256,511,16,3)
511304..319UI 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
FunctionAddressWhat it provesDump
Battle-init TIM walkerFUN_8001FE70 (via FUN_800520F0 → FUN_8001FA88)Walks the streaming tail; LoadImage per type-0x01 chunk; stops at zero-size or type-0x02funcs/8001fe70.txt
Standard chunk dispatcherFUN_8001F05C case 1Same type byte means TIM_LIST there - the two walkers' semantics differfuncs/8001f05c.txt
Boot per-TIM uploaderFUN_800198E0Flattens each CLUT bank to a w*h × 1 strip - the rows 510/511 bandfuncs/800198e0.txt
VRAM capturesrow 479 = blob offset 0xEF800Row zero in pre-battle town saves, populated mid/post-battle; strips 510/511 byte-identical in every phasemednafen automation (vram-dump, bracket-writer.py)

See also

Source of record: docs/formats/npc-palette.md.