At a glance

In the game
Every texture: character skins, floors and walls, the world map, UI panels and cursors, menu glyphs and the dialog font.
Magic
0x00000010 as the first u32
Lives in
Inside PROT.DAT entries (usually behind LZS), inside TIM-packs and field-packs, and two in the unindexed head gap of PROT.DAT before the first entry (0x018E0, 0x11218)
Parser
crates/tim (parse, parse_strict, PNG export, encode PNG-to-TIM); catalogs legaia_asset::tim_catalog / tim_deep_catalog / tim_labels
Confidence
Confirmed - public format; Legaia's upload habits byte-checked against retail VRAM dumps.
Used by
Renderer, Asset viewer, ROM patcher

What a TIM is

The PSX has 1 MB of video memory (VRAM), one 1024×512 grid of 16-bit pixels. A TIM is a “VRAM-ready” image: a rectangle of pixel data plus the VRAM coordinates it uploads to. Most textures are palettised - 4-bit (16 colours) or 8-bit (256 colours) indices - and the palette, the CLUT (colour look-up table), is itself just another small rectangle of VRAM pixels, usually one row of 16 or 256 entries.

id 0x10 +0x00 flags bpp | clut +0x04 CLUT block (if flag bit 3) size, dx, dy, w, h + w*h BGR555 entries +0x08 image block size, dx, dy, w, h + packed pixels after CLUT block header (12 bytes): u32 size u16 dx u16 dy u16 w u16 h (dx,dy) = VRAM pos; w in halfwords
A TIM is an 8-byte header followed by up to two VRAM rectangles, each with its own 12-byte header.
OffsetSizeFieldMeaning
+0x004id0x00000010
+0x044flagsbits 0..2 pixel mode (0 = 4bpp, 1 = 8bpp, 2 = 16bpp, 3 = 24bpp); bit 3 = CLUT block present; bits 4+ zero on a real TIM
+0x0812 + dataCLUT block (optional)size = 12 + w*h*2; h > 1 means several palette rows
after CLUT12 + dataimage blockw counts 16-bit VRAM halfwords - a 4bpp image 256 texels wide has w = 64

Every TIM in the streaming files is 4bpp with a CLUT. Most sit inside PROT.DAT entries behind LZS compression; two UI sheets sit in the unindexed gap before the first entry, where no per-entry walker reaches them.

One image, many palettes

A 4bpp CLUT block may hold several 16-entry rows, so one sheet of indexed shapes is recoloured per use by picking a row. Legaia's system-UI sheets work this way.

SheetWhereRows
System-UI sprite sheet (4bpp, 256×192, 16×16 CLUT)PROT.DAT[0x018E0], head gap; constants legaia_asset::title_pak::OVERLAY_SYSTEM_UI_TIM_*Row 2 = load-screen panel chrome; row 7 = pointing-finger cursor; others = HP/MP/money panels, battle chrome, equipment frames
Menu-glyph / interior atlas (4bpp, 256×256, multi-row CLUT)PROT.DAT[0x11218], head gap; legaia_asset::menu_glyph_atlasRows render NEW GAME / CONTINUE / OPTIONS and smaller menu labels; also the shared interior page (below)
asset-viewer tim extracted/PROT.DAT --offset 0x018E0 --clut 2   # panel chrome
asset-viewer tim extracted/PROT.DAT --offset 0x018E0 --clut 7   # cursor

Flat-strip CLUT uploads: when the header lies

This is the trap that makes textures “disappear” in a straightforward renderer. Two TIM families declare a multi-row CLUT rectangle that would run off the bottom of VRAM. The game does not upload that rectangle - it uploads the palette as one flat horizontal strip of w × h entries at the block's origin.

FamilyDeclaredUploaded asUploader
Field-character atlas palettes (PROT 0874 §2, entries 1..3)multi-row blockone strip on row 478legaia_asset::field_char_textures::upload_to_vram
Shared interior page (256×256 4bpp, image at (960, 256))16×16 block at (0, 510)256-entry strip on row 510legaia_asset::interior_page

Town meshes address cells mid-strip (town01 uses (64, 510) = strip entry 64) with texture pages inside the (960, 256) image. A renderer that honours the declared rectangle clips the rows past y = 512, leaves the strip empty, and the meshes sampling it render black or drop - even though every byte is on the disc. Row 510 in every captured field save is byte-identical to the strip upload.

Cataloging every TIM on the disc

Scanning PROT.DAT as one flat stream (rather than per entry) catches every raw TIM whichever addressing layer hosts it, including the head-gap sheets. Two tiers, one label table:

