@@ -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
394409def 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
0 commit comments