Skip to content

Commit 86010c7

Browse files
LinuxJessiclaude
andcommitted
Texture decode: overlay descriptors as UI-sheet materials (casino)
A model-less UI sheet is multi-palette by design, and OV11.OVL's texture descriptors say which parked CLUT colours which rect — the overlay equivalent of a .lex model's materials. scan_ovl_descriptors keeps the full (u, v, w, h, cbp) records; decode_xtx paints a descriptor group (all rects sharing one CBP) when its tile is a CLUT parked beside the sheet's own palette and the group renders its rects clearly more coherently than the base palette does (median ratio <= 0.9, opacity kept). The second test keeps the card descriptors — handed to every OV11 texture — off the poker pay table and exchange screens. Also lifts the < 95 %-opaque cap on the conventional-spot rule, which had left the fully opaque casino gallery sketches as line noise. Retail result, all 245 lex-less .xtx re-rendered: 15 change — the four card sheets (pips, court cards and back each from their own palette), the slot sheet (nine elements, nine CLUTs), nine gallery sketches, and texture/fm8011 (noise before and after). docs/research/lexless_render.py is the 35-second harness that proved it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent df6c734 commit 86010c7

4 files changed

Lines changed: 311 additions & 34 deletions

File tree

‎browse.py‎

Lines changed: 130 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -213,20 +213,22 @@ def _cbp_to_xy(cbp: int, clen: int) -> tuple[int, int]:
213213
return (page % pages_per_row) * 64 + bx, (page // pages_per_row) * 32 + by
214214

215215

216-
def scan_ovl_cluts(code_dir: Path) -> dict[str, list[int]]:
217-
"""Map lowercase .xtx basename -> CBP palette-pointer candidates, read
218-
from the OV*.OVL overlays' texture descriptors.
219-
220-
Each overlay that references a texture by path also holds 28-byte
221-
descriptors {u32 u, v, w-1, h-1, 0, cbp, texslot} whose CBP is the
222-
ground-truth palette location (see FORMATS.md). Descriptor->texture
223-
binding needs runtime tracing, so every texture referenced by an
224-
overlay gets that overlay's whole (deduped) CBP set as candidates —
225-
the per-file plausibility check and scoring pick the right one.
216+
def scan_ovl_descriptors(code_dir: Path
217+
) -> dict[str, list[tuple[int, int, int, int, int]]]:
218+
"""Map lowercase .xtx basename -> texture descriptors (u, v, w, h, cbp)
219+
read from the OV*.OVL overlays that reference the texture by path.
220+
221+
Each overlay holds 28-byte descriptors {u32 u, v, w-1, h-1, 0, cbp,
222+
texslot}: a rect in texture pixels plus the GS block address of the
223+
CLUT that colours it (see FORMATS.md). Descriptor->texture binding is
224+
runtime state, so every texture an overlay references gets the
225+
overlay's whole descriptor list; ``decode_xtx`` keeps the descriptor
226+
groups whose CBP lands on a CLUT tile parked beside the sheet's own
227+
palette and which render their rects more coherently than it does.
226228
"""
227229
import re
228230

229-
index: dict[str, list[int]] = {}
231+
index: dict[str, list[tuple[int, int, int, int, int]]] = {}
230232
if not code_dir.is_dir():
231233
return index
232234
for ovl in sorted(code_dir.glob("OV*.OVL")):
@@ -236,18 +238,31 @@ def scan_ovl_cluts(code_dir: Path) -> dict[str, list[int]]:
236238
re.IGNORECASE)}
237239
if not names:
238240
continue
239-
cbps = []
241+
descs: list[tuple[int, int, int, int, int]] = []
240242
for off in range(0, len(data) - 28, 4):
241243
u, v, w, h, zero, cbp, slot = struct.unpack_from("<7I", data, off)
242244
if (zero == 0 and 0 < cbp < 0x800 and u < 1024 and v < 1024
243-
and 0 < w + 1 <= 1024 and 0 < h + 1 <= 1024 and slot < 32
244-
and cbp not in cbps):
245-
cbps.append(cbp)
246-
if not cbps:
245+
and 0 < w + 1 <= 1024 and 0 < h + 1 <= 1024 and slot < 32):
246+
d = (u, v, w + 1, h + 1, cbp)
247+
if d not in descs:
248+
descs.append(d)
249+
if not descs:
247250
continue
248251
for name in names:
249252
merged = index.setdefault(name, [])
250-
merged += [c for c in cbps if c not in merged]
253+
merged += [d for d in descs if d not in merged]
254+
return index
255+
256+
257+
def scan_ovl_cluts(code_dir: Path) -> dict[str, list[int]]:
258+
"""Map lowercase .xtx basename -> CBP palette-pointer candidates: the
259+
distinct CBPs of ``scan_ovl_descriptors``, in first-seen order."""
260+
index: dict[str, list[int]] = {}
261+
for name, descs in scan_ovl_descriptors(code_dir).items():
262+
cbps = index.setdefault(name, [])
263+
for d in descs:
264+
if d[4] not in cbps:
265+
cbps.append(d[4])
251266
return index
252267

253268

@@ -393,6 +408,7 @@ def lex_materials(lex: bytes) -> list[tuple[int, int, int, int, int, int]]:
393408

394409
def decode_xtx(data: bytes, lex: bytes | list[bytes] = b"",
395410
cbp_candidates: Optional[list[int]] = None,
411+
ovl_rects: Optional[list[tuple[int, int, int, int, int]]] = None,
396412
) -> Optional[tuple[int, int, bytes, str]]:
397413
"""(width, height, RGBA, palette_source) of the composed texture, or None.
398414
@@ -404,6 +420,9 @@ def decode_xtx(data: bytes, lex: bytes | list[bytes] = b"",
404420
``cbp_candidates`` — CLUT base pointers from the consuming overlay's
405421
texture descriptors (``scan_ovl_cluts``); when one of them lands on a
406422
plausible palette tile it is ground truth and wins over guessing.
423+
``ovl_rects`` — the full descriptors (``scan_ovl_descriptors``): for a
424+
model-less UI sheet they are its materials, each rect painted with
425+
its own parked palette (card pips vs court cards vs card back).
407426
palette_source is one of "embedded", "lex", "ovl", "scan", "gray".
408427
"""
409428
if data[:4] != b"XTX\x00" or len(data) < 0x24:
@@ -522,10 +541,13 @@ def decode_xtx(data: bytes, lex: bytes | list[bytes] = b"",
522541
# penalises transparency — right for character atlases, where a wrong
523542
# palette hides garbage behind alpha 0, wrong here: on the casino slot
524543
# frame and window sheet it preferred washed-out grey palettes parked
525-
# for other sub-images. When the first conventional-spot palette is
526-
# a frame-like render (50-95 % opaque) and, penalty aside, about as
527-
# coherent as the ranked winner, keep it — the v1.0.0 first-hit
528-
# behaviour that was right on those sheets.
544+
# for other sub-images. When the first conventional-spot palette
545+
# renders at least half opaque and, penalty aside, about as coherent
546+
# as the ranked winner, keep it — the v1.0.0 first-hit behaviour that
547+
# was right on those sheets. (v1.1.0 required < 95 % opaque, which
548+
# left the fully opaque casino gallery sketches and the slot sheet
549+
# with a monotone palette the coherence score prefers — the ratio is
550+
# biased toward washed-out palettes, see _region_noise.)
529551
if not lexes and base_pal is not None and source in ("ovl", "scan"):
530552
for cand, xy in _scan_for_clut(canvas, clen):
531553
if not _plausible_clut(cand):
@@ -536,8 +558,8 @@ def decode_xtx(data: bytes, lex: bytes | list[bytes] = b"",
536558
# a ranked winner that renders the sheet nearly flat (the
537559
# window sheet's pick was near-black) is no evidence at all
538560
flat_b = _far_contrast(idx, W, base_pal, 0, W, 0, H) < 12.0
539-
if 0.5 <= opq_f < 0.95 and (ratio_f <= ratio_b * 1.25
540-
or flat_b):
561+
if 0.5 <= opq_f and (ratio_f <= ratio_b * 1.25
562+
or flat_b):
541563
base_pal, pal_xy, source = cand, xy, "scan"
542564
break
543565
base_lut = ([bytes(p) for p in base_pal] if base_pal
@@ -584,6 +606,81 @@ def paint(pal, u0, u1, v0, v1):
584606
paint(pal, u0, u1, v0, v1)
585607
painted.append((u0, u1, v0, v1))
586608

609+
# UI sheets (no model) consumed by an overlay: the overlay's texture
610+
# descriptors are the sheet's materials — a rect in texture pixels plus
611+
# the CBP of the CLUT that colours it (the card sheets draw pips, court
612+
# cards and the card back from three parked palettes; the slot sheet
613+
# keys nine UI elements to a column of nine). Every texture the
614+
# overlay references gets the same descriptor list, so a descriptor is
615+
# trusted only as part of a GROUP (all rects sharing one CBP) that
616+
# (1) lands on a CLUT tile parked beside the sheet's own palette —
617+
# chained within 32 px of the base tile, the way artists park a
618+
# sheet's palette strip — and (2) renders its rects clearly smoother
619+
# than the base palette does (median adjacent/far ratio <= 0.9 of the
620+
# base's) without hiding them (median opacity >= half the base's;
621+
# text strips legitimately turn their background transparent).
622+
# Measured on OV11: true groups score 0.31-0.85, the same descriptors
623+
# applied to the wrong sheet 0.99-12 (poker pay table, exchange, BG).
624+
used_tiles: set[tuple[int, int]] = set() # accepted groups' CLUT tiles
625+
if (not lexes and ovl_rects and base_pal is not None
626+
and pal_xy is not None):
627+
groups: dict[tuple[int, int], list[tuple[int, int, int, int]]] = {}
628+
for u, v, w, h, cbp in ovl_rects:
629+
u0, v0, u1, v1 = u, v, min(W, u + w), min(H, v + h)
630+
if u0 >= W or v0 >= H or u1 - u0 < 8 or v1 - v0 < 8:
631+
continue
632+
tile = _cbp_to_xy(cbp, clen)
633+
if tile == pal_xy or not _clut_tile_like(canvas, clen, *tile):
634+
continue
635+
groups.setdefault(tile, []).append((u0, u1, v0, v1))
636+
parked = {pal_xy}
637+
grown = True
638+
while grown:
639+
grown = False
640+
for t in groups:
641+
if t not in parked and any(abs(t[0] - q[0]) <= 32
642+
and abs(t[1] - q[1]) <= 32
643+
for q in parked):
644+
parked.add(t)
645+
grown = True
646+
accepted: list[tuple[int, int, int, int, int, list[bytes]]] = []
647+
for tile, rects in groups.items():
648+
if tile not in parked:
649+
continue
650+
pal = _clut_at(canvas, clen, *tile)
651+
if not _plausible_clut(pal) or pal == base_pal:
652+
continue
653+
ratios: list[float] = []
654+
opqs: list[float] = []
655+
measured = []
656+
for u0, u1, v0, v1 in rects:
657+
mn, mo = _region_noise(idx, W, pal, u0, u1, v0, v1)
658+
bn, bo = _region_noise(idx, W, base_pal, u0, u1, v0, v1)
659+
measured.append((u0, u1, v0, v1, mn, mo))
660+
if mn != float("inf") and bn != float("inf") and bn > 0:
661+
ratios.append(mn / bn)
662+
if bo > 0:
663+
opqs.append(mo / bo)
664+
if not ratios:
665+
continue
666+
ratios.sort()
667+
opqs.sort()
668+
if (ratios[len(ratios) // 2] > 0.9
669+
or (opqs and opqs[len(opqs) // 2] < 0.5)):
670+
continue
671+
for u0, u1, v0, v1, mn, mo in measured:
672+
# a rect the palette renders as one flat opaque colour is
673+
# no evidence either way; leave it to the base palette
674+
if mn == float("inf") and mo > 0.05:
675+
continue
676+
accepted.append(((u1 - u0) * (v1 - v0), u0, u1, v0, v1, pal))
677+
used_tiles.add(tile)
678+
source = "ovl"
679+
accepted.sort(key=lambda a: -a[0]) # big rects first: specific win
680+
for _area, u0, u1, v0, v1, pal in accepted:
681+
paint(pal, u0, u1, v0, v1)
682+
painted.append((u0, u1, v0, v1))
683+
587684
# regions no parsed material covers (their materials hide in the VIF
588685
# vertex streams): per 64px block, if some known palette renders it
589686
# clearly smoother than the base palette, use it — this un-garbles the
@@ -716,7 +813,7 @@ def paint(pal, u0, u1, v0, v1):
716813
if len(cluster) * 32 * 32 > W * H * 0.06:
717814
cluster = set() # parked CLUTs are a sliver of a big canvas;
718815
# on a small texture the "tile" is likelier art — leave it
719-
for tx, ty in cluster:
816+
for tx, ty in cluster | used_tiles:
720817
px0, py0 = tx * 2, ty * 2
721818
for y in range(max(0, py0), min(H, py0 + 32)):
722819
drow = y * W * 4
@@ -1166,7 +1263,13 @@ def bump(key: str, n: int = 1) -> None:
11661263

11671264
tdir = browse / "textures_png"
11681265
srcs = list(_dump_files(dump, (".xtx",)))
1169-
cbp_index = scan_ovl_cluts(browse / "code") # {} without extract --code
1266+
rect_index = scan_ovl_descriptors(browse / "code") # {} w/o --code
1267+
cbp_index: dict[str, list[int]] = {}
1268+
for name, descs in rect_index.items():
1269+
cbps = cbp_index.setdefault(name, [])
1270+
for d in descs:
1271+
if d[4] not in cbps:
1272+
cbps.append(d[4])
11701273
seen: dict[str, str] = {} # sha1 of XTX bytes -> written PNG (dedup)
11711274
log(f"textures: decoding {len(srcs)} .xtx -> PNG "
11721275
f"({len(cbp_index)} with overlay palette hints) ...")
@@ -1194,7 +1297,8 @@ def bump(key: str, n: int = 1) -> None:
11941297
for extra in sorted(companions.get(src, [])):
11951298
lex.append(_load(extra, bump))
11961299
cbps = cbp_index.get(src.name.lower())
1197-
decoded = decode_xtx(data, lex, cbps) if data else None
1300+
rects = rect_index.get(src.name.lower())
1301+
decoded = decode_xtx(data, lex, cbps, rects) if data else None
11981302
if decoded is None:
11991303
bump("textures_undecodable")
12001304
continue

‎docs/FORMATS.md‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,28 @@ two middle 8-entry runs of each 32). PS2 alpha is 7-bit: scale
124124

125125
1. Dedicated 16x16 sub-image in the same file.
126126
2. Paired `.lex` model materials (below).
127-
3. Overlay descriptors (`scan_ovl_cluts`): the consuming overlay addresses
128-
the CLUT by GS block pointer (CBP) in 28-byte texture descriptors
129-
`{u32 u, v, w-1, h-1, 0, CBP, texslot}` — OV11.OVL (casino) references
130-
`data\tanaka\*.xtx` by path string. Mapping: `page = cbp/32`, pages laid
131-
canvas_width/64 per row (64x32 px), block within page via the PSMCT32
132-
block table (8x8 px blocks). Descriptor->texture binding would need
133-
runtime tracing, so each referenced texture gets the overlay's whole CBP
134-
set as candidates and the least-noisy render wins (below).
127+
3. Overlay descriptors (`scan_ovl_descriptors` / `scan_ovl_cluts`): the
128+
consuming overlay addresses the CLUT by GS block pointer (CBP) in
129+
28-byte texture descriptors `{u32 u, v, w-1, h-1, 0, CBP, texslot}` —
130+
OV11.OVL (casino) references `data\tanaka\*.xtx` by path string.
131+
Mapping: `page = cbp/32`, pages laid canvas_width/64 per row (64x32
132+
px), block within page via the PSMCT32 block table (8x8 px blocks).
133+
Descriptor->texture binding would need runtime tracing, so each
134+
referenced texture gets the overlay's whole descriptor list. The
135+
distinct CBPs are base-palette candidates (least-noisy render wins,
136+
below) and the descriptors are the sheet's *materials*: a UI sheet is
137+
multi-palette by design (OV11's card grid draws the 81x121 pip cards
138+
from CBP 1016, the J/Q/K/Joker from 1020 and the card back from
139+
1004; the slot sheet keys nine elements to a column of nine CLUTs at
140+
canvas x=112). `decode_xtx` paints a descriptor *group* (every rect
141+
sharing one CBP) when its tile is CLUT-like and parked within 32 px
142+
(chained) of the sheet's base tile, and the group's rects render with
143+
a median adjacent/far ratio <= 0.9x the base palette's at >= half its
144+
opacity (text strips legitimately go transparent). Measured on OV11:
145+
true groups score 0.31-0.85; the same descriptors applied to a sheet
146+
they do not belong to (poker pay table, exchange, BG — which park
147+
CLUT-like tiles at the same corner) score 0.99-12. Big rects paint
148+
first so specific ones win; accepted tiles are blanked from output.
135149
4. Corner-scan: menu/backdrop textures park CLUT tiles in unused canvas
136150
corners. Scan 16x16 tiles (all raw alpha <= 0x80, >= 64 distinct
137151
colours), conventional spots first: (0,224), (240,240), (224,240),

‎docs/UPDATES.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,58 @@
33
Newest first. Each entry lists what changed, why, and what you have to do
44
to benefit from it.
55

6+
## 2026-10-14 — casino sheets: overlay descriptors as materials
7+
8+
The casino UI sheets were the last textures the decoder got visibly
9+
wrong, and both residuals turned out to be the same fact: a UI sheet is
10+
**multi-palette by design**, and OV11.OVL's texture descriptors say
11+
which parked CLUT colours which rect — the overlay equivalent of a
12+
`.lex` model's materials.
13+
14+
* **Card sheets** (`tanaka/clover`, `dia`, `heart`, `spade`): pip cards,
15+
court cards and the card back each have their own palette parked in
16+
the bottom-right corner. One palette can't render all three — v1.1.0
17+
showed the court cards in a pink cast with correct pips, or correct
18+
court cards with sepia pips. Now every card renders right (black and
19+
red pips, KOS-MOS/Shion/Jr./chaos/Ziggy court art, the red ornate
20+
back).
21+
* **Slot sheet** (`tanaka/base`): keys nine UI elements (reel symbols,
22+
"Gets down" panels, digits, text strips) to a column of nine CLUTs.
23+
v1.1.0 rendered it through one dark monotone palette with half the
24+
sheet blanked; now each element draws from its own.
25+
* **Casino gallery sketches** (`tanaka/02_Y2`, `03_W2`, `08_W3`,
26+
`12_W2`, `12_W3`, `15_W2`, `19_W1`, `19_W3`, `19_W4`): the pencil
27+
concept art the dataviewer shows rendered as near-transparent line
28+
noise because the conventional-spot rule from v1.1.0 only applied to
29+
sheets under 95 % opaque. Lifting the cap gives clean sketches on
30+
white.
31+
32+
How it works (`browse.py`): `scan_ovl_descriptors` keeps the full
33+
`(u, v, w, h, cbp)` descriptors, not just the CBP set. For a sheet with
34+
no `.lex`, `decode_xtx` groups the descriptors by CBP and paints a
35+
group only when its tile is a CLUT parked beside the sheet's own
36+
palette (chained within 32 px) **and** its rects render clearly more
37+
coherently than under the base palette (median ratio <= 0.9, opacity
38+
kept). The second test is what stops the card descriptors — which every
39+
OV11 texture receives — from repainting the poker pay table or the
40+
exchange screen, whose corners park CLUT-like tiles at the same spots
41+
(true groups score 0.31–0.85, foreign ones 0.99–12). `docs/FORMATS.md`
42+
§ Palettes has the numbers.
43+
44+
Still open on the slot sheet: the two reel-symbol strips (texture
45+
x < 224, y < 250) are covered by no descriptor — the reels are drawn by
46+
another code path — so they render under the sheet's base palette with
47+
a red/blue fringe; the column parks three CLUTs no descriptor claims
48+
(canvas (112,0), (112,16), (112,48)) and one of those is probably the
49+
reel palette (per-machine colour variants?).
50+
51+
Verified by rendering all 245 lex-less `.xtx` with the previous and new
52+
code (`docs/research/lexless_render.py`, 35 s instead of a 40-minute
53+
full browse): exactly 15 files change — the 14 above, all better, plus
54+
`texture/fm8011`, which is dither noise under every candidate palette
55+
before and after (its real format is still unknown). Model-bound
56+
textures are untouched (the pass is gated on "no `.lex`").
57+
658
## 2026-09-24 — scene-archive textures: the "undecodable variant" was ARX
759

860
The ~500 embedded XTX hits the sweep reported as *undecodable* (all in

0 commit comments

Comments
 (0)