PSX TIM (texture) Confirmed
Every surface you see in Legend of Legaia - Vahn's jacket, the cobbles of Rim Elm, the gold border round a menu, the letters of the dialog font - is painted from a texture, and every one of those textures is a TIM. TIM is the standard PlayStation texture-image format: a small header, an optional palette, and a block of pixels stamped straight into video memory. This page covers how Legaia uses it, the two habits that break naive tools, and the write side that lets a modder swap a texture on the disc.
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
0x00000010as 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.DATbefore the first entry (0x018E0,0x11218) - Parser
crates/tim(parse,parse_strict, PNG export,encodePNG-to-TIM); catalogslegaia_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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | id | 0x00000010 |
+0x04 | 4 | flags | bits 0..2 pixel mode (0 = 4bpp, 1 = 8bpp, 2 = 16bpp, 3 = 24bpp); bit 3 = CLUT block present; bits 4+ zero on a real TIM |
+0x08 | 12 + data | CLUT block (optional) | size = 12 + w*h*2; h > 1 means several palette rows |
| after CLUT | 12 + data | image block | w 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.
| Sheet | Where | Rows |
|---|---|---|
| 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_atlas | Rows 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.
| Family | Declared | Uploaded as | Uploader |
|---|---|---|---|
| Field-character atlas palettes (PROT 0874 §2, entries 1..3) | multi-row block | one strip on row 478 | legaia_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 510 | legaia_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:
| Tier | Module | Finds | Command |
|---|---|---|---|
| Flat | legaia_asset::tim_catalog | Raw TIMs, mapped back to owning entry + offset (or the gap); item-for-item identical to an independent reference decoder | asset tim-catalog extracted/PROT.DAT --out catalog.tsv |
| Deep | legaia_asset::tim_deep_catalog | TIMs inside LZS-compressed sections (most character and scene textures), keyed by (entry, section, offset) | asset tim-deep-catalog extracted/PROT.DAT --out deep.tsv |
| Labels | legaia_asset::tim_labels | Content-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:
| Alpha | Encoded texel |
|---|---|
0 | 0x0000 - transparent (the GPU skips it) |
1..=254 | STP set + RGB truncated to 5 bits per channel |
255 | STP 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
| Claim | Evidence |
|---|---|
Head-gap sheets at 0x018E0 / 0x11218, row usage | Retail 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 / 510 | VRAM row 510 byte-identical across every captured field save; town01 env CBA (64, 510) resolves only under the strip reading |
| Flat catalog completeness | Item-for-item match with an independent reference decoder (offsets, dimensions, bit depths, palette counts); disc-gated regression pins count + digest |
| Encoder round trip | Disc-gated test: encode(decode(tim)) byte-identical for every raw-catalog TIM |