Repository navigation
Expand file tree
/
Copy pathbrowse.py
More file actions
1861 lines (1711 loc) · 83.1 KB
/
Copy pathbrowse.py
File metadata and controls
1861 lines (1711 loc) · 83.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""browse.py — convert extracted assets into immediately-viewable formats.
The Episode I counterpart of the Episode III kit's ``browse_bundle.py``:
after ``extract`` has produced ``OUTDIR/dump/``, this builds a sibling
``OUTDIR/browse/`` tree you can actually look at and listen to.
* ``textures`` — decode ``.xtx`` to PNG (pure Python, stdlib zlib).
XS1's ``.xtx`` is a **virtual GS memory dump**, not a plain image (format
understanding owed to Lakuwu's xenotool). Header: u32 magic, u32 total
size, u32 sub-image count, u32 header table offset; then one 20-byte
record per sub-image: ``u16 width, u16 buffer_width, u16 height, u16 pad,
u32 gs_offset, u32 size, u32 file_addr``. Each sub-image (32-byte
sub-header, then raster CT32 rows) is composed onto a CT32 canvas —
256 or 512 pixels wide per ``buffer_width`` 4 / 8 — at the page position
encoded by ``gs_offset`` (4096-byte GS blocks, ``buffer_width/2`` per
row). The composed canvas is then unswizzled as one big **PSMT8 8-bpp
indexed** image at double the CT32 dimensions. 16x16 sub-images are
256-entry CSM1 palettes (two middle 8-entry runs of each 32 swapped).
Palette sources, in priority order: embedded palette sub-image, paired
``.lex`` material, the consuming overlay's texture descriptors
(``scan_ovl_cluts``; needs ``extract --code``), corner scan; candidates
and per-material repaints are ranked by render coherence
(``_region_noise``) and the chosen CLUT tile is blanked from the output
(see docs/FORMATS.md and GitHub issue #1). Files with no palette are written as grayscale
index images. PS2 alpha is 7-bit (128 = opaque) and is scaled
``min(a*2, 255)``. ARX-compressed ``.xtx`` are decompressed transparently.
After the standalone ``.xtx``, every other dump file is swept for
**embedded** XTX blobs (validated by header: u32 total size, sub-image
count 1-64, in-bounds header table). Effect libraries (``.esd``/``.esp``),
scene archives (``.a``), battle data (``.bin``) and the NLNK/NBGL/NBXX UI
containers (``.npr``/``.rbg``/``.bxx``) carry ~3,000 textures this way —
more than the standalone set. Scene archives, ``.fpk`` packs and ``.arc``
bundles keep their members **ARX-compressed in place**; those are
decompressed first (``iter_arx_containers``) and the XTX inside are
paletted by the ``lex`` models packed beside them, nearest member first.
Blobs byte-identical to an already-decoded texture are recorded as
duplicates, not re-written; the sweep is logged per-carrier in
``browse/embedded_textures.csv`` (``packed`` = "arx" for members that
only exist after decompression).
* ``audio`` — decode ``.vds``/``.vdm`` streamed audio to 16-bit WAV.
The streams are headerless PS2 SPU ADPCM, **stereo, block-interleaved
every 0x400 bytes** (64 frames per channel per block; verified by
channel-envelope correlation ~0.95 at exactly that granularity). The
sample rate is 48000 Hz — the constant the scene classes pass to
``xeno.Sound.streamPlay`` next to the stream's file id (lifted with the
``classes`` command). Genuinely mono streams are detected per file and
kept mono.
* ``battle_audio`` — carve the battle voice/SE banks
(``yamamoto/snd/sed/*.bin``) into per-sample WAVs.
Each ``.bin`` wraps alternating ``seds``/``swdm`` chunks behind a small
offset table (grammar in docs/FORMATS.md); the ``swdm`` chunks are
ordinary wave banks, decoded at each sample's **native rate** derived
from its base_pitch. Stereo halves (``X.aif.L/R``, ``X_L/R``, ``X.L/R``)
are stitched into one stereo file.
* ``images`` — copy ``.jpg`` straight across, and unpack ``PS2ICON3D``
``.res`` resources (the HDD-install bundle) into their sections: the
boot CNF, the ``icon.sys`` metadata and the PS2 memory-card ``.ico``
3D icon model.
* ``text`` — copy ``.txt`` (recoded Shift-JIS -> UTF-8), plus a sniff pass
over ``.dat``/``.info``/``.lst``/``.uml``/``.res``/``.esp``: files that
are mostly printable (NUL-separated string tables like the item
descriptions in ``evtitem.dat`` or the casino dialogue in ``CASINO.res``)
are exported as readable ``*.strings.txt``.
* ``movies`` — transcode ``.pss``/``.ipu`` (MPEG-2 PS / PS2 IPU) to H.264
MP4 **via ffmpeg when one can be found** (bundled tools/, PATH, common
install dirs — same probe order as the Episode III kit). Without ffmpeg
the kind is skipped with a note; raw ``.pss`` files also play directly
in VLC / ffplay.
"""
from __future__ import annotations
import os
import shutil
import struct
import subprocess
import sys
import wave
import zlib
from pathlib import Path
from typing import Iterable, Optional
import arx
ALL_KINDS = ("textures", "models", "audio", "banks", "battle_audio", "images", "text", "movies")
VOICE_RATE = 48000 # from xeno.Sound.streamPlay(id, 48000, ...) in the .evt scripts
# ---------------------------------------------------------------------------
# PNG writer (stdlib only)
# ---------------------------------------------------------------------------
def _png_chunk(tag: bytes, payload: bytes) -> bytes:
return (struct.pack(">I", len(payload)) + tag + payload
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
def png_bytes(width: int, height: int, rgba: bytes) -> bytes:
raw = bytearray()
stride = width * 4
for y in range(height):
raw.append(0) # filter: none
raw += rgba[y * stride : (y + 1) * stride]
return (
b"\x89PNG\r\n\x1a\n"
+ _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
+ _png_chunk(b"IDAT", zlib.compress(bytes(raw), 6))
+ _png_chunk(b"IEND", b"")
)
def write_png(path: Path, width: int, height: int, rgba: bytes) -> None:
path.write_bytes(png_bytes(width, height, rgba))
# ---------------------------------------------------------------------------
# XTX -> PNG
# ---------------------------------------------------------------------------
_ALPHA_SCALE = bytes(min(a * 2, 255) for a in range(256))
def _plausible_clut(pal: Optional[list[bytes]]) -> bool:
"""Reject palette tiles that are clearly not palettes (empty canvas,
flat fills). Material palette pointers describe *runtime VRAM* slots,
which usually — but not always — match the tile's position in the file
canvas; a pointer into unloaded space reads back near-empty."""
if pal is None:
return False
distinct = len({p[:3] for p in pal})
return distinct >= 16 and any(p[3] for p in pal)
def _clut_at(canvas: bytearray, clen: int, palx: int, paly: int) -> Optional[list[bytes]]:
"""Read a 256-entry CSM1 palette from the CT32 canvas at (palx, paly)."""
if palx + 16 > clen or paly + 16 > clen:
return None
pal = []
for ey in range(16):
row = ((paly + ey) * clen + palx) * 4
for ex in range(16):
p = canvas[row + ex * 4 : row + ex * 4 + 4]
pal.append(bytes((p[0], p[1], p[2], min(p[3] * 2, 255))))
for g in range(8): # CSM1 storage order -> logical order
for j in range(8):
k, m = g * 32 + 8 + j, g * 32 + 16 + j
pal[k], pal[m] = pal[m], pal[k]
return pal
def _clut_tile_like(canvas: bytearray, clen: int, px: int, py: int) -> bool:
"""Does the 16x16 tile at (px, py) look like CLUT data? (all raw alpha
<= 0x80 — GS 7-bit — and a rich colour count)."""
if px < 0 or py < 0 or px + 16 > clen or py + 16 > clen:
return False
distinct = set()
for ey in range(16):
row = ((py + ey) * clen + px) * 4
for ex in range(16):
r, g, b, a = canvas[row + ex * 4 : row + ex * 4 + 4]
if a > 0x80:
return False
distinct.add((r, g, b))
return len(distinct) >= 64
def _scan_for_clut(canvas: bytearray, clen: int
) -> Iterable[tuple[list[bytes], tuple[int, int]]]:
"""Yield candidate palette tiles of a canvas with no other palette source.
Menu/backdrop textures (casino, UI, dev-folder art) park their CLUT as a
16x16 CT32 tile in an unused corner of the same canvas; the consuming
code (e.g. OV11.OVL's texture descriptors) addresses it by GS block
pointer. Standalone we scan for it: raw alpha <= 0x80 throughout and a
rich colour count, trying the conventional spots first, then all
16px-aligned tiles bottom-right first, then the finer 8px grid (CBPs
can address half-block tiles). Yields (palette, (tile_x, tile_y)) in
preference order; the caller keeps the first that renders sanely.
"""
spots = [(0, 224), (240, 240), (224, 240), (176, 240), (112, 64), (128, 0)]
spots += [(x, y) for y in range(clen - 16, -1, -16)
for x in range(clen - 16, -1, -16)]
spots += [(x, y) for y in range(clen - 16, -1, -8)
for x in range(clen - 16, -1, -8) if x % 16 or y % 16]
for px, py in spots:
if _clut_tile_like(canvas, clen, px, py):
yield _clut_at(canvas, clen, px, py), (px, py)
# PSMCT32 block layout within a 64x32-pixel GS page: 8 cols x 4 rows of
# 8x8-pixel blocks, in this (non-raster) order.
_PSMCT32_BLOCKS = (
(0, 1, 4, 5, 16, 17, 20, 21),
(2, 3, 6, 7, 18, 19, 22, 23),
(8, 9, 12, 13, 24, 25, 28, 29),
(10, 11, 14, 15, 26, 27, 30, 31),
)
_BLOCK_XY = {blk: (c * 8, r * 8)
for r, row in enumerate(_PSMCT32_BLOCKS)
for c, blk in enumerate(row)}
def _cbp_to_xy(cbp: int, clen: int) -> tuple[int, int]:
"""CLUT base pointer (GS block address) -> pixel coords in the canvas."""
pages_per_row = clen // 64
page, bip = cbp // 32, cbp % 32
bx, by = _BLOCK_XY[bip]
return (page % pages_per_row) * 64 + bx, (page // pages_per_row) * 32 + by
def scan_ovl_descriptors(code_dir: Path
) -> dict[str, list[tuple[int, int, int, int, int]]]:
"""Map lowercase .xtx basename -> texture descriptors (u, v, w, h, cbp)
read from the OV*.OVL overlays that reference the texture by path.
Each overlay holds 28-byte descriptors {u32 u, v, w-1, h-1, 0, cbp,
texslot}: a rect in texture pixels plus the GS block address of the
CLUT that colours it (see FORMATS.md). Descriptor->texture binding is
runtime state, so every texture an overlay references gets the
overlay's whole descriptor list; ``decode_xtx`` keeps the descriptor
groups whose CBP lands on a CLUT tile parked beside the sheet's own
palette and which render their rects more coherently than it does.
"""
import re
index: dict[str, list[tuple[int, int, int, int, int]]] = {}
if not code_dir.is_dir():
return index
for ovl in sorted(code_dir.glob("OV*.OVL")):
data = ovl.read_bytes()
names = {m.group(1).lower().rsplit(b"\\", 1)[-1].decode()
for m in re.finditer(rb"data\\([\x20-\x7e]{1,96}?\.xtx)", data,
re.IGNORECASE)}
if not names:
continue
descs: list[tuple[int, int, int, int, int]] = []
for off in range(0, len(data) - 28, 4):
u, v, w, h, zero, cbp, slot = struct.unpack_from("<7I", data, off)
if (zero == 0 and 0 < cbp < 0x800 and u < 1024 and v < 1024
and 0 < w + 1 <= 1024 and 0 < h + 1 <= 1024 and slot < 32):
d = (u, v, w + 1, h + 1, cbp)
if d not in descs:
descs.append(d)
if not descs:
continue
for name in names:
merged = index.setdefault(name, [])
merged += [d for d in descs if d not in merged]
return index
def scan_ovl_cluts(code_dir: Path) -> dict[str, list[int]]:
"""Map lowercase .xtx basename -> CBP palette-pointer candidates: the
distinct CBPs of ``scan_ovl_descriptors``, in first-seen order."""
index: dict[str, list[int]] = {}
for name, descs in scan_ovl_descriptors(code_dir).items():
cbps = index.setdefault(name, [])
for d in descs:
if d[4] not in cbps:
cbps.append(d[4])
return index
def _region_noise(idx: bytearray, W: int, pal: list[bytes],
u0: int, u1: int, v0: int, v1: int) -> tuple[float, float]:
"""(coherence ratio, opaque fraction) of a region rendered under a
palette. The ratio is the mean L1 RGB distance of horizontally adjacent
opaque pixels divided by the same for pixels 16 apart (rows sampled).
Real art is smooth up close but varied at distance — a correct palette
scores well under 1; a wrong palette renders dither noise, adjacent ~=
far, ~1 (the exact garbling of GitHub issue #1). Being a ratio it
cannot be gamed by washed-out low-contrast palettes the way an absolute
noise measure can. A palette that hides the region (mostly transparent)
or flattens it (no distance-16 variation) gets ratio inf. Callers
choosing a BASE palette must also weigh the opaque fraction: transparent
pixels are excluded from the ratio, so a wrong palette that maps garbled
regions to alpha 0 would otherwise look spuriously coherent."""
adj = adjn = far = farn = opaque = count = 0
for v in range(v0, v1, 2):
irow = v * W
row = [pal[idx[irow + u]] for u in range(u0, u1)]
n = len(row)
for i, p in enumerate(row):
count += 1
if not p[3]:
continue
opaque += 1
if i + 1 < n and row[i + 1][3]:
q = row[i + 1]
adj += (abs(p[0] - q[0]) + abs(p[1] - q[1])
+ abs(p[2] - q[2]))
adjn += 1
if i + 16 < n and row[i + 16][3]:
q = row[i + 16]
far += (abs(p[0] - q[0]) + abs(p[1] - q[1])
+ abs(p[2] - q[2]))
farn += 1
opq = opaque / count if count else 0.0
if not count or opq < 0.3 or not adjn or not farn:
return float("inf"), opq
fbar = far / farn
if fbar < 2.0:
return float("inf"), opq # flat render carries no information
return (adj / adjn) / fbar, opq
def _rgba_noise(buf, stride: int, u0: int, u1: int, v0: int, v1: int) -> float:
"""Coherence ratio of a region of an RGBA buffer (same adjacent÷16-apart
measure as ``_region_noise``, but on already-rendered pixels)."""
adj = adjn = far = farn = 0
for v in range(v0, v1, 2):
row = v * stride
for u in range(u0, u1):
o = (row + u) * 4
if not buf[o + 3]:
continue
r, g, b = buf[o], buf[o + 1], buf[o + 2]
if u + 1 < u1 and buf[o + 7]:
adj += (abs(r - buf[o + 4]) + abs(g - buf[o + 5])
+ abs(b - buf[o + 6]))
adjn += 1
if u + 16 < u1 and buf[o + 67]:
far += (abs(r - buf[o + 64]) + abs(g - buf[o + 65])
+ abs(b - buf[o + 66]))
farn += 1
if not adjn or not farn:
return float("inf")
fbar = far / farn
if fbar < 2.0:
return float("inf")
return (adj / adjn) / fbar
def _far_contrast(idx: bytearray, W: int, pal: list[bytes],
u0: int, u1: int, v0: int, v1: int) -> float:
"""Mean L1 RGB distance between opaque pixels 16 apart (rows sampled)
under a palette — the absolute contrast the coherence ratio divides
by. Near zero means the palette renders the region as a flat sheet."""
far = farn = 0
for v in range(v0, v1, 4):
irow = v * W
for u in range(u0, u1 - 16, 2):
p = pal[idx[irow + u]]
q = pal[idx[irow + u + 16]]
if p[3] and q[3]:
far += (abs(p[0] - q[0]) + abs(p[1] - q[1])
+ abs(p[2] - q[2]))
farn += 1
return far / farn if farn else 0.0
def _candidate_score(idx: bytearray, W: int, H: int, pal: list[bytes]) -> float:
"""Whole-image score for a BASE-palette candidate: coherence ratio plus
a stiff penalty for transparency, so a palette cannot win by hiding
hard-to-explain pixels behind alpha 0. Lower is better; inf = reject."""
ratio, opq = _region_noise(idx, W, pal, 0, W, 0, H)
return ratio + max(0.0, 0.95 - opq) * 2.0
def lex_materials(lex: bytes) -> list[tuple[int, int, int, int, int, int]]:
"""(palx, paly, umin, umax, vmin, vmax) per material of a .lex model —
the mesh-header materials AND the material blocks spliced into the VIF
vertex streams (``lex.parse_lex``; a mesh switches material mid-stream:
Cherenkov's jacket mesh draws its sleeves from a second palette that
only the in-stream block names). True-colour materials (pal 0xFF) are
skipped — they have no palette. Falls back to the header-only read if
the stream parse fails."""
if lex[:4] != b"lex\x00" or len(lex) < 0xB0:
return []
try:
import lex as lexmod
model = lexmod.parse_lex(lex)
mats = []
for m in model.materials:
if not m.has_texture or m.true_color:
continue
pal_hi, pal_lo = m.pal >> 4, m.pal & 0xF
palx = (pal_hi % 2) * 256 + (pal_lo // 2) * 32 + (m.pal2 >> 7) * 16
paly = (pal_hi // 2) * 32 + (pal_lo % 2) * 16
mats.append((palx, paly, m.umin, m.umax, m.vmin, m.vmax))
if mats:
return mats
except Exception:
pass
return _lex_header_materials(lex)
def lex_truecolor_rects(lex: bytes) -> list[tuple[int, int, int, int]]:
"""(umin, umax, vmin, vmax) in CANVAS pixels of every true-colour
(pal 0xFF) material of a .lex — regions stored as raw CT32 on the
canvas (hair sheets), to be drawn as such instead of through a palette."""
if lex[:4] != b"lex\x00":
return []
try:
import lex as lexmod
model = lexmod.parse_lex(lex)
except Exception:
return []
# only the 0xFF UV encoding (canvas-coordinate rects: the character
# hair sheets); the 0x0A family used by map models gives texel-space
# rects that mis-scale here (MC_SYU01's skyline became noise), and the
# 0x7F eyelash sheets are 4bpp, not CT32
return [(m.umin, m.umax, m.vmin, m.vmax, m.uv_type == 0x7F) for m in model.materials
if m.true_color and m.has_texture and m.umax > m.umin
and m.uv_type in (0xFF, 0x7F)]
def _lex_header_materials(lex: bytes) -> list[tuple[int, int, int, int, int, int]]:
"""Header-only material read (fixed-offset mesh headers: palette byte at
+0x125, UV info at +0x130 — layout from xenotool's lex_file.h)."""
(nmesh,) = struct.unpack_from("<I", lex, 0x44)
if not (0 < nmesh <= 4096):
return []
mats = []
for i in range(nmesh):
off = 0xB0 + 4 * i
if off + 4 > len(lex):
break
(addr,) = struct.unpack_from("<I", lex, off)
if addr + 0x190 > len(lex):
continue
pal2, pal = lex[addr + 0x124], lex[addr + 0x125]
if pal == 0xFF:
continue
t = lex[addr + 0x130]
b = lex[addr + 0x131 : addr + 0x140]
if t == 0x00:
continue
if t == 0xFF:
w, h = b[0] & 0xF, b[2] >> 4
umin = b[1] * 4
vmin = (b[4] & 0xF) * 64 + (b[3] >> 7) * 32
umax, vmax = umin + (w + 1) * 16, vmin + (h + 1) * 16
else: # 0x0a and friends
umin = (b[0] & 0x3F) << 4
vmin = b[2]
umax = ((b[1] << 2) | (b[0] >> 6)) + 1
vmax = ((b[4] << 6) | (b[3] >> 2)) + 1
pal_hi, pal_lo = pal >> 4, pal & 0xF
palx = (pal_hi % 2) * 256 + (pal_lo // 2) * 32 + (pal2 >> 7) * 16
paly = (pal_hi // 2) * 32 + (pal_lo % 2) * 16
mats.append((palx, paly, umin, umax, vmin, vmax))
return mats
def decode_xtx(data: bytes, lex: bytes | list[bytes] = b"",
cbp_candidates: Optional[list[int]] = None,
ovl_rects: Optional[list[tuple[int, int, int, int, int]]] = None,
) -> Optional[tuple[int, int, bytes, str]]:
"""(width, height, RGBA, palette_source) of the composed texture, or None.
``lex`` — the paired model file, when there is one; its materials say
which palette tile in the canvas colours which UV region. A LIST of lex
blobs merges materials from companion models that share the atlas
(``kosmos_face.lex`` has no ``kosmos_face.xtx`` — its meshes bind
palettes onto ``kosmos.xtx``'s canvas, e.g. the red eye tile).
``cbp_candidates`` — CLUT base pointers from the consuming overlay's
texture descriptors (``scan_ovl_cluts``); when one of them lands on a
plausible palette tile it is ground truth and wins over guessing.
``ovl_rects`` — the full descriptors (``scan_ovl_descriptors``): for a
model-less UI sheet they are its materials, each rect painted with
its own parked palette (card pips vs court cards vs card back).
palette_source is one of "embedded", "lex", "ovl", "scan", "gray".
"""
if data[:4] != b"XTX\x00" or len(data) < 0x24:
return None
_total, count, hdr_addr = struct.unpack_from("<III", data, 4)
if not (1 <= count <= 64) or hdr_addr + 20 * count > len(data):
return None
subs = []
for i in range(count):
base = hdr_addr + 20 * i
w, bufw, h = struct.unpack_from("<HHH", data, base)
gs_off, _size, addr = struct.unpack_from("<III", data, base + 8)
if addr + 32 + w * h * 4 > len(data):
return None
subs.append((w, bufw, h, gs_off, addr))
bufw = subs[0][1] or 8
clen = {4: 256, 8: 512}.get(bufw)
if clen is None:
return None
canvas = bytearray(clen * clen * 4) # CT32 canvas, clen x clen pixels
placed: list[tuple[int, int, int, int]] = [] # (w, h, x0, y0) per sub
for w, _, h, gs_off, addr in subs:
px = data[addr + 32 : addr + 32 + w * h * 4]
# sub-image placement in the canvas: gs_off counts 32 per pixel
# column and clen*32 per 32-pixel row band — i.e. 4096 per 128-px
# column step, 65536 (clen 512) / 32768 (clen 256) per 128-row
# step. Fitted on all 166 multi-sub layouts on the retail disc:
# the 42 two-part character atlases (andrew, jr1, momo2, virgil1
# …: second part at 8192/12288 -> x 256/384) only decode under
# their own .lex palettes with this placement, and the parked
# 16x16 CLUT tiles at 4096 land at x=128, where the .lex
# materials point (the old 64-px block formula put them at 64 —
# the "hama.lex points at (128,0) but the tile is at (64,0)"
# caveat was this bug, not a VRAM-vs-file mismatch).
y0 = (gs_off // (clen * 32)) * 32
x0 = (gs_off % (clen * 32)) // 32
if x0 + w > clen or y0 + h > clen:
continue
for y in range(h):
dst = ((y0 + y) * clen + x0) * 4
canvas[dst : dst + w * 4] = px[y * w * 4 : (y + 1) * w * 4]
placed.append((w, h, x0, y0))
# 16x16 sub-images are palette tiles: composed into the canvas (materials
# address them there) but not part of the visible image extent
pal_tiles = [(x0, y0) for w, h, x0, y0 in placed
if w == 16 and h == 16 and count > 1]
if placed and len(pal_tiles) == len(placed):
# nothing left as art — a tiny all-16x16 sprite file (e.g. the 1P/2P
# indicators): keep only tiles that truly read as CLUT data (first
# one if all do), and let the rest render as image
looks = [(x0, y0) for x0, y0 in pal_tiles
if _clut_tile_like(canvas, clen, x0, y0)]
pal_tiles = (looks if len(looks) < len(pal_tiles) else looks[:1])
embedded_pal: Optional[list[bytes]] = None
if pal_tiles:
embedded_pal = _clut_at(canvas, clen, *pal_tiles[0])
max_x = max_y = 0
for w, h, x0, y0 in placed:
if (x0, y0) in pal_tiles:
continue
max_x = max(max_x, (x0 + w) * 2)
max_y = max(max_y, (y0 + h) * 2)
if not max_x or not max_y:
return None
# The canvas holds PSMT8 indices swizzled into CT32; unswizzle the
# cropped region ("unswizzle8", the widely shared PS2 routine).
W, H, tw = max_x, max_y, clen * 2
idx = bytearray(W * H)
for y in range(H):
block_row = (y & ~0xF) * tw
swap_selector = (((y + 2) >> 2) & 1) * 4
col_row = ((((y & ~3) >> 1) + (y & 1)) & 7) * tw * 2
byte_y = (y >> 1) & 1
drow = y * W
for x in range(W):
idx[drow + x] = canvas[
block_row + (x & ~0xF) * 2 + col_row
+ ((x + swap_selector) & 7) * 4 + byte_y + ((x >> 2) & 2)]
lexes = lex if isinstance(lex, list) else ([lex] if lex else [])
mats = [m for lx in lexes for m in lex_materials(lx)]
# palette selection: embedded tile > lex material > OVL descriptor CBP >
# corner scan > grayscale. pal_xy = the chosen tile's canvas coords when
# we are certain the tile IS a CLUT (so it can be blanked from output).
pal_xy: Optional[tuple[int, int]] = None
source = "gray"
base_pal = embedded_pal
if base_pal is not None:
source = "embedded"
if base_pal is None and mats:
cand = _clut_at(canvas, clen, mats[0][0], mats[0][1])
if _plausible_clut(cand):
base_pal, source = cand, "lex"
if base_pal is None and cbp_candidates:
best = 1.2 # only accept OVL candidates that render coherently
for cbp in cbp_candidates:
px, py = _cbp_to_xy(cbp, clen)
if not _clut_tile_like(canvas, clen, px, py):
continue
cand = _clut_at(canvas, clen, px, py)
if not _plausible_clut(cand):
continue
s = _candidate_score(idx, W, H, cand)
if s < best:
best, base_pal, pal_xy, source = s, cand, (px, py), "ovl"
if base_pal is None:
# best of the first couple dozen scan candidates (the true CLUT is
# near-always parked early in the spot order); first-hit is not
# enough — a wrong tile can look acceptable while a later one is
# clearly better, and vice versa
best, tried = 1.2, 0
for cand, xy in _scan_for_clut(canvas, clen):
if not _plausible_clut(cand):
continue
s = _candidate_score(idx, W, H, cand)
if s < best:
best, base_pal, pal_xy, source = s, cand, xy, "scan"
tried += 1
if tried >= 24 or best < 0.45:
break
# UI sheets (no model): the artists park the sheet's own CLUT at one of
# the conventional corner spots, and the sheet often has transparent
# cut-outs (slot-reel windows, the window frame). The ranking above
# penalises transparency — right for character atlases, where a wrong
# palette hides garbage behind alpha 0, wrong here: on the casino slot
# frame and window sheet it preferred washed-out grey palettes parked
# for other sub-images. When the first conventional-spot palette
# renders at least half opaque and, penalty aside, about as coherent
# as the ranked winner, keep it — the v1.0.0 first-hit behaviour that
# was right on those sheets. (v1.1.0 required < 95 % opaque, which
# left the fully opaque casino gallery sketches and the slot sheet
# with a monotone palette the coherence score prefers — the ratio is
# biased toward washed-out palettes, see _region_noise.)
if not lexes and base_pal is not None and source in ("ovl", "scan"):
for cand, xy in _scan_for_clut(canvas, clen):
if not _plausible_clut(cand):
continue
if cand != base_pal:
ratio_f, opq_f = _region_noise(idx, W, cand, 0, W, 0, H)
ratio_b, _ = _region_noise(idx, W, base_pal, 0, W, 0, H)
# a ranked winner that renders the sheet nearly flat (the
# window sheet's pick was near-black) is no evidence at all
flat_b = _far_contrast(idx, W, base_pal, 0, W, 0, H) < 12.0
if 0.5 <= opq_f and (ratio_f <= ratio_b * 1.25
or flat_b):
base_pal, pal_xy, source = cand, xy, "scan"
break
base_lut = ([bytes(p) for p in base_pal] if base_pal
else [bytes((i, i, i, 255)) for i in range(256)])
out = bytearray(W * H * 4)
for y in range(H):
drow = y * W * 4
irow = y * W
for x in range(W):
out[drow + x * 4 : drow + x * 4 + 4] = base_lut[idx[irow + x]]
def paint(pal, u0, u1, v0, v1):
for v in range(v0, v1):
drow = v * W * 4
irow = v * W
for u in range(u0, u1):
out[drow + u * 4 : drow + u * 4 + 4] = pal[idx[irow + u]]
# per-material regions override the base palette — unless the material's
# palette renders its own UV rect clearly noisier than the base palette
# does (material palx/paly are runtime-VRAM slots that only sometimes
# match the file canvas; a plausible-but-wrong tile used to garble
# exactly its UV rect — GitHub issue #1)
painted: list[tuple[int, int, int, int]] = []
pals: list[list[bytes]] = []
for palx, paly, umin, umax, vmin, vmax in mats:
pal = _clut_at(canvas, clen, palx, paly)
if pal is None or not _plausible_clut(pal):
continue
if all(pal != p for p in pals):
pals.append(pal)
if pal == base_pal:
continue
v0, v1 = max(0, vmin), min(H, vmax)
u0, u1 = max(0, umin), min(W, umax)
if v1 <= v0 or u1 <= u0:
continue
mn, _ = _region_noise(idx, W, pal, u0, u1, v0, v1)
bn, _ = (_region_noise(idx, W, base_pal, u0, u1, v0, v1) if base_pal
else (mn, 0.0))
if mn > bn * 1.3:
continue
paint(pal, u0, u1, v0, v1)
painted.append((u0, u1, v0, v1))
# UI sheets (no model) consumed by an overlay: the overlay's texture
# descriptors are the sheet's materials — a rect in texture pixels plus
# the CBP of the CLUT that colours it (the card sheets draw pips, court
# cards and the card back from three parked palettes; the slot sheet
# keys nine UI elements to a column of nine). Every texture the
# overlay references gets the same descriptor list, so a descriptor is
# trusted only as part of a GROUP (all rects sharing one CBP) that
# (1) lands on a CLUT tile parked beside the sheet's own palette —
# chained within 32 px of the base tile, the way artists park a
# sheet's palette strip — and (2) renders its rects clearly smoother
# than the base palette does (median adjacent/far ratio <= 0.9 of the
# base's) without hiding them (median opacity >= half the base's;
# text strips legitimately turn their background transparent).
# Measured on OV11: true groups score 0.31-0.85, the same descriptors
# applied to the wrong sheet 0.99-12 (poker pay table, exchange, BG).
used_tiles: set[tuple[int, int]] = set() # accepted groups' CLUT tiles
if (not lexes and ovl_rects and base_pal is not None
and pal_xy is not None):
groups: dict[tuple[int, int], list[tuple[int, int, int, int]]] = {}
for u, v, w, h, cbp in ovl_rects:
u0, v0, u1, v1 = u, v, min(W, u + w), min(H, v + h)
if u0 >= W or v0 >= H or u1 - u0 < 8 or v1 - v0 < 8:
continue
tile = _cbp_to_xy(cbp, clen)
if tile == pal_xy or not _clut_tile_like(canvas, clen, *tile):
continue
groups.setdefault(tile, []).append((u0, u1, v0, v1))
parked = {pal_xy}
grown = True
while grown:
grown = False
for t in groups:
if t not in parked and any(abs(t[0] - q[0]) <= 32
and abs(t[1] - q[1]) <= 32
for q in parked):
parked.add(t)
grown = True
accepted: list[tuple[int, int, int, int, int, list[bytes]]] = []
for tile, rects in groups.items():
if tile not in parked:
continue
pal = _clut_at(canvas, clen, *tile)
if not _plausible_clut(pal) or pal == base_pal:
continue
ratios: list[float] = []
opqs: list[float] = []
measured = []
for u0, u1, v0, v1 in rects:
mn, mo = _region_noise(idx, W, pal, u0, u1, v0, v1)
bn, bo = _region_noise(idx, W, base_pal, u0, u1, v0, v1)
measured.append((u0, u1, v0, v1, mn, mo))
if mn != float("inf") and bn != float("inf") and bn > 0:
ratios.append(mn / bn)
if bo > 0:
opqs.append(mo / bo)
if not ratios:
continue
ratios.sort()
opqs.sort()
if (ratios[len(ratios) // 2] > 0.9
or (opqs and opqs[len(opqs) // 2] < 0.5)):
continue
for u0, u1, v0, v1, mn, mo in measured:
# a rect the palette renders as one flat opaque colour is
# no evidence either way; leave it to the base palette
if mn == float("inf") and mo > 0.05:
continue
accepted.append(((u1 - u0) * (v1 - v0), u0, u1, v0, v1, pal))
used_tiles.add(tile)
source = "ovl"
accepted.sort(key=lambda a: -a[0]) # big rects first: specific win
for _area, u0, u1, v0, v1, pal in accepted:
paint(pal, u0, u1, v0, v1)
painted.append((u0, u1, v0, v1))
# regions no parsed material covers (their materials hide in the VIF
# vertex streams): per 64px block, if some known palette renders it
# clearly smoother than the base palette, use it — this un-garbles the
# issue-#1 char atlases without touching regions base already explains.
# The candidate pool is the parsed material palettes PLUS every
# CLUT-looking tile parked in the canvas — map atlases (MC_*.xtx) bind
# several parked CLUTs through VIF-stream materials we never parse, so
# the right palette is often in the canvas but absent from ``pals``.
if base_pal is not None:
for cand, _xy in _scan_for_clut(canvas, clen):
if len(pals) >= 14:
break
if _plausible_clut(cand) and all(cand != p for p in pals):
pals.append(cand)
# Model-bound textures only (a .lex exists), like the raw-CT32 pass: on
# lex-less UI sheets every rescue this pass made was wrong (grey blobs
# over the card-tutorial text, a noise strip on the casino menu, an
# orange tile on the slot machine) — checked 2026-09-24 across all 32
# lex-less files where it fired.
if base_pal is not None and len(pals) > 1 and lexes:
for v0 in range(0, H, 64):
v1 = min(H, v0 + 64)
for u0 in range(0, W, 64):
u1 = min(W, u0 + 64)
cov = any(u0 >= pu0 and u1 <= pu1 and v0 >= pv0 and v1 <= pv1
for pu0, pu1, pv0, pv1 in painted)
if cov:
continue
bn, _ = _region_noise(idx, W, base_pal, u0, u1, v0, v1)
if bn == float("inf") or bn < 0.75: # base looks fine; keep it
continue
best_pal, best_n = None, bn
for pal in pals:
if pal == base_pal:
continue
n, _ = _region_noise(idx, W, pal, u0, u1, v0, v1)
if n < best_n:
best_pal, best_n = pal, n
if best_pal is not None and best_n < bn * 0.5:
paint(best_pal, u0, u1, v0, v1)
# fine-grained rescue with TRUSTED palettes only: the parsed materials'
# rects don't cover the whole atlas (more materials hide in the VIF
# vertex streams), but the missing regions are almost always outfit art
# continuing past a parsed rect — rendered by one of the SAME material
# palettes. 32px blocks outside every painted rect, visibly imperfect
# under base (bn > 0.25), repaint when a material palette renders them
# near-perfectly (n < 0.25 and < 0.6*bn — thresholds fitted on the
# KOS-MOS atlas: true fixes measured <= 0.16, false repaints >= 0.51).
if base_pal is not None and mats:
mat_pals: list[list[bytes]] = []
for palx, paly, *_ in mats:
mp = _clut_at(canvas, clen, palx, paly)
if mp is not None and _plausible_clut(mp) \
and all(mp != q for q in mat_pals):
mat_pals.append(mp)
for v0 in range(0, H, 32):
v1 = min(H, v0 + 32)
for u0 in range(0, W, 32):
u1 = min(W, u0 + 32)
if any(u0 >= pu0 and u1 <= pu1 and v0 >= pv0 and v1 <= pv1
for pu0, pu1, pv0, pv1 in painted):
continue
bn, _ = _region_noise(idx, W, base_pal, u0, u1, v0, v1)
if bn <= 0.25 or bn == float("inf"):
continue
best_pal, best_n = None, min(0.25, bn * 0.6)
for mp in mat_pals:
if mp == base_pal:
continue
n, _ = _region_noise(idx, W, mp, u0, u1, v0, v1)
if n < best_n:
best_pal, best_n = mp, n
if best_pal is not None:
paint(best_pal, u0, u1, v0, v1)
# true-colour materials named by the model (pal 0xFF): their rect is a
# raw CT32 region of the canvas — draw it as such, no heuristic needed
# (Albedo's white hair rendered half through a skin palette when the
# per-block noise test below fired on only some of its blocks)
# — guarded by the same coherence comparison as the block pass: two
# map atlases (MC_KAS11, MC_SYU01) declare pal-0xFF rects over
# ordinary paletted art, which reads as noise taken as CT32.
# Eyelash rects (UV type 0x7F) are drawn unconditionally: the sheet is
# a mostly transparent dark alpha gradient the lash polygons stretch
# into strokes, which the coherence/opacity guard would reject.
truecolor = [r for lx in lexes for r in lex_truecolor_rects(lx)]
for cu0, cu1, cv0, cv1, is_lash in truecolor:
cu0, cv0 = max(0, cu0), max(0, cv0)
cu1, cv1 = min(clen, cu1, W // 2), min(clen, cv1, H // 2)
if cu1 <= cu0 or cv1 <= cv0:
continue
if not is_lash:
on = _rgba_noise(out, W, cu0 * 2, cu1 * 2, cv0 * 2, cv1 * 2)
cn = _rgba_noise(canvas, clen, cu0, cu1, cv0, cv1)
if not (cn < on):
continue
# and the region must hold pixels: a rect that is mostly alpha-0
# as CT32 (MC_SYU01's declared rects) is not a hair sheet
opaque = sum(1 for y in range(cv0, cv1, 2) for x in range(cu0, cu1, 2)
if canvas[((y * clen) + x) * 4 + 3])
if opaque * 4 < 0.4 * (cu1 - cu0) * (cv1 - cv0):
continue
for y in range(cv0 * 2, cv1 * 2):
drow = y * W * 4
crow = (y // 2) * clen
for x in range(cu0 * 2, cu1 * 2):
o = (crow + x // 2) * 4
out[drow + x * 4] = canvas[o]
out[drow + x * 4 + 1] = canvas[o + 1]
out[drow + x * 4 + 2] = canvas[o + 2]
out[drow + x * 4 + 3] = _ALPHA_SCALE[canvas[o + 3]]
painted.append((cu0 * 2, cu1 * 2, cv0 * 2, cv1 * 2))
# raw CT32 regions: a canvas can mix 8bpp paletted art with TRUE-COLOUR
# pixel regions (KOS-MOS's hair-strand sheets — issue #1's last holdout).
# No palette can ever render those. Per 64px block: if the current render
# still reads as dither noise (ratio >= 0.9; every correctly-paletted
# block on the KOS-MOS atlas measures below that) but the canvas read
# directly as CT32 is more coherent, draw the block from the canvas —
# each canvas pixel covers 2x2 output pixels.
# Only for model-bound textures (a .lex exists): true-colour regions are
# a character-model feature, and on the lex-less UI sheets the pass
# mis-fired on dithered 8bpp art — the casino slot reels (two indices
# alternating) read as a "coherent" checker when taken as CT32 and were
# repainted blue/yellow (regression vs the July build, found 2026-09-24).
for v0 in (range(0, H, 64) if lexes else ()):
v1 = min(H, v0 + 64)
for u0 in range(0, W, 64):
u1 = min(W, u0 + 64)
on = _rgba_noise(out, W, u0, u1, v0, v1)
if on < 0.9 or on == float("inf"):
continue
cn = _rgba_noise(canvas, clen, u0 // 2, u1 // 2, v0 // 2, v1 // 2)
if cn >= on:
continue
for y in range(v0, v1):
crow = (y // 2) * clen
drow = y * W * 4
for x in range(u0, u1):
o = (crow + x // 2) * 4
out[drow + x * 4] = canvas[o]
out[drow + x * 4 + 1] = canvas[o + 1]
out[drow + x * 4 + 2] = canvas[o + 2]
out[drow + x * 4 + 3] = _ALPHA_SCALE[canvas[o + 3]]
# CLUT tiles are palette data, not art: blank the chosen tile plus the
# connected cluster of palette-looking tiles around it (card sheets etc.
# park a strip of colour-variant CLUTs together) when it falls inside
# the visible extent — the "square of noise in the corner" of issue #1
if pal_xy is not None:
cluster = {pal_xy}
frontier = [pal_xy]
while frontier and len(cluster) <= 16:
cx, cy = frontier.pop()
for dx in (-8, 0, 8):
for dy in (-8, 0, 8):
t = (cx + dx, cy + dy)
if (t not in cluster
and _clut_tile_like(canvas, clen, t[0], t[1])):
cluster.add(t)
frontier.append(t)
if len(cluster) > 16:
# a real palette strip is a handful of tiles; a sprawling
# "cluster" means the flood fill wandered into colourful art
# (card faces, object sprites) — blank only the CLUT itself
cluster = {pal_xy}
if len(cluster) * 32 * 32 > W * H * 0.06:
cluster = set() # parked CLUTs are a sliver of a big canvas;
# on a small texture the "tile" is likelier art — leave it
for tx, ty in cluster | used_tiles:
px0, py0 = tx * 2, ty * 2
for y in range(max(0, py0), min(H, py0 + 32)):
drow = y * W * 4
for x in range(max(0, px0), min(W, px0 + 32)):
out[drow + x * 4 : drow + x * 4 + 4] = b"\x00\x00\x00\x00"
return W, H, bytes(out), source
def iter_embedded_xtx(data: bytes) -> Iterable[tuple[int, bytes]]:
"""Yield (offset, blob) for each plausible XTX embedded in ``data``.
A hit must look like a real header — u32 total size in bounds, sub-image
count 1-64, header table inside the blob — which filters the stray
``XTX\\0`` byte patterns that occur in animation and movie data.
"""
i, n = 0, len(data)
while True:
i = data.find(b"XTX\x00", i)
if i < 0:
return
if i + 16 <= n:
total, count, hdr = struct.unpack_from("<III", data, i + 4)
if 0x24 <= total <= n - i and 1 <= count <= 64 and hdr + 20 * count <= total:
yield i, data[i : i + total]
i += total
continue
i += 4
def iter_arx_containers(data: bytes) -> Iterable[tuple[int, int, bytes]]:
"""Yield ``(offset, span, payload)`` for every ARX container inside ``data``.
Scene archives (``.a``), ``.fpk`` packs, ``.arc`` bundles and a few
``.bin``/``.npr`` files store their members ARX-compressed in place:
a 16-byte header (magic, uncompressed size, container size including
the header, 0), the 30-word LUT, then the bit stream. The members —
XTX textures, the ``lex`` models that bind their palettes, FPK packs,
FL00 event containers — only exist after decompression. Because the
ARX coder passes literal words through verbatim, the *header* of a
packed XTX still reads as ``XTX\0`` + sane sizes inside the stream,
which is exactly the "undecodable in-``.a`` variant" the raw sweep used
to report (528 on the retail disc): callers must skip raw hits that
fall inside a container's span. A hit must carry a sane header and
decompress to exactly the announced size; anything else is skipped.
"""
i, n = 0, len(data)
while True:
i = data.find(arx.MAGIC, i)
if i < 0:
return
if i + 16 <= n:
usize, csize, zero = struct.unpack_from("<III", data, i + 4)
if zero == 0 and usize > 0 and 16 + 30 * 4 < csize <= n - i:
try:
payload = arx.decompress(data[i:i + csize])
except (arx.ARXError, struct.error, ValueError):
payload = b""
if len(payload) == usize:
yield i, csize, payload
i += csize
continue
i += 4
# extensions that never carry embedded XTX art (movies, audio, plain media);
# everything else in the dump gets swept
_NO_XTX_CARRIER = (".xtx", ".pss", ".ipu", ".vds", ".vdm", ".jpg", ".jpeg", ".txt")
# ---------------------------------------------------------------------------
# SPU ADPCM (.vds/.vdm) -> WAV
# ---------------------------------------------------------------------------
_SPU_FILTERS = ((0, 0), (60, 0), (115, -52), (98, -55), (122, -60))
_SIGNED_NIBBLE = tuple(n - 16 if n >= 8 else n for n in range(16))
def decode_spu_adpcm(data: bytes) -> bytes:
"""Decode headerless SPU ADPCM to 16-bit little-endian mono PCM."""
import array
out = array.array("h")
h1 = h2 = 0
nib = _SIGNED_NIBBLE
for base in range(0, len(data) - 15, 16):
hdr = data[base]
shift = hdr & 0x0F
filt = hdr >> 4
if filt > 4 or shift > 12: # invalid frame; keep sync, emit silence
out.extend((0,) * 28)