mirror of
https://github.com/jkdansereau/goldeneye-pc-port.git
synced 2026-10-23 15:52:42 +02:00
GE's texLoadFromGdl picks a handler per G_NOOP texture reference from w0 & 7. TEXTURETYPE_LOD/MIPMAP (texHandleType0/Type2) issue ONE load block at TMEM 0, so tiles 0..n are LOD levels of that one image. TEXTURETYPE_DETAIL (texHandleType1, src/game/tex.c) issues TWO: the detail texture at TMEM 0 and the real base image at TMEM texGetSizeInBytes(detail,0) on tiles 1+. gfx_lod_tile_offset folded every G_TL_LOD draw to tile 0 (D107 -- correct for the first two, wrong for DETAIL), so the port drew the detail texture and never imported the base image at all. All four Surface 1 treeline cards (tex1198-tex1201, RGBA5551 64x17 cut-outs) are DETAIL bindings sharing one detail texture, tex2465: an opaque 32x32 IA8 noise tile. That is the same texture D267/D276 measured at 1023/1024 texels opaque and called the mystery "noise painter" -- hence 20+ passes chasing decode, alpha, mip and geometry theories for what was a binding bug. Fix (port layer only): under G_TL_LOD, if tile fi+1 differs from tile fi in BOTH TMEM address and fmt/siz, sample tile 1. Asymmetric on purpose -- LOD levels of one image always share a format, so a mip chain can never trip it, and a detail pair that happened to share a format just keeps the old behaviour. Config Video.DetailBaseTile, default on. Verified: Surface 1 idle spawn goes from an opaque tiled noise wall to discrete snow-covered pines with ground/snow unchanged; Surface 2 shows a discrete night treeline; Depot renders clean; bunker1 golden framediff is bit-identical with the fix on and off. Offline scan of every level's bg: DETAIL bindings exist only in sevx (84), depo (39), sevb (4), sev (4), so every other level is provably untouched. User play-test confirmed. Also refuted and removed pass 25's fix direction: cut-out mip suppression changed nothing, and its alpha census showed the wall is painted by a fully opaque texture -- retiring the mip / 1-bit-alpha / XLU-smear branch. The GE_D236P25 probe is gone with it. tools_pc/gen_env_probes.py: stop counting a "GE_FOO[" log-line TAG in the doc as a documented env var. GE_TEXDUMP_ADDR prints "GE_TEXA[...]", which is not the name of any getenv(), so the check reported a permanent phantom GONE. Generalised as docs/porting-notes.md D12; full trail in findings.md D236 pass 26. Carries the pending D75/M-197 sidecar-converter fix and the env-gated D236/D75 probes, which the doc gates require to land alongside their rows.
309 lines
11 KiB
Python
309 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""D236 pass 24: decode GE texture pixels offline, straight from the ROM.
|
|
|
|
Ports the subset of `src/game/image.c` needed to get real pixels out of a
|
|
texture without building or running the game:
|
|
|
|
* `texReadBits` (image_bank.c:99) -- MSB-first bit reader
|
|
* `texInflateNonZlib` (image.c:896) -- per-image header loop
|
|
* `texBuildLookup` (image.c:1669) -- colour lookup table
|
|
* `texInflateHuffman` (image.c:1434) -- huffman tree + decode
|
|
* `texInflateRle` (image.c:1596) -- RLE directives
|
|
* `texInflateLookupFromBuffer` (image.c:2090) -- indices -> pixels
|
|
|
|
That covers compmethods UNCOMP0/1 (trivially), LOOKUP, HUFFLOOKUP and
|
|
RLELOOKUP. The channel-based methods (HUFFMAN, HUFFMANPERCHANNEL, RLE,
|
|
*BLUR) need `texChannelsToPixels` and are not implemented -- `decode()`
|
|
raises for those rather than returning something plausible-looking.
|
|
|
|
The point of this tool is the ALPHA channel: D236's tree cards are
|
|
`TEXFORMAT_RGBA16` = RGBA5551, and `g_TexFormatHas1BitAlpha[RGBA16] == 1`
|
|
(image.c:90), so their silhouette lives entirely in one bit per texel.
|
|
|
|
Usage:
|
|
python tools_pc/texdecode.py 1198 # all LODs, alpha art
|
|
python tools_pc/texdecode.py 1198 --lod 0 --ppm out.ppm
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.join(ROOT, "tools_pc"))
|
|
import texheader # noqa: E402
|
|
|
|
# image.c per-format tables
|
|
NUM_CHANNELS = [4, 3, 3, 3, 2, 2, 1, 1, 1, 1, 1, 1, 1]
|
|
HAS_1BIT_ALPHA = [0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
|
|
CHANNEL_SIZES = [0x100, 0x20, 0x100, 0x20, 0x100, 0x10, 8, 0x100, 0x10,
|
|
0x100, 0x10, 0x100, 0x10]
|
|
BITS_PER_PIXEL = [0x20, 0x10, 0x18, 0x0F, 0x10, 8, 4, 8, 4, 0x10, 0x10, 0x10, 0x10]
|
|
|
|
F_RGBA32, F_RGBA16, F_RGB24, F_RGB15, F_IA16, F_IA8, F_IA4, F_I8, F_I4 = range(9)
|
|
LOOKUP_METHODS = {5: "LOOKUP", 6: "HUFFLOOKUP", 7: "RLELOOKUP"}
|
|
|
|
|
|
class Bits(object):
|
|
"""texSetBitstring / texReadBits."""
|
|
|
|
def __init__(self, data, pos=0):
|
|
self.d = data
|
|
self.p = pos
|
|
self.acc = 0
|
|
self.n = 0
|
|
|
|
def align_after_image(self):
|
|
"""image.c:1096 -- at the end of each LOD image the bitstream is
|
|
byte-aligned: any partial byte is discarded, and if the reader happens
|
|
to sit exactly on a boundary a whole byte is skipped anyway. Omitting
|
|
this desynchronises every LOD after the first (found as a constant
|
|
+8-bit drift while walking tex1198)."""
|
|
if self.n == 0:
|
|
self.p += 1
|
|
else:
|
|
self.n = 0
|
|
|
|
def read(self, count):
|
|
while self.n < count:
|
|
self.acc = (self.d[self.p] | (self.acc << 8)) & 0xFFFFFFFFFFFF
|
|
self.p += 1
|
|
self.n += 8
|
|
self.n -= count
|
|
return (self.acc >> self.n) & ((1 << count) - 1)
|
|
|
|
|
|
def build_lookup(bs, bitsperpixel):
|
|
"""texBuildLookup -> (entries, count)."""
|
|
n = bs.read(11)
|
|
if bitsperpixel <= 24:
|
|
return [bs.read(bitsperpixel) for _ in range(n)], n
|
|
return [(bs.read(24) << 8) | bs.read(bitsperpixel - 24) for _ in range(n)], n
|
|
|
|
|
|
def inflate_huffman(bs, numiterations, chansize):
|
|
"""texInflateHuffman -- ported branch for branch."""
|
|
NB = 2048
|
|
freq = [0] * NB
|
|
nodes = [[-1, -1] for _ in range(NB)]
|
|
for i in range(chansize):
|
|
freq[i] = bs.read(8)
|
|
|
|
def two_smallest():
|
|
f1 = f2 = 9999
|
|
i1 = i2 = -1
|
|
for i in range(chansize):
|
|
if freq[i] < f1:
|
|
if f2 < f1:
|
|
f1, i1 = freq[i], i
|
|
else:
|
|
f2, i2 = freq[i], i
|
|
elif freq[i] < f2:
|
|
f2, i2 = freq[i], i
|
|
return f1, i1, f2, i2
|
|
|
|
f1, i1, f2, i2 = two_smallest()
|
|
rootindex = 0
|
|
while not (f1 == 9999 or f2 == 9999):
|
|
s = freq[i1] + freq[i2] or 1
|
|
freq[i1] = 9999
|
|
freq[i2] = 9999
|
|
if nodes[i1][0] < 0 and nodes[i1][1] < 0:
|
|
nodes[i1][0] = i1 + 10000
|
|
rootindex = i1
|
|
freq[i1] = s
|
|
nodes[i1][1] = (i2 + 10000) if (nodes[i2][0] < 0 and nodes[i2][1] < 0) else i2
|
|
elif nodes[i2][0] < 0 and nodes[i2][1] < 0:
|
|
nodes[i2][0] = i2 + 10000
|
|
rootindex = i2
|
|
freq[i2] = s
|
|
nodes[i2][1] = (i1 + 10000) if (nodes[i1][0] < 0 and nodes[i1][1] < 0) else i1
|
|
else:
|
|
rootindex = 0
|
|
while nodes[rootindex][0] >= 0 or nodes[rootindex][1] >= 0 or freq[rootindex] < 9999:
|
|
rootindex += 1
|
|
freq[rootindex] = s
|
|
nodes[rootindex][0] = i1
|
|
nodes[rootindex][1] = i2
|
|
f1, i1, f2, i2 = two_smallest()
|
|
|
|
out = []
|
|
for _ in range(numiterations):
|
|
v = rootindex
|
|
while v < 10000:
|
|
v = nodes[v][bs.read(1)]
|
|
out.append(v - 10000)
|
|
return out
|
|
|
|
|
|
def inflate_rle(bs, blockstotal):
|
|
"""texInflateRle (image.c:1596) -- ported exactly.
|
|
|
|
Header: 3 bits backtrack-field size, 3 bits runlen-field size, 4 bits
|
|
block size. Then directives: a 0 bit introduces a literal block; a 1 bit
|
|
introduces a run (backtrack distance, run length), and a run is ALWAYS
|
|
followed by one literal with no marker bit of its own.
|
|
|
|
The `fudge` added to every run length is derived, not guessed -- short
|
|
runs cost more than literals, so they are never encoded and the field is
|
|
biased by however many blocks the directive header itself costs.
|
|
"""
|
|
btfieldsize = bs.read(3)
|
|
rlfieldsize = bs.read(3)
|
|
blocksize = bs.read(4)
|
|
cost = btfieldsize + rlfieldsize + blocksize + 1
|
|
fudge = 0
|
|
while cost > 0:
|
|
cost -= blocksize + 1
|
|
fudge += 1
|
|
|
|
out = []
|
|
guard = blockstotal * 4 + 64
|
|
while len(out) < blockstotal and guard > 0:
|
|
guard -= 1
|
|
if bs.read(1) == 0:
|
|
out.append(bs.read(blocksize))
|
|
else:
|
|
start = len(out) - bs.read(btfieldsize) - 1
|
|
runlen = bs.read(rlfieldsize) + fudge
|
|
for k in range(runlen):
|
|
out.append(out[start + k] if 0 <= start + k < len(out) else 0)
|
|
out.append(bs.read(blocksize)) # a run is always followed by a literal
|
|
return out[:blockstotal]
|
|
|
|
|
|
def _stride16(width):
|
|
return (width + 3) & 0xFFC
|
|
|
|
|
|
def decode_image(bs, fmt, width, height, compmethod):
|
|
"""Return (rows, kind) where rows is a list of per-row pixel lists."""
|
|
if compmethod not in LOOKUP_METHODS:
|
|
raise NotImplementedError(
|
|
"compmethod %d needs texChannelsToPixels, not ported" % compmethod)
|
|
lookup, ncol = build_lookup(bs, BITS_PER_PIXEL[fmt])
|
|
npix = width * height
|
|
if compmethod == 6:
|
|
idx = inflate_huffman(bs, npix, ncol)
|
|
elif compmethod == 7:
|
|
idx = inflate_rle(bs, npix)
|
|
else: # plain LOOKUP: indices are read straight off the bitstream
|
|
# texGetBitSize(ncol) == (ncol-1).bit_length(), and is 0 for a
|
|
# single-colour table -- do NOT clamp to 1, that desynchronises.
|
|
nbits = (ncol - 1).bit_length()
|
|
idx = [bs.read(nbits) for _ in range(npix)]
|
|
rows = []
|
|
for y in range(height):
|
|
row = [lookup[i] if i < len(lookup) else 0
|
|
for i in idx[y * width:(y + 1) * width]]
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def decode(num, region="ntsc-final", max_lods=None):
|
|
"""Decode every LOD image of one texture. Returns (header, [image, ...])."""
|
|
rom, rows = texheader.load(region)
|
|
off, size = rows[num]
|
|
blob = rom[off:off + size]
|
|
hdr = texheader.parse(blob)
|
|
if hdr["zlib"]:
|
|
raise NotImplementedError("zlib/paletted textures not handled here")
|
|
bs = Bits(blob, 1)
|
|
numimages = hdr["lods"] if hdr["explicit_lods"] else 1
|
|
if max_lods:
|
|
numimages = min(numimages, max_lods)
|
|
imgs = []
|
|
for _ in range(numimages):
|
|
fmt = bs.read(4)
|
|
w = bs.read(8)
|
|
h = bs.read(8)
|
|
cm = bs.read(4)
|
|
if w * h > 0x2000 or w == 0 or h == 0:
|
|
break
|
|
try:
|
|
rows_ = decode_image(bs, fmt, w, h, cm)
|
|
except NotImplementedError as e:
|
|
# Later LODs often switch to a channel-based codec; stop cleanly
|
|
# and report, rather than pretending the chain ended here.
|
|
imgs.append({"format": fmt, "width": w, "height": h,
|
|
"compmethod": cm, "rows": None, "error": str(e)})
|
|
break
|
|
imgs.append({"format": fmt, "width": w, "height": h, "compmethod": cm,
|
|
"rows": rows_})
|
|
bs.align_after_image()
|
|
return hdr, imgs
|
|
|
|
|
|
def alpha_art(img):
|
|
"""ASCII art of the 1-bit alpha channel of an RGBA5551 image."""
|
|
if img["format"] != F_RGBA16:
|
|
return None
|
|
return ["".join("#" if (p & 1) else "." for p in row) for row in img["rows"]]
|
|
|
|
|
|
def rgb_art(img):
|
|
"""Coarse luminance art, to sanity-check the colour channels."""
|
|
ramp = " .:-=+*#%@"
|
|
out = []
|
|
for row in img["rows"]:
|
|
line = ""
|
|
for p in row:
|
|
r, g, b = (p >> 11) & 31, (p >> 6) & 31, (p >> 1) & 31
|
|
line += ramp[min(9, (r + g + b) * 10 // 96)]
|
|
out.append(line)
|
|
return out
|
|
|
|
|
|
def write_ppm(img, path):
|
|
w, h = img["width"], img["height"]
|
|
with open(path, "wb") as f:
|
|
f.write(b"P6\n%d %d\n255\n" % (w, h))
|
|
for row in img["rows"]:
|
|
for p in row:
|
|
r, g, b, a = (p >> 11) & 31, (p >> 6) & 31, (p >> 1) & 31, p & 1
|
|
# transparent texels rendered magenta so the silhouette is obvious
|
|
f.write(bytes((r << 3, g << 3, b << 3)) if a else b"\xff\x00\xff")
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) < 2:
|
|
print(__doc__)
|
|
return 1
|
|
num = int(argv[1], 0)
|
|
only = None
|
|
ppm = None
|
|
if "--lod" in argv:
|
|
only = int(argv[argv.index("--lod") + 1])
|
|
if "--ppm" in argv:
|
|
ppm = argv[argv.index("--ppm") + 1]
|
|
hdr, imgs = decode(num)
|
|
print("tex%d: explicit_lods=%d lods=%d -> decoded %d image(s)"
|
|
% (num, hdr["explicit_lods"], hdr["lods"], len(imgs)))
|
|
for i, img in enumerate(imgs):
|
|
if only is not None and i != only:
|
|
continue
|
|
print("\n LOD %d: %s %dx%d compmethod=%s"
|
|
% (i, texheader.TEXFORMAT.get(img["format"], img["format"]),
|
|
img["width"], img["height"],
|
|
texheader.COMPMETHOD.get(img["compmethod"], img["compmethod"])))
|
|
if img["rows"] is None:
|
|
print(" NOT DECODED: %s" % img["error"])
|
|
continue
|
|
a = alpha_art(img)
|
|
if a:
|
|
opaque = sum(r.count("#") for r in a)
|
|
total = img["width"] * img["height"]
|
|
print(" alpha: %d/%d opaque (%.1f%%) ['#'=opaque '.'=transparent]"
|
|
% (opaque, total, 100.0 * opaque / total))
|
|
for r in a:
|
|
print(" " + r)
|
|
else:
|
|
for r in rgb_art(img):
|
|
print(" " + r)
|
|
if ppm and (only is None or i == only):
|
|
write_ppm(img, ppm)
|
|
print(" wrote %s" % ppm)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|