What a TIM is

The PSX has 1 MB of video memory (VRAM) organised as one 1024×512 grid of 16-bit pixels - the framebuffer. A TIM is a “VRAM-ready” image: it records a rectangle of pixel data plus the framebuffer coordinates where that rectangle should be uploaded. Most textures are palettised - 4-bit pixels (4bpp, 16 colours) or 8-bit pixels (8bpp, 256 colours) - and the palette itself, called a CLUT (colour look-up table), is stored as just another little rectangle of framebuffer pixels, usually a single row of 16 or 256 entries. The magic check is simple: the first u32 of a TIM is 0x00000010.

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 - (dx, dy) being the framebuffer position it loads to.

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

Most TIMs on the disc live inside PROT.DAT (the disc's single big archive; everything except movies and audio streams lives in its 1233 numbered entries), often behind a layer of LZS compression. A few interesting ones live outside any numbered entry - more on that below.

Multi-row CLUT blocks: one image, many palettes

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 - one sheet of glyph shapes, recoloured per use by picking a palette row:

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 - a stretch of PROT.DAT before the first numbered entry's data, so no per-entry walker can reach it. 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

Flat-strip CLUT uploads: when the header lies

Here is the trap that makes textures “disappear” in a straightforward TIM renderer. Two Legaia TIM families declare a multi-row CLUT block whose declared rectangle would run off the bottom of the 1024×512 framebuffer. The retail game doesn't upload the declared rect at all - it uploads the palette as a single flat horizontal strip of w × h entries at the block's origin:

  • The field-character atlas palettes (PROT 0874 §2 entries 1/2/3 - entry 0874 of the archive, sub-section 2): each declared block lands as a strip on row 478 (legaia_asset::field_char_textures::upload_to_vram).
  • The shared interior page (the 256×256 4bpp TIM at image (960, 256)): its declared 16×16 CLUT block at (0, 510) lands as a 256-entry strip on row 510 - byte-identical to VRAM row 510 in every captured field save from the first post-New-Game scene onward. The TIM's only raw copy lives in the unindexed head gap of PROT.DAT (byte offset 0x11218, after the 3-sector table of contents but before the first entry's data), so no per-entry read can source it. Town env meshes reference mid-strip CBAs (CLUT-base addresses - the field in each drawing primitive that says which palette row to use; e.g. town01's (64, 510) = strip entry 64) with texture pages inside the (960, 256) image. Parser + uploader: legaia_asset::interior_page.

A renderer placing these blocks at their declared rects clips/wraps the rows past y = 512 and leaves the strip cells unpopulated - the meshes that sample them then drop (or render black) even though every byte is on disc.

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 mode - pmode 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.

Deriving an “NPC palette” label structurally from the CLUT load position fb=(0, 479) 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 conflates floors / walls / terrain with NPC colour tables. Labels are content-keyed observations, not a CLUT heuristic.

VRAM emulation in the engine port

crates/engine-render emulates a 1024×512 R16Uint VRAM page so per-prim CBA/TSB selectors (the per-primitive palette and texture-page addresses) 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 covers the scene types whose chain is not yet traced.

See also