From ad992f20017628293c84e9f086bba329046bd835 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:56:20 +0100 Subject: [PATCH] Fix padded Xbox texture rows and BC2/BC3 colour decoding --- docs/retail-renderer.md | 39 +++++++++++++++++ tools/asset_pipeline/test_texture_decode.py | 46 +++++++++++++++++++++ tools/vendor/utt/rx2_fast.py | 8 +++- tools/vendor/utt/rx2_parser.py | 5 ++- 4 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 tools/asset_pipeline/test_texture_decode.py diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index cdeaa94e..fb3b4378 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -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 district, before applying sky lighting. Track its entities and assets in the 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. diff --git a/tools/asset_pipeline/test_texture_decode.py b/tools/asset_pipeline/test_texture_decode.py new file mode 100644 index 00000000..47d95761 --- /dev/null +++ b/tools/asset_pipeline/test_texture_decode.py @@ -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) diff --git a/tools/vendor/utt/rx2_fast.py b/tools/vendor/utt/rx2_fast.py index 7110beed..f45f3875 100644 --- a/tools/vendor/utt/rx2_fast.py +++ b/tools/vendor/utt/rx2_fast.py @@ -17,7 +17,9 @@ def _layout(units,width,pitch): 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&16)>>4 - dest=y*width+x;valid=dest>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[:,0,:3]=a;pal[:,1,:3]=z pal[:,2,:3]=np.where(four[:,None],(2*a+z+1)//3,(a+z)//2) diff --git a/tools/vendor/utt/rx2_parser.py b/tools/vendor/utt/rx2_parser.py index 651c99a6..ffdc6a8a 100644 --- a/tools/vendor/utt/rx2_parser.py +++ b/tools/vendor/utt/rx2_parser.py @@ -426,7 +426,7 @@ def _untile360(src, width_units, texel_pitch): y = _x360_tiled_y(j * width_units + i, width_units, texel_pitch) dst_idx = (y * width_units + x) * 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] return bytes(dst) @@ -473,7 +473,8 @@ def _dxt1_block(blk, transparent): c0, c1 = struct.unpack_from(">HH", blk, 0) r0, g0, b0 = _rgb565(c0) 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, (c0 >> 5) & 0x3F, (c1 >> 5) & 0x3F, c0 & 0x1F, c1 & 0x1F)