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_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:

OffsetSizeFieldExample / meaning
+0x00u32chunk header(type << 24) | size; e.g. 0x01008220 = type 0x01 ("upload TIM"), payload 0x8220 bytes
+0x04u32TIM magic0x00000010
+0x08u32TIM flags0x00000008 = 4bpp + CLUT
+0x0Cu32CLUT block size0x20C = 12-byte header + 512 data
+0x102 × u16fb_x, fb_y0, 479
+0x142 × u16num_colors, num_cluts256, 1
+0x18512CLUT data256 BGR555 halfwords
after-image blockStandard 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.

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. 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:

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 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

FunctionAddressWhat it provesDump
Battle-init TIM walkerFUN_8001FE70 (via FUN_800520F0FUN_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.