Skip to content

Commit cd44de6

Browse files
LinuxJessiclaude
andcommitted
Texture decode: UI sheets keep their conventional-spot palette
The candidate ranking's transparency penalty (right for character atlases) made lex-less UI sheets with legitimate transparent cut-outs lose their own corner-spot CLUT to washed-out palettes parked for other sub-images: the casino slot frame rendered in a purple tint, the window sheet near-black, the pay table teal-striped. v1.0.0's first-hit rule had these right. For textures without a .lex, keep the first conventional-spot palette when it renders 50-95% opaque and is, penalty aside, about as coherent as the ranked winner (or the winner renders the sheet nearly flat). Validated against every lex-less texture on the retail disc: exactly six change, all for the better (slot_2, poker_2, w1, window, card_Rare, fm8009). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent d73b898 commit cd44de6

3 files changed

Lines changed: 76 additions & 4 deletions

File tree

‎browse.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,24 @@ def _rgba_noise(buf, stride: int, u0: int, u1: int, v0: int, v1: int) -> float:
321321
return (adj / adjn) / fbar
322322

323323

324+
def _far_contrast(idx: bytearray, W: int, pal: list[bytes],
325+
u0: int, u1: int, v0: int, v1: int) -> float:
326+
"""Mean L1 RGB distance between opaque pixels 16 apart (rows sampled)
327+
under a palette — the absolute contrast the coherence ratio divides
328+
by. Near zero means the palette renders the region as a flat sheet."""
329+
far = farn = 0
330+
for v in range(v0, v1, 4):
331+
irow = v * W
332+
for u in range(u0, u1 - 16, 2):
333+
p = pal[idx[irow + u]]
334+
q = pal[idx[irow + u + 16]]
335+
if p[3] and q[3]:
336+
far += (abs(p[0] - q[0]) + abs(p[1] - q[1])
337+
+ abs(p[2] - q[2]))
338+
farn += 1
339+
return far / farn if farn else 0.0
340+
341+
324342
def _candidate_score(idx: bytearray, W: int, H: int, pal: list[bytes]) -> float:
325343
"""Whole-image score for a BASE-palette candidate: coherence ratio plus
326344
a stiff penalty for transparency, so a palette cannot win by hiding
@@ -498,6 +516,30 @@ def decode_xtx(data: bytes, lex: bytes | list[bytes] = b"",
498516
tried += 1
499517
if tried >= 24 or best < 0.45:
500518
break
519+
# UI sheets (no model): the artists park the sheet's own CLUT at one of
520+
# the conventional corner spots, and the sheet often has transparent
521+
# cut-outs (slot-reel windows, the window frame). The ranking above
522+
# penalises transparency — right for character atlases, where a wrong
523+
# palette hides garbage behind alpha 0, wrong here: on the casino slot
524+
# 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.
529+
if not lexes and base_pal is not None and source in ("ovl", "scan"):
530+
for cand, xy in _scan_for_clut(canvas, clen):
531+
if not _plausible_clut(cand):
532+
continue
533+
if cand != base_pal:
534+
ratio_f, opq_f = _region_noise(idx, W, cand, 0, W, 0, H)
535+
ratio_b, _ = _region_noise(idx, W, base_pal, 0, W, 0, H)
536+
# a ranked winner that renders the sheet nearly flat (the
537+
# window sheet's pick was near-black) is no evidence at all
538+
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):
541+
base_pal, pal_xy, source = cand, xy, "scan"
542+
break
501543
base_lut = ([bytes(p) for p in base_pal] if base_pal
502544
else [bytes((i, i, i, 255)) for i in range(256)])
503545

‎docs/FORMATS.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,14 @@ two middle 8-entry runs of each 32). PS2 alpha is 7-bit: scale
138138
(176,240), (112,64), (128,0); then the 16px grid bottom-right first;
139139
then the 8px grid (CBPs can address half-block-aligned tiles).
140140

141+
For textures without a `.lex` (UI sheets) the first conventional-spot
142+
candidate wins over the ranked one when it renders 50–95 % opaque and
143+
is, transparency penalty aside, about as coherent as the winner (or the
144+
winner renders the sheet nearly flat): frame-like sheets park their own
145+
CLUT at a corner spot, and the penalty below otherwise hands them a
146+
washed-out palette meant for another sub-image (casino slot frame,
147+
window, pay table; 2026-09-25).
148+
141149
Choosing between palette candidates uses a noise metric
142150
(`_region_noise`): mean L1 RGB distance between horizontally adjacent
143151
opaque pixels — the correct palette renders coherent art (low), a wrong

‎docs/UPDATES.md‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,31 @@ compressed stream, while its pixels do not exist until decompression.
3737
is the ARX bit stream (the entry-level sweep works because the hair
3838
words are stream literals).
3939

40-
## 2026-09-25 — v1.1.1: casino slot regression, block rescue gated
41-
42-
The v1.1.0 release still repainted one tile of the casino slot machine
40+
## 2026-09-25 — v1.1.0 verification pass: casino palettes and rescues
41+
42+
Proving issue #1 against the July build (v1.0.0 renders decoded from
43+
the tagged `browse.py`) turned up three things the interim decoders
44+
had got wrong on the lex-less UI sheets. All folded into v1.1.0.
45+
46+
### UI sheets keep their conventional-spot palette
47+
48+
The candidate ranking penalises transparency (right for character
49+
atlases, where a wrong palette hides garbage behind alpha 0). UI frames
50+
legitimately have transparent cut-outs — slot-reel windows, the window
51+
frame — so on those sheets the ranking preferred washed-out grey
52+
palettes parked for other sub-images over the sheet's own CLUT at a
53+
conventional corner spot, which v1.0.0's first-hit rule had picked
54+
correctly. For textures without a `.lex`, the first conventional-spot
55+
palette is now kept when it renders 50–95 % opaque and is, penalty
56+
aside, about as coherent as the ranked winner (or the winner renders
57+
the sheet nearly flat). Changes exactly six sheets, all for the
58+
better: `tanaka/slot_2` (slot frame), `poker_2` (pay table), `w1`
59+
(neon reel labels), `window`, `carddata/card_Rare` (rarity labels),
60+
`texture/fm8009` (the Elsa deck map).
61+
62+
### Block-rescue pass gated
63+
64+
The interim build still repainted one tile of the casino slot machine
4365
(`tanaka/slot_1`, an orange square) that v1.0.0 rendered black. Cause:
4466
the 64-px **block-rescue** pass (a wrong palette "explains" a near-black
4567
noise block better than the base palette). Checked across every
@@ -58,7 +80,7 @@ libraries (`simajiri/esd/eve017.esd`, `eve107.esd`, `boss0185.esd`,
5880
`scene/cf0680.a`) as PSMT4 from the character VRAM slot with CLUT
5981
pointers 14400/14401 — palettes those effects bring themselves.
6082

61-
### Casino regression fixed (raw-CT32 pass gated)
83+
### Raw-CT32 pass gated
6284

6385
Verifying issue #1 against the July build turned up a regression from
6486
the 2026-08-21 decoder: the raw-CT32 pass (which draws true-colour

0 commit comments

Comments
 (0)