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 packs - bare, or behind the chunk header the TIM-pack reader steps past - 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_disc_audit::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.

File header

+0x00id = 0x10u32
+0x04flagsu32
+0x08CLUT block (if flags bit 3)12 + w·h·2
after CLUTimage block12 + pixels

flags bits 0..2 are the pixel mode (0 = 4bpp, 1 = 8bpp, 2 = 16bpp, 3 = 24bpp), bit 3 says a CLUT block follows, and bits 4+ are zero on a real TIM.

Block header (CLUT and image alike)

+0x00sizeu32
+0x04dxu16
+0x06dyu16
+0x08wu16
+0x0Ahu16
+0x0Cdataw·h halfwords

(dx, dy) is the VRAM position the rectangle uploads to; size = 12 + w*h*2. w counts 16-bit VRAM halfwords, not texels - a 4bpp image 256 texels wide has w = 64. A CLUT block with h > 1 holds several palette rows.

A TIM's two blocks upload to two rectangles of the 1024 by 512 VRAM grid: the image block to a tall rectangle at (960, 256), the CLUT block to a strip of 256 colours on row 510. A primitive then names a 16-colour slice of that strip. one TIM file header id 0x10, flags CLUT block dx, dy, w, h + colours image block dx, dy, w, h + indices VRAM: 1024 x 512 cells, 16 bits each (0, 0) image at (960, 256) 64 x 256 cells = 256 x 256 texels CLUT on row 510: 256 colours 0..63 80..255 row 510 zoomed: palette selector (64, 510) reads colours 64..79
The shared interior page as an example (image block at (960, 256), palette strip on row 510). The image holds only 4-bit indices; a texel gets its colour at draw time, from whichever 16-cell slice of VRAM the primitive names. The file's own CLUT is just the pixels that happened to be uploaded there - see why an exported palette can look wrong.

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

Any row 0..15 works; row 2 shows the panel chrome, row 7 the 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_actor_formats::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_ui_formats::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_disc_audit::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_disc_audit::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_disc_audit::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.

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.
  • Only the palette the image was drawn through is rewritten; every other palette of a multi-palette CLUT stays byte-identical, because the game picks a palette per sprite and copying one over the rest would recolour every region drawn through them. Editing several palettes at once goes through the multi-palette downloads.

Why an exported palette can look wrong

A TIM export decodes the image through one of the file's own CLUT rows. That matches the screen only when three things hold, and on Legaia they often don't:

  • Each palette recolours the whole image. A multi-palette sheet is one grid of indices that each sprite reads through its own palette, so any single palette shows most of the sheet in colours it is never drawn with.
  • The draw names a VRAM cell, not a file. A sprite can take a palette another TIM uploaded (the system-UI sheet's status badges use the CLUT-only TIM at PROT.DAT[0x1858]), and a later upload can overwrite a TIM's own palette before anything draws it (the battle font's (0, 510) strip is covered by the menu-glyph atlas at boot).
  • The screen multiplies the texel. The primitive's colour word (texel * colour / 128) and the depth cue sit on top of the palette.

legaia_ui_formats::tim_palette_context resolves the first two from the disc - boot-time CLUT state, a per-rectangle palette map of the system-UI page, and an “as drawn” composite - and the viewer and ROM patcher read it. The whole colour chain is on shading.

In the port

crates/tim parses and encodes; crates/engine-render keeps a 1024×512 R16Uint VRAM page so the per-primitive palette and texture-page selectors (CBA / TSB) and 4/8/15bpp CLUT decoding run in a fragment shader, with every sibling TIM uploaded so multi-page meshes bind the right CLUT.

How we know

Evidence behind each claim
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)
Encoder round tripencode(decode(tim)) is byte-identical for every raw-catalog TIM

See also