TierModuleFindsCommand
Flatlegaia_asset::tim_catalogRaw TIMs, mapped back to owning entry + offset (or the gap); item-for-item identical to an independent reference decoderasset tim-catalog extracted/PROT.DAT --out catalog.tsv
Deeplegaia_asset::tim_deep_catalogTIMs inside LZS-compressed sections (most character and scene textures), keyed by (entry, section, offset)asset tim-deep-catalog extracted/PROT.DAT --out deep.tsv
Labelslegaia_asset::tim_labelsContent-fingerprint (FNV-1a-64) keyed names - a coarse category or a pinned role - shared by both tiers and shown in the viewer--rollup on either command for count + digest

Both tiers admit a hit only through parse_strict: no reserved flag bits, pixel mode 0..3, block sizes exactly 12 + w*h*2, non-zero dimensions, image rectangle inside VRAM. The CLUT rectangle is deliberately not bounds-checked - the row-479 palette band parks legitimate blocks at rows 510..511 with heights up to 16. The deep tier additionally requires the decoded bytes to render to RGBA, because LZS decompresses anything without error. The asset viewer builds both catalogs live from a user-supplied disc.

Details: how labels are assigned

A label is either a coarse visual category from reviewing decoded thumbnails (environment, terrain, foliage, character, ui-text, effect, other) or a precise role for a texture whose loader site is pinned (menu-glyph atlas, main-title sprite sheet, the four init.pak publisher / warning logos, the load-screen UI sheet, party portraits, empty-slot frame). asset tim-render-distinct decodes each distinct fingerprint to a local PNG and scripts/asset-investigation/montage_tims.py lays them into contact sheets; the PNGs stay local, only the fingerprint-to-label table is committed. The flat tier has an external reference decoder as its oracle; the deep tier has none, so its regression pins count + digest + a committed metadata reference.

History: retired readings

An “NPC palette” label once derived structurally from a CLUT load position of (0, 479); nearly every scene texture page parks its CLUT in that band, so the rule conflated floors with NPC colour tables. Labels are content-keyed. The pin “menu-glyph atlas CLUT row 13 carries the load screen's Load text” is falsified by a live glyph-rect probe. Both live on do not re-walk.

Encoding: PNG to TIM (texture replacement)

A replacement texture only works if it is the same size as the original, so it drops into the same slot on the disc. legaia_tim::encode copies the original's structure verbatim - pixel mode, block dimensions, every VRAM placement field - and takes pixels from caller-supplied RGBA. It backs legaia-patcher tim-replace and the ROM patcher's texture panel.

Alpha to STP mapping

PSX texels carry a 1-bit semi-transparency flag (STP), not an alpha channel:

AlphaEncoded texel
00x0000 - transparent (the GPU skips it)
1..=254STP set + RGB truncated to 5 bits per channel
255STP clear + RGB truncated - except opaque pure black, which becomes 0x8000 (plain 0x0000 would read back transparent)

Palette fitting

  • A pixel whose position held the same colour keeps its original index verbatim; other matches reuse the first palette entry that decodes to the colour. Untouched regions keep their STP choices, so encode(decode(tim)) reproduces the original byte-for-byte across the whole raw catalog.
  • Indexed modes must fit 16 / 256 distinct 15-bit colours. New colours overwrite slots the new image no longer references; overflow is a hard error listing the offending pixels, or, behind an explicit quantize opt-in, the least-frequent extras fold to their nearest palette colour.
  • If any palette slot changes, the rebuilt palette is replicated into every CLUT row, so multi-palette variants do not recolour new indices with stale rows.

VRAM emulation in the engine port

crates/engine-render keeps a 1024×512 R16Uint VRAM page so the per-primitive palette and texture-page selectors (CBA / TSB) plus 4/8/15bpp CLUT decoding run in a fragment shader. The viewer uploads every sibling TIM into VRAM so multi-page meshes bind the right CLUT. Some character meshes reference CLUT rows in other PROT entries than their mesh; the viewer's --vram-extra-dir flag covers scene types whose chain is not traced.

How we know

ClaimEvidence
Head-gap sheets at 0x018E0 / 0x11218, row usageRetail VRAM dumps byte-matched; the 32-byte panel CLUT signature occurs once on the disc - save screen, sprite sources
Flat-strip upload on rows 478 / 510VRAM row 510 byte-identical across every captured field save; town01 env CBA (64, 510) resolves only under the strip reading
Flat catalog completenessItem-for-item match with an independent reference decoder (offsets, dimensions, bit depths, palette counts); disc-gated regression pins count + digest
Encoder round tripDisc-gated test: encode(decode(tim)) byte-identical for every raw-catalog TIM

See also