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 packs - bare, or behind the chunk header the TIM-pack reader steps past - 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_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
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)
(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.
(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.
| Sheet | Where | Rows |
|---|---|---|
| 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_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 2Any 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.
| Family | Declared | Uploaded as | Uploader |
|---|---|---|---|
| Field-character atlas palettes (PROT 0874 §2, entries 1..3) | multi-row block | one strip on row 478 | legaia_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 510 | legaia_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:
| Tier | Module | Finds | Command |
|---|---|---|---|
| Flat | legaia_disc_audit::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_disc_audit::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_disc_audit::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.
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.
- 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
| 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) |
| Encoder round trip | encode(decode(tim)) is byte-identical for every raw-catalog TIM |