Fix padded Xbox texture rows and BC2/BC3 colour decoding

This commit is contained in:
chazmm committed 2026-09-08 16:56:20 +01:00
1 parent 7d44d84f19
commit ad992f2001
4 files changed
+94 -4

No files matched your search

+39
View File
@@ -187,3 +187,42 @@ for the user. The normal launcher clears SKATE_DEBUG_FOLIAGE.
For integration with staged map switching, prepare this supplement alongside the For integration with staged map switching, prepare this supplement alongside the
district, before applying sky lighting. Track its entities and assets in the district, before applying sky lighting. Track its entities and assets in the
same map lifetime. The current adapter is in `retail_backdrop.rs`. same map lifetime. The current adapter is in `retail_backdrop.rs`.
### Black texture/lightmap bands (2026-09-08)
The University bark rings and mega-park shark logo expose an exporter defect in
the shared Xbox texture decoder. `XGAddress2DTiledX/Y` returns coordinates in the
padded physical tile. `_untile360` flattened `y * logical_width + x` before
rejecting columns outside the logical width. Padding columns consequently aliased
onto later valid rows, overwriting colour blocks with padding or other tile data.
The NumPy implementation reproduced the scalar bug, including last-write wins.
Both implementations now reject `x >= logical_width` before writing.
The logo's diffuse is `0x2c70170a001d01bb`; its 64x64 DXT1 lightmap is
`0x8b068e5c4f70dd7a`. Re-decoding that lightmap removes the black stripes while
the logo diffuse remains unchanged. Nine lightmaps bound to the spring/fall-tree
bark diffuse textures `0x00007e2903e3870a` and `0x00007dc403e3870a` have the same
corruption. The shared decoder affects colour textures as well: the 64x128 palm
fringe texture `0x00007e3103e3870a` also loses artificial horizontal bands.
This establishes an asset-decoding cause independently of shader lighting.
A separate colour-palette error was corrected in both decoders: DXT3/DXT5 colour
blocks must retain four interpolated colours even when their endpoints are in
ascending order. The old code reused DXT1's three-colour/black branch and merely
made that black opaque. DXT1's transparent palette remains intact. See Microsoft's
[block compression reference](https://learn.microsoft.com/en-us/windows/win32/direct3d10/d3d10-graphics-programming-guide-resources-block-compression)
for the format layouts and separate BC2/BC3 alpha blocks.
The private University repair re-decodes the original RX2 sources and changes
723 of 2,046 texture payloads: 720 DXT1 (logical widths 16/32/64), two DXT5 and
one A8R8G8B8. It preserves the exporter's dedicated B5G6R5 decoder output. Every
original payload was checked against its source cache before replacement;
material definitions and the entire geometry/collision/metadata tail remain
byte-identical. The repaired package passes the offline SKATE reader.
Regression tests cover narrow/wide tile layouts at 2/4/8/16 bytes per unit,
DXT1 transparency, and DXT3/DXT5 reversed colour endpoints with independent alpha,
using both scalar and NumPy paths. All 16 asset-pipeline tests pass. The game was
not launched; visual confirmation remains user-run. Existing exported maps need
their textures regenerated from RX2, since changing the decoder cannot repair
already-baked PNG/RGBA cache files. Fresh conversions use the fix across maps.
@@ -0,0 +1,46 @@
"""Regression fixtures for padded Xbox tiles and BC colour palettes."""
import importlib.util
from pathlib import Path
import struct
import sys
import unittest
from unittest.mock import patch
UTT = Path(__file__).resolve().parents[1] / 'vendor/utt'
sys.path.insert(0, str(UTT))
import rx2_fast
# Exercise the standalone fallback as well as the NumPy implementation.
spec = importlib.util.spec_from_file_location('rx2_scalar_test', UTT / 'rx2_parser.py')
scalar = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {'rx2_fast': None}):
spec.loader.exec_module(scalar)
class TextureDecodeTests(unittest.TestCase):
def test_padding_cannot_overwrite_visible_rows(self):
for width, pitch in ((w, p) for w in (4, 8, 16, 32) for p in (2, 4, 8, 16)):
with self.subTest(width=width, pitch=pitch):
units = max(32*32, 4096//pitch)
raw = bytearray(units*pitch)
for offset in range(units):
x = scalar._x360_tiled_x(offset, width, pitch)
y = scalar._x360_tiled_y(offset, width, pitch)
if x < width and y < 32:
raw[offset*pitch:offset*pitch+pitch] = bytes([1+y])*pitch
expected = b''.join(bytes([1+y])*(width*pitch) for y in range(32))
for decoder in (scalar, rx2_fast):
self.assertEqual(decoder._untile360(raw, width, pitch)[:len(expected)], expected)
def test_bc1_keeps_transparent_palette(self):
colour = struct.pack('>HH', 0x001f, 0xf800) + b'\xff'*4
self.assertEqual(scalar._dxt1_block(colour, True), bytes(64))
self.assertEqual(rx2_fast._decode(colour, 4, 4, 1, False), bytes(64))
def test_bc2_bc3_use_four_colours_with_reversed_endpoints(self):
colour = struct.pack('>HH', 0x001f, 0xf800) + b'\xff'*4
for kind, alpha, block_decoder in ((3, b'\xaa'*8, scalar._dxt3_block),
(5, b'\x00\xaa'+bytes(6), scalar._dxt5_block)):
expected = bytes((170, 0, 85, 170))*16
self.assertEqual(block_decoder(alpha+colour), expected)
self.assertEqual(rx2_fast._decode(alpha+colour, 4, 4, kind, False), expected)
+6 -2
View File
@@ -17,7 +17,9 @@ def _layout(units,width,pitch):
y=(((m//(aligned>>5))<<2)+((t>>(6+log))&1)+((b&2048)>>10))<<3 y=(((m//(aligned>>5))<<2)+((t>>(6+log))&1)+((b&2048)>>10))<<3
y+=(((t&(((pitch<<6)-1)&~31))+((t&15)<<1))>>(3+log))&~1 y+=(((t&(((pitch<<6)-1)&~31))+((t&15)<<1))>>(3+log))&~1
y+=(t&16)>>4 y+=(t&16)>>4
dest=y*width+x;valid=dest<units # Padded tile columns are outside the logical image. Flattening them
# first aliases them onto later rows and overwrites valid blocks.
dest=y*width+x;valid=(x<width)&(dest<units)
# Match scalar last-write ordering even for narrow padded surfaces. # Match scalar last-write ordering even for narrow padded surfaces.
source=np.full(units,-1,dtype=np.int64) source=np.full(units,-1,dtype=np.int64)
np.maximum.at(source,dest[valid],offset[valid]) np.maximum.at(source,dest[valid],offset[valid])
@@ -39,7 +41,9 @@ def _blocks(data,w,h,size):
def _colour(b,transparent): def _colour(b,transparent):
c=b[:,:4].astype(np.uint32);c0=c[:,0]*256+c[:,1];c1=c[:,2]*256+c[:,3] c=b[:,:4].astype(np.uint32);c0=c[:,0]*256+c[:,1];c1=c[:,2]*256+c[:,3]
def rgb(v):return np.stack((((v>>11)&31)*255//31,((v>>5)&63)*255//63,(v&31)*255//31),axis=1) def rgb(v):return np.stack((((v>>11)&31)*255//31,((v>>5)&63)*255//63,(v&31)*255//31),axis=1)
a=rgb(c0);z=rgb(c1);four=c0>c1 # BC2/BC3 always use four colours. Only BC1 uses endpoint ordering
# to select the three-colour + transparent-black palette.
a=rgb(c0);z=rgb(c1);four=(c0>c1) | (not transparent)
pal=np.zeros((len(b),4,4),dtype=np.uint8);pal[:,:,3]=255 pal=np.zeros((len(b),4,4),dtype=np.uint8);pal[:,:,3]=255
pal[:,0,:3]=a;pal[:,1,:3]=z pal[:,0,:3]=a;pal[:,1,:3]=z
pal[:,2,:3]=np.where(four[:,None],(2*a+z+1)//3,(a+z)//2) pal[:,2,:3]=np.where(four[:,None],(2*a+z+1)//3,(a+z)//2)
+3 -2
View File
@@ -426,7 +426,7 @@ def _untile360(src, width_units, texel_pitch):
y = _x360_tiled_y(j * width_units + i, width_units, texel_pitch) y = _x360_tiled_y(j * width_units + i, width_units, texel_pitch)
dst_idx = (y * width_units + x) * texel_pitch dst_idx = (y * width_units + x) * texel_pitch
src_idx = (j * width_units + i) * texel_pitch src_idx = (j * width_units + i) * texel_pitch
if dst_idx + texel_pitch <= len(dst) and src_idx + texel_pitch <= len(src): if x < width_units and dst_idx + texel_pitch <= len(dst) and src_idx + texel_pitch <= len(src):
dst[dst_idx:dst_idx + texel_pitch] = src[src_idx:src_idx + texel_pitch] dst[dst_idx:dst_idx + texel_pitch] = src[src_idx:src_idx + texel_pitch]
return bytes(dst) return bytes(dst)
@@ -473,7 +473,8 @@ def _dxt1_block(blk, transparent):
c0, c1 = struct.unpack_from(">HH", blk, 0) c0, c1 = struct.unpack_from(">HH", blk, 0)
r0, g0, b0 = _rgb565(c0) r0, g0, b0 = _rgb565(c0)
r1, g1, b1 = _rgb565(c1) r1, g1, b1 = _rgb565(c1)
if c0 > c1: # DXT3/DXT5 have independent alpha and always use four colour entries.
if c0 > c1 or not transparent:
c2, c3 = _rgb565_interp((c0 >> 11) & 0x1F, (c1 >> 11) & 0x1F, c2, c3 = _rgb565_interp((c0 >> 11) & 0x1F, (c1 >> 11) & 0x1F,
(c0 >> 5) & 0x3F, (c1 >> 5) & 0x3F, (c0 >> 5) & 0x3F, (c1 >> 5) & 0x3F,
c0 & 0x1F, c1 & 0x1F) c0 & 0x1F, c1 & 0x1F)