Overview

u32  id          // 0x00000010
u32  flags       // bits 0..2 = pixel mode (0=4bit, 1=8bit, 2=16bit, 3=24bit)
                 // bit 3   = CLUT present
[CLUT block if flag bit 3 set]
[image block]

Each block has its own header (u32 size, u16 dx, u16 dy, u16 w, u16 h) followed by pixel data.

In the extracted streaming files, all observed TIMs use type 8 (4-bit indexed with CLUT). They're VRAM-ready textures.

VRAM emulation in the engine port

crates/engine-render emulates a 1024×512 R16Uint VRAM page so per-prim CBA/TSB selectors plus 4/8/15bpp + CLUT decoding can be done in a fragment shader. The viewer uploads every sibling TIM into VRAM so multi-page meshes render with the correct CLUT bindings.

Some character meshes reference CLUT rows that live in different PROT entries from their TMD source (the runtime asset chain stitches them together). The viewer's --vram-extra-dir flag is the workaround until the chain is fully traced for every scene type.

Multi-row CLUT blocks

The PSX TIM spec allows a 4bpp TIM's CLUT block to contain multiple CLUT rows (each row is 16 BGR555 entries = 32 bytes), so the same indexed pixel data can be re-rendered under different palettes. Legaia uses this extensively for system-UI sprite sheets:

Source TIMLayoutCLUT-row usage
System-UI sprite sheet at PROT.DAT[0x018E0] (4bpp, 256×192, 16×16 CLUT block) Lives in the unindexed pre-init_data gap — not reachable through the per-PROT-entry walker. Constants in legaia_asset::title_pak::OVERLAY_SYSTEM_UI_TIM_*. Row 2 = load-screen panel chrome (gold-bronze 9-slice border + marbled-blue interior region). Row 7 = pointing-finger cursor. Other rows render HP/MP/money panels, battle chrome, equipment frames.
Menu-glyph atlas at PROT.DAT[0x11218] (4bpp, 256×256, multi-row CLUT block) Same pre-init_data gap. See legaia_asset::menu_glyph_atlas. Row 13 carries the “Load” text glyphs the load screen draws inside its panel. Other rows render NEW GAME / CONTINUE / OPTIONS strings + smaller menu labels.

Both TIMs are byte-confirmed against retail VRAM dumps. See subsystems/save-screen — sprite asset sources for the pinning method.

Browse them in the asset viewer with:

asset-viewer tim extracted/PROT.DAT --offset 0x018E0 --clut 2   # system-UI panel CLUT
asset-viewer tim extracted/PROT.DAT --offset 0x018E0 --clut 7   # system-UI cursor CLUT
asset-viewer tim extracted/PROT.DAT --offset 0x11218 --clut 13  # menu-glyph "Load" text CLUT

Cataloging every PROT.DAT TIM

PROT.DAT is also indexable as one flat 2048-byte-sector stream. Scanning the whole image (rather than per-TOC-entry) catches every standard TIM regardless of which addressing layer hosts it — including the TIMs in the unindexed system-UI gap before the first entry (the menu-glyph atlas and load-screen chrome above). legaia_asset::tim_catalog does this and maps each hit back to its owning PROT entry + byte offset (or the gap), producing a per-TIM catalog keyed by a stable id.

asset tim-catalog extracted/PROT.DAT --out catalog.tsv   # or .json
asset tim-catalog extracted/PROT.DAT --rollup            # count + digest

Strict validation (what counts as a TIM)

A magic-only scan turns up spurious matches — a coincidental 0x00000010 word inside another TIM's pixel data, padded blocks, or garbage pixel modes. legaia_tim::parse_strict applies the checks that separate real, VRAM-ready TIMs from noise:

  • No reserved flag bits — only bits 0..3 (pixel mode + CLUT-present) may be set.
  • A real pixel modepmode 0..3.
  • Exact block lengths — each block's size equals 12 + w*h*2 precisely, no padding.
  • Nonzero dimensions and an in-VRAM-bounds image rectangle.

The CLUT rectangle is deliberately not bounds-checked: Legaia stores many NPC palettes at fb_y 510..511 (the row-479 CLUT band) with heights up to 16, so a legitimate CLUT block extends a few rows past the framebuffer's bottom edge.

Under this rule a flat scan of the retail NA PROT.DAT recovers the same TIM set an independent reference decoder reports, cross-checked item-for-item (identical offsets, dimensions, bit depths, palette counts). The lenient legaia_tim::parse is kept for callers decoding bytes already known to be a TIM. A committed reference catalog (derived metadata + fingerprints, never pixel bytes) plus a disc-gated regression pin the count and a rollup digest. The in-browser asset viewer builds the same catalog live from a user-supplied disc — page through every TIM by id with its CLUT variants.

Deep catalog: TIMs inside LZS-compressed sections

The flat catalog — like the reference decoder — scans only raw bytes, so any TIM stored inside an LZS-compressed PROT.DAT section is invisible to it, and most character and scene textures are compressed. legaia_asset::tim_deep_catalog recovers them as a separate tier: it walks every PROT entry, LZS-decompresses it, and strict-parses every TIM in each decoded section, keyed by (entry, LZS section, offset-in-section).

asset tim-deep-catalog extracted/PROT.DAT --out deep_catalog.tsv   # or .json
asset tim-deep-catalog extracted/PROT.DAT --rollup                 # count + digest

The validity gate matters: LZS “decompresses without error” is never a validity signal — the 4 KB ring buffer initialises to zeros, so random input decodes to plausible-looking bytes. A deep hit is admitted only when the decoded bytes both pass parse_strict and decode to RGBA, which rejects the coincidental TIM-magic-in-noise a magic-only scan of decompressed garbage would produce. The deep tier stays wholly separate from the flat catalog (which remains byte-identical to its reference); it has no external decoder oracle, so its disc-gated regression instead guards the decode path + validity gate by pinning count + rollup digest + a byte-exact committed reference (metadata + fingerprints only). The viewer surfaces it as a distinct “compressed textures” grid.

Semantic labels

The catalog records where each texture lives, not what it is. legaia_asset::tim_labels is a curated label table that answers the “what”. It is keyed by content fingerprint (the FNV-1a-64 the catalogs already record), so a single label propagates to every catalog id sharing those bytes — duplicates and textures aliased across overlapping PROT entries — and one table serves both the raw and the deep tier. Labels show as a label column in the reference TSVs and beside each identified texture in the viewer.

A label is either a coarse visual category assigned by inspecting the decoded thumbnail (environment, terrain, foliage, character, ui-text, effect, other) or a precise reverse-engineered role for a texture whose loader site is pinned (the menu-glyph atlas, the main-title sprite sheet, the four init.pak publisher / warning logos, and the load-screen UI sheet + party portraits + empty-slot frame). Both are our own observations — not asset strings or pixel data — so the table ships in the repo, like the ground-truth gamedata tables.

An earlier revision tried to derive an “NPC palette” label structurally from the CLUT load position fb=(0, 479). That is unsound: nearly every 256×256 4bpp scene/field texture page parks its CLUT in that same bottom VRAM band (see NPC palettes), so the rule conflated floors / walls / terrain with NPC colour tables. Labels are now content-keyed observations, not a CLUT heuristic.

See also