Files
goldeneye-pc-port/tools_pc/texheader.py
T
James Dansereau 425237f93f D236 (M-198): fix Surface 1/2 tree cards; root cause was a DETAIL tile binding
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.
2026-09-21 22:48:10 -04:00

123 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""D236 pass 23: read GE texture headers straight out of the ROM, offline.
A texture's ROM blob starts with a control byte (`image.c:2568`):
bit 7 hasExplicitLods bit 6 iszlib bits 0-5 lod (image count)
and the bitstream begins at byte 1. Forgetting that control byte shifts
every subsequent field by 8 bits and yields a header that still *parses* --
plausible-looking formats and dimensions -- so `census()` below reports how
many entries come out implausible as a built-in sanity check (expect ~1 of
2699 for U; a few hundred means the offset is wrong again).
Two payload layouts follow, per `image.c`:
* iszlib=0 -> `texInflateNonZlib`: a 24-bit bitstream header per image,
`ffff wwwwwwww hhhhhhhh cccc` (format / width / height / compmethod).
* iszlib=1 -> `texInflateZlib`: format u8, numcolours-1 u8, then the
palette, then per-image width u8 / height u8.
Only headers are decoded here -- the pixel payloads use Rare's huffman/RLE/
lookup codecs, which are not implemented.
Usage: python tools_pc/texheader.py [texnum ...] (no args: census)
"""
import collections
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEXFORMAT = {0: "RGBA32", 1: "RGBA16", 2: "RGB24", 3: "RGB15", 4: "IA16",
5: "IA8", 6: "IA4", 7: "I8", 8: "I4", 9: "RGBA16_CI8",
10: "RGBA16_CI4", 11: "IA16_CI8", 12: "IA16_CI4"}
COMPMETHOD = {0: "UNCOMP0", 1: "UNCOMP1", 2: "HUFFMAN", 3: "HUFFMANCHAN",
4: "RLE", 5: "LOOKUP", 6: "HUFFLOOKUP", 7: "RLELOOKUP",
8: "HUFFBLUR", 9: "RLEBLUR"}
# image.c g_TexFormatGbiMappings / g_TexFormatDepths -- what the runtime
# reports, i.e. what a live GE_TEXI/GE_D2xx probe prints as fmt=/siz=.
GBI_FMT = {0: 0, 1: 0, 2: 0, 3: 0, 4: 3, 5: 3, 6: 3, 7: 4, 8: 4,
9: 2, 10: 2, 11: 2, 12: 2}
GBI_SIZ = {0: 3, 1: 2, 2: 3, 3: 2, 4: 2, 5: 1, 6: 0, 7: 1, 8: 0,
9: 1, 10: 0, 11: 1, 12: 0}
GBI_FMT_NAME = {0: "RGBA", 2: "CI", 3: "IA", 4: "I"}
def load(region="ntsc-final"):
"""(rom bytes, [(offset, size), ...] indexed by texture number)."""
rom = open(os.path.join(ROOT, "data", "ge007.%s.z64" % region), "rb").read()
rows = []
csv = os.path.join(ROOT, "imagelist.%s.csv" % ("u" if region.startswith("ntsc") else region[0]))
for line in open(csv):
if not line.strip():
continue
f = line.split(",")
rows.append((int(f[0]), int(f[1])))
return rom, rows
def parse(blob):
ctrl = blob[0]
d = {"lods": ctrl & 0x3F, "zlib": (ctrl >> 6) & 1, "explicit_lods": (ctrl >> 7) & 1}
if d["zlib"]:
d["format"] = blob[1]
d["numcolours"] = blob[2] + 1
else:
v = (blob[1] << 16) | (blob[2] << 8) | blob[3]
d["format"] = (v >> 20) & 0xF
d["width"] = (v >> 12) & 0xFF
d["height"] = (v >> 4) & 0xFF
d["compmethod"] = v & 0xF
f = d["format"]
d["format_name"] = TEXFORMAT.get(f, "?%d" % f)
d["gbi_fmt"] = GBI_FMT.get(f)
d["gbi_siz"] = GBI_SIZ.get(f)
return d
def implausible(d):
if d["format"] > 12:
return True
if d["zlib"]:
return False
return d["compmethod"] > 9 or d["width"] == 0 or d["height"] == 0
def describe(num, rom, rows):
off, size = rows[num]
d = parse(rom[off:off + 5])
live = "fmt=%d(%s) siz=%d" % (d["gbi_fmt"], GBI_FMT_NAME.get(d["gbi_fmt"], "?"), d["gbi_siz"])
if d["zlib"]:
body = "ZLIB %-11s ncol=%-4d" % (d["format_name"], d["numcolours"])
else:
body = "NONZLIB %-11s %3dx%-3d %-11s" % (d["format_name"], d["width"], d["height"],
COMPMETHOD.get(d["compmethod"], d["compmethod"]))
return "tex%-5d size=%-5d %s lods=%d explicit_lods=%d -> live %s" % (
num, size, body, d["lods"], d["explicit_lods"], live)
def census(rom, rows):
fmts, bad = collections.Counter(), 0
for num in range(len(rows)):
off, size = rows[num]
if size < 5:
continue
d = parse(rom[off:off + 5])
fmts[("zlib " if d["zlib"] else "") + d["format_name"]] += 1
if implausible(d):
bad += 1
return fmts, bad
if __name__ == "__main__":
rom, rows = load()
args = [a for a in sys.argv[1:] if not a.startswith("-")]
if args:
for a in args:
print(describe(int(a, 0), rom, rows))
else:
fmts, bad = census(rom, rows)
print("%d textures, %d implausible headers (sanity check: expect ~1)" % (len(rows), bad))
for name, n in fmts.most_common():
print(" %-16s %5d" % (name, n))