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.
This commit is contained in:
James Dansereau committed 2026-09-21 22:48:10 -04:00
1 parent 327e6fe1a7
commit 425237f93f
17 files changed
+1732 -12

No files matched your search

+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""D236 pass 22: offline census of a level's bg room display lists.
Decodes `bg/bg_<level>_all_p.seg` straight out of the ROM and walks every
room's primary and secondary GDL, reporting the distinct render-mode
(`G_SETOTHERMODE_L`, i.e. the `oml` words the live `GE_D2xx` probes log) and
texture-image classes each room draws. This answers "which bg draw class
carries the trees, and which room draws it" without building or running the
game.
Pipeline (mirrors `bg.c` exactly):
* the .seg file is stored UNCOMPRESSED in ROM (obLoadBGFileBytesAtOffset
random-accesses it by byte offset); only the per-room blobs inside it are
deflated;
* word[1] of the decompressed file is the segment offset of the
`bg_room_data` array (`bg.c:865`), 24 bytes/record:
u32 pPointTableBin, u32 pPriMappingBin, u32 pSecMappingBin, coord3d pos;
* a room's compressed DL size is the distance to the *next* non-zero
mapping-bin offset, taking primary and secondary as one interleaved
stream (`bg.c:944-990` + `getPri/SecMappingBinCount`);
* each blob is itself raw-deflate behind its own 2-byte header
(`bgDecompress` -> `decompressdata`).
The words reported are the ones **as authored in the file**, i.e. BEFORE
`bgApplyDynamicCCRMLUT` rewrites them at room load. The `->` column shows
what the applied LUT turns each one into, so the output can be compared
directly against what the runtime probes log. Only LUTs 1/5/6/7 are ever
applied (`bg.c:2797/2804/2812/2819`); see D236 pass 21.
Usage: python tools_pc/bg_gdl_census.py [sevx] [ntsc-final]
"""
import collections
import contextlib
import io
import os
import struct
import sys
import zlib
import rendermode
import texheader
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "tools_pc"))
G_SETOTHERMODE_L = 0xB9
G_SETTIMG = 0xFD
G_NOOP = 0xC0
G_ENDDL = 0xB8
G_DL = 0xDE
# gsDPSetRenderMode's w0: SETOTHERMODE_L, shift 3, length 29 -> the render-mode
# field. Other 0xB9 commands (alpha compare, z source) use different shifts
# and are counted separately.
RENDERMODE_W0 = 0xB900031D
IM_FMT = {0: "RGBA", 1: "YUV", 2: "CI", 3: "IA", 4: "I"}
IM_SIZ = {0: "4b", 1: "8b", 2: "16b", 3: "32b"}
def load_rom_file(name, region):
"""Decompress one ROM file table entry by name."""
sys.argv = ["", region]
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
import d43_emit as m
key, row = m.find_row(name)
if not key:
raise SystemExit("not in ROM file table: %s" % name)
addr, size = row
raw = m.rom[addr:addr + size]
# Model files are raw-deflate behind a 2-byte 0x1172 header, but the bg
# .seg files are stored UNCOMPRESSED in ROM -- they have to be, because
# obLoadBGFileBytesAtOffset() random-accesses them by byte offset to pull
# one room's blob at a time. Only the per-room blobs inside are deflated.
try:
return zlib.decompress(raw[2:], -15)
except zlib.error:
return raw
def seg_off(v):
"""Segment-0x0F address -> file offset (bg.c's BG_SEG_TO_PTR)."""
return v & 0x00FFFFFF
def read_rooms(data):
"""Parse the bg_room_data array: [(point, pri, sec, pos), ...]."""
list_off = seg_off(struct.unpack_from(">I", data, 4)[0])
rooms = []
off = list_off
while off + 24 <= len(data):
point, pri, sec = struct.unpack_from(">III", data, off)
pos = struct.unpack_from(">fff", data, off + 12)
rooms.append((point, pri, sec, pos))
# The array is terminated by the sentinel record whose pPriMappingBin
# is 0 AND which is not just an interior room with no primary DL, so
# walk until all three offsets are zero.
if len(rooms) > 1 and point == 0 and pri == 0 and sec == 0:
break
off += 24
return rooms, list_off
def next_nonzero(rooms, start, field):
i = start
while i < len(rooms) and rooms[i][field] == 0:
i += 1
return i if i < len(rooms) else None
def blob_sizes(rooms, i):
"""csize_primary / csize_secondary for room i, per bg.c:944-990."""
_point, pri, sec, _pos = rooms[i]
csz_pri = csz_sec = 0
if pri:
pidx = next_nonzero(rooms, i + 1, 1)
sidx = next_nonzero(rooms, i, 2)
if pidx is not None and (sidx is None or pidx <= sidx):
csz_pri = seg_off(rooms[pidx][1]) - seg_off(pri)
elif sidx is not None:
csz_pri = seg_off(rooms[sidx][2]) - seg_off(pri)
if sec:
pidx = next_nonzero(rooms, i + 1, 1)
sidx = next_nonzero(rooms, i + 1, 2)
if pidx is not None and (sidx is None or pidx <= sidx):
csz_sec = seg_off(rooms[pidx][1]) - seg_off(sec)
elif sidx is not None:
csz_sec = seg_off(rooms[sidx][2]) - seg_off(sec)
return csz_pri, csz_sec
def inflate_blob(data, off, size):
if size <= 2:
return None
try:
return zlib.decompress(data[off + 2:off + size], -15)
except zlib.error as e:
return ("ERR", str(e))
def walk_gdl(gdl):
"""Walk an 8-byte big-endian N64 Gfx stream.
Returns (rendermodes, textures, opcodes, ncmd) where rendermodes maps an
oml word to how many draw-setup commands used it, and textures maps
(fmt, siz) to the set of image addresses set while it was active.
"""
rms = collections.Counter()
texs = collections.Counter()
ops = collections.Counter()
pairs = collections.Counter()
cur_rm = None
n = len(gdl) // 8
for k in range(n):
w0, w1 = struct.unpack_from(">II", gdl, k * 8)
op = w0 >> 24
ops[op] += 1
if op == G_SETOTHERMODE_L and w0 == RENDERMODE_W0:
cur_rm = w1
rms[w1] += 1
elif op == G_NOOP:
# bg room DLs never carry G_SETTIMG -- they reference textures by
# NUMBER in a G_NOOP, which texLoadFromGdl() resolves into real
# texture commands at room load (tex.c:779, `case G_NOOP:
# texnum = in->words.w1 & 0xfff`).
texs[w1 & 0xFFF] += 1
pairs[(cur_rm, w1 & 0xFFF)] += 1
return rms, texs, ops, pairs, n
def report(level, region):
name = "bg_%s_all_p" % level # file-table basename (no dir/.seg)
data = load_rom_file(name, region)
print("%s: %d bytes decompressed" % (name, len(data)))
rooms, list_off = read_rooms(data)
print("room table at file offset 0x%x, %d records\n" % (list_off, len(rooms)))
all_rms = collections.Counter()
all_pairs = collections.Counter()
rm_rooms = collections.defaultdict(set)
tex_rooms = collections.defaultdict(set)
bad = 0
for i in range(1, len(rooms)):
csz_pri, csz_sec = blob_sizes(rooms, i)
for label, off_raw, csz in (("pri", rooms[i][1], csz_pri),
("sec", rooms[i][2], csz_sec)):
if not off_raw or csz <= 0:
continue
out = inflate_blob(data, seg_off(off_raw), csz)
if out is None:
continue
if isinstance(out, tuple):
bad += 1
print("room %3d %s: INFLATE FAILED at 0x%x size %d (%s)"
% (i, label, seg_off(off_raw), csz, out[1]))
continue
rms, texs, ops, pairs, ncmd = walk_gdl(out)
all_rms.update(rms)
all_pairs.update(pairs)
for w in rms:
rm_rooms[w].add(i)
for t in texs:
tex_rooms[t].add(i)
print("room %3d %s: csize=%-6d usize=%-6d cmds=%-5d rm=%d tex=%d %s"
% (i, label, csz, len(out), ncmd, len(rms), sum(texs.values()),
" ".join("0x%08x" % w for w in sorted(rms))))
print("\n%s" % ("=" * 78))
rm = rendermode.RenderModes()
luts = rendermode.applied_luts(rm)
print("RENDER-MODE CENSUS (as authored in the file, pre-LUT)")
print(" the '->' column is what bgApplyDynamicCCRMLUT rewrites the word to at")
print(" room load when fog is on (LUT 1 primary / LUT 5 secondary) -- i.e. the")
print(" value a live GE_D2xx probe would log. With fog OFF, LUTs 6/7 rewrite")
print(" COMBINE modes only, so render modes stay exactly as authored.")
print("=" * 78)
for w, n in all_rms.most_common():
rr = sorted(rm_rooms[w])
to = luts.get(1, {}).get(w) or luts.get(5, {}).get(w)
print(" 0x%08x -> %-12s %4d setups %3d rooms rooms=%s"
% (w, ("0x%08x" % to) if to else "(unchanged)", n, len(rr),
",".join(map(str, rr[:14])) + ("..." if len(rr) > 14 else "")))
print(" %s" % " | ".join(rm.decode(w)["flags"]))
print("\n%s" % ("=" * 78))
print("TEXTURES PER RENDER-MODE CLASS (texture numbers from G_NOOP w1)")
print(" ROM header decoded offline; 'live' is what a runtime probe prints.")
print("=" * 78)
rom, trows = texheader.load(region)
bytex = collections.defaultdict(collections.Counter)
for (w, t), _n in all_pairs.items():
bytex[w][t] = len(tex_rooms[t])
for w, _n in all_rms.most_common():
print(" rm=0x%08x" % w)
for t, nrooms in bytex[w].most_common():
try:
desc = texheader.describe(t, rom, trows)
except Exception:
desc = "tex%-5d (not in image list)" % t
print(" %2d rooms %s" % (nrooms, desc))
if bad:
print("\n%d blob(s) failed to inflate -- size derivation may be off for those."
% bad)
if __name__ == "__main__":
lvl = sys.argv[1] if len(sys.argv) > 1 else "sevx"
reg = sys.argv[2] if len(sys.argv) > 2 else "ntsc-final"
report(lvl, reg)
+19 -2
View File
@@ -402,8 +402,11 @@ def process(name):
merged[-1][1] = max(merged[-1][1], iv[1])
else:
merged.append(iv)
blob_regions = [] # (old_off, old_size, new_off) raw pixel spans
for (t, e) in merged:
n = dstpos
dstpos = add_region(t, e - t, dstpos)
blob_regions.append((t, e - t, n))
# GDLs last, tight-packed 16B slots. Visited GDLs go in VISIT order (the
# compaction span arithmetic requires it). Referenced-but-unvisited GDLs
@@ -669,9 +672,17 @@ def process(name):
j += 1
o += 8
# Embedded image blobs: raw pixel copy (the RDP reads them as-is; no
# transform). Layout reserves the span and remaps G_SETTIMG w1 to it, but
# without this copy the buffer stays zeroed and the texture renders black
# (M-197 / D75: PnintendologoZ white wordmark invisible on PC).
for (o, osz, n) in blob_regions:
buf[n:n + osz] = src[o:o + osz]
# ---- round-trip validation ----
validate(name, src, D, NS, NT, nodes, R0, placed, all_gdls, buf, D_PC,
regions, node_newoff, rec_newoff, gdl_newoff, remap, inv_remap)
regions, blob_regions, node_newoff, rec_newoff, gdl_newoff,
remap, inv_remap)
# ---- compress ----
co = zlib.compressobj(6, zlib.DEFLATED, -15)
@@ -693,9 +704,15 @@ op24_is_collision = {}
op24_pointusage = {}
def validate(name, src, D, NS, NT, nodes, R0, placed, all_gdls, buf, D_PC,
regions, node_newoff, rec_newoff, gdl_newoff, remap, inv_remap):
regions, blob_regions, node_newoff, rec_newoff, gdl_newoff,
remap, inv_remap):
def err(m): errors.append(f"{name}: RT {m}")
# embedded image blobs: raw identity round-trip (M-197)
for (o, osz, n) in blob_regions:
if bytes(buf[n:n + osz]) != bytes(src[o:o + osz]):
err(f"blob {o:#x}+{osz:#x}: pixel bytes differ at new {n:#x}")
# tiling: regions cover [0, D_PC) exactly
rs = sorted(regions, key=lambda x: x[2])
pos = 0
+7 -1
View File
@@ -49,7 +49,13 @@ def doc_vars() -> tuple[set[str], set[str]]:
return set(), set()
named, tombstone = set(), set()
for line in DOC.read_text(encoding="utf-8").splitlines():
vs = re.findall(r"GE_[A-Z0-9_]+", line)
# `GE_FOO[` in prose is a probe's LOG-LINE TAG, not an env var -- several
# probes print a tag that is not the name of any getenv() (e.g.
# GE_TEXDUMP_ADDR prints "GE_TEXA[...]"). Counting those as documented
# vars reports a permanent phantom GONE. A row for a real var always
# names it plainly in the table's first cell, so it is still picked up.
vs = [m.group(1) for m in re.finditer(r"(GE_[A-Z0-9_]+)(\[?)", line)
if not m.group(2)]
named.update(vs)
if "tombstone" in line.lower():
tombstone.update(vs)
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""D236 pass 22: evaluate N64 render-mode words offline, straight from the repo.
Two jobs:
1. `decode(word)` -- turn a raw `G_SETOTHERMODE_L` value (the `oml` the live
`GE_D2xx` probes print) into the flag names and blender equation it stands
for, and name it if it matches a `G_RM_*` composite in `include/PR/gbi.h`.
2. `applied_luts()` -- evaluate the `DL_LUT_*` tables in `src/game/bg.c` into
plain (from -> to) word maps, for the four LUT indices `bg.c` actually
applies at room load (1/5/6/7). This is what lets a *static* census of a
bg file's authored render modes be compared against the words a *live*
probe logs, which are the post-rewrite ones.
Everything is parsed from the real headers/sources rather than hardcoded, so
it cannot drift from the build. No C compiler required.
"""
import os
import re
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GBI_H = os.path.join(ROOT, "include", "PR", "gbi.h")
BG_C = os.path.join(ROOT, "src", "game", "bg.c")
# Order matters for readable decoding: mask-style fields first.
_FIELDS = [
("CVG_DST", 0x300, {0x000: "CVG_DST_CLAMP", 0x100: "CVG_DST_WRAP",
0x200: "CVG_DST_FULL", 0x300: "CVG_DST_SAVE"}),
("ZMODE", 0xC00, {0x000: "ZMODE_OPA", 0x400: "ZMODE_INTER",
0x800: "ZMODE_XLU", 0xC00: "ZMODE_DEC"}),
]
_BITS = [(0x8, "AA_EN"), (0x10, "Z_CMP"), (0x20, "Z_UPD"), (0x40, "IM_RD"),
(0x80, "CLR_ON_CVG"), (0x1000, "CVG_X_ALPHA"),
(0x2000, "ALPHA_CVG_SEL"), (0x4000, "FORCE_BL")]
_BL_P = {0: "CLR_IN", 1: "CLR_MEM", 2: "CLR_BL", 3: "CLR_FOG"}
_BL_A = {0: "A_IN/1MA", 1: "A_FOG/A_MEM", 2: "A_SHADE/1", 3: "0"}
def _flag_defines():
"""Simple `#define NAME <int literal>` pairs from gbi.h."""
out = {}
for line in open(GBI_H, encoding="utf-8", errors="replace"):
m = re.match(r"#define\s+(\w+)\s+(0x[0-9a-fA-F]+|\d+)\s*(/\*.*)?$", line)
if m:
out[m.group(1)] = int(m.group(2), 0)
return out
def _macro_bodies():
"""`#define NAME(args) body` and `#define NAME body` with continuations."""
src = open(GBI_H, encoding="utf-8", errors="replace").read()
src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S)
src = src.replace("\\\n", " ")
fn, obj = {}, {}
for line in src.split("\n"):
m = re.match(r"#define\s+(\w+)\(([^)]*)\)\s+(.*)$", line)
if m:
fn[m.group(1)] = ([a.strip() for a in m.group(2).split(",")], m.group(3).strip())
continue
m = re.match(r"#define\s+(\w+)\s+(.+)$", line)
if m:
obj[m.group(1)] = m.group(2).strip()
return fn, obj
class RenderModes(object):
def __init__(self):
self.flags = _flag_defines()
self.fn, self.obj = _macro_bodies()
self.by_name = {}
for name in self.obj:
if not name.startswith("G_RM_"):
continue
try:
v = self.eval_name(name)
except Exception:
continue
if v is not None:
self.by_name[name] = v & 0xFFFFFFFF
self.by_value = {}
for n, v in self.by_name.items():
self.by_value.setdefault(v, []).append(n)
def _expand(self, text, depth=0):
if depth > 12:
raise ValueError("macro recursion")
# GBL_c1/GBL_c2 are the only function-like macros we need.
def gbl(m):
which, args = m.group(1), [a.strip() for a in m.group(2).split(",")]
vals = [self._value(a) for a in args]
sh = (30, 26, 22, 18) if which == "1" else (28, 24, 20, 16)
return "(%d)" % sum(v << s for v, s in zip(vals, sh))
prev = None
while prev != text:
prev = text
text = re.sub(r"GBL_c([12])\s*\(([^()]*)\)", gbl, text)
return text
def _value(self, tok):
tok = tok.strip()
if re.match(r"^(0x[0-9a-fA-F]+|\d+)$", tok):
return int(tok, 0)
if tok in self.flags:
return self.flags[tok]
if tok in self.obj:
return self.eval_name(tok)
raise ValueError("unknown token %r" % tok)
def eval_name(self, name, _depth=0):
if _depth > 12:
raise ValueError("recursion")
body = self.obj[name]
# G_RM_FOO -> RM_FOO(1) / G_RM_FOO2 -> RM_FOO(2)
m = re.match(r"^(RM_\w+)\((\d)\)$", body)
if m:
macro, clk = m.group(1), m.group(2)
if macro not in self.fn:
return None
_args, mbody = self.fn[macro]
mbody = mbody.replace("GBL_c##clk", "GBL_c%s" % clk)
return self._eval_expr(mbody)
return self._eval_expr(body)
def _eval_expr(self, expr):
expr = self._expand(expr)
expr = re.sub(r"\b([A-Za-z_]\w*)\b",
lambda m: str(self._value(m.group(1))), expr)
return eval(expr, {"__builtins__": {}}, {}) & 0xFFFFFFFF # noqa: S307
def decode(self, word):
word &= 0xFFFFFFFF
low = word & 0xFFFF
parts = [n for b, n in _BITS if low & b]
for _label, mask, table in _FIELDS:
parts.append(table[low & mask])
c1 = "(%s*%s + %s*%s)" % (_BL_P[(word >> 30) & 3], _BL_A[(word >> 26) & 3],
_BL_P[(word >> 22) & 3], _BL_A[(word >> 18) & 3])
c2 = "(%s*%s + %s*%s)" % (_BL_P[(word >> 28) & 3], _BL_A[(word >> 24) & 3],
_BL_P[(word >> 20) & 3], _BL_A[(word >> 16) & 3])
names = self.by_value.get(word, [])
return {"word": word, "flags": parts, "cyc1": c1, "cyc2": c2, "names": names}
def name_of(self, word):
n = self.by_value.get(word & 0xFFFFFFFF, [])
return "/".join(n) if n else ""
# ---------------------------------------------------------------- bg.c LUTs
LUT_NAMES = {1: "DL_LUT_PRIMARY_ADDFOG", 5: "DL_LUT_SECONDARY_ADDFOG",
6: "DL_LUT_PRIMARY", 7: "DL_LUT_SECONDARY"}
def _lut_entries(body, rm):
"""Evaluate a DL_LUT_* initializer body into a flat list of (w0, w1)."""
body = re.sub(r"//[^\n]*", " ", body)
body = re.sub(r"/\*.*?\*/", " ", body, flags=re.S)
words = []
for tok in re.finditer(r"gsDPSetRenderMode\s*\(([^,]+),([^)]+)\)|(0x[0-9a-fA-F]+|\b0\b)",
body):
if tok.group(1):
a = rm._eval_expr(tok.group(1).strip())
b = rm._eval_expr(tok.group(2).strip())
words.append((0xB900031D, (a | b) & 0xFFFFFFFF))
else:
words.append(("RAW", int(tok.group(3), 0)))
# Flatten raw scalar runs into (w0, w1) pairs the same way C brace-elision
# fills Gfx.words.w0/w1.
out, pend = [], []
for kind, val in words:
if kind == "RAW":
pend.append(val)
if len(pend) == 2:
out.append((pend[0], pend[1]))
pend = []
else:
if pend:
out.append((pend[0], 0))
pend = []
out.append((kind, val))
if pend:
out.append((pend[0], 0))
return out
def applied_luts(rm=None):
"""{lut_index: {from_word: to_word}} for the LUTs bg.c actually applies."""
rm = rm or RenderModes()
src = open(BG_C, encoding="utf-8", errors="replace").read()
maps = {}
for idx, name in LUT_NAMES.items():
m = re.search(r"Gfx\s+%s\[\]\s*=\s*\{(.*?)\n\};" % re.escape(name), src, re.S)
if not m:
continue
ents = _lut_entries(m.group(1), rm)
pairs = {}
# (match, replacement) pairs, terminated by an entry with w0 == 0.
i = 0
while i + 1 < len(ents) and ents[i][0] != 0:
(aw0, aw1), (bw0, bw1) = ents[i], ents[i + 1]
if aw0 == 0xB900031D and bw0 == 0xB900031D:
pairs[aw1] = bw1
i += 2
maps[idx] = pairs
return maps
if __name__ == "__main__":
import sys
rm = RenderModes()
if len(sys.argv) > 1:
for a in sys.argv[1:]:
d = rm.decode(int(a, 16))
print("0x%08x %s" % (d["word"], "/".join(d["names"]) or "(no G_RM_* match)"))
print(" flags: %s" % " | ".join(d["flags"]))
print(" cyc1 %s cyc2 %s" % (d["cyc1"], d["cyc2"]))
else:
print("known G_RM_* composites: %d" % len(rm.by_name))
for idx, pairs in sorted(applied_luts(rm).items()):
print("\n%s (index %d): %d rewrite pairs" % (LUT_NAMES[idx], idx, len(pairs)))
for a, b in sorted(pairs.items()):
print(" 0x%08x %-26s -> 0x%08x %s"
% (a, rm.name_of(a), b, rm.name_of(b)))
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""D236 pass 21: list the PROP_* models a level's setup actually places.
Parses the PROP enum out of src/bondconstants.h and the object records out of
one or more assets/obseg/setup/Usetup*Z.c files, then reports which props each
setup instantiates. Used to decide, statically, whether a given piece of level
scenery can possibly be a prop model at all -- if the setup places none of the
candidate models, the geometry must come from the level's bg file instead.
Note: only the record types that carry a real ObjectRecord header (StandardProp,
Door, Collectable, ...) have a meaningful `obj` field; Guard/Tag rows reuse the
same word layout for something else and are reported but should not be trusted
as model placements.
Usage: python tools_pc/scan_setup_props.py [setup.c ...]
(default: UsetupsevxZ.c UsetupsevxbZ.c -- Surface 1 / Surface 2)
"""
import collections
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONSTANTS = os.path.join(ROOT, "src", "bondconstants.h")
SETUP_DIR = os.path.join(ROOT, "assets", "obseg", "setup")
# One object record: the PropDefHeaderRecord word, then _mkword(obj, pad).
RECORD_RE = re.compile(
r"/\* Type = (\w+); index = \d+ \*/\s*"
r"_mkword\(\s*-?\d+,\s*_mkshort\(\s*-?\d+,\s*-?\d+\s*\)\s*\),\s*"
r"_mkword\(\s*(-?\d+)\s*,")
FOLIAGE_RE = re.compile(r"PROP_(JUNGLE\d*_TREE|PALM|PALMTREE|PLANT\d*B?)$")
def load_prop_enum(path=CONSTANTS):
lines = open(path, encoding="utf-8", errors="replace").read().split("\n")
# Exact match only: "typedef enum PROPFLAG" / "PROPDEF_TYPE" / "PROP_TYPE"
# all share the prefix, and picking one of those silently yields an enum
# with no PROP_* members at all (every obj then resolves to "?").
i = next(n for n, l in enumerate(lines) if l.strip() == "typedef enum PROP")
while "{" not in lines[i]:
i += 1
i += 1
val, out = 0, {}
while not lines[i].strip().startswith("}"):
m = re.match(r"(PROP_[A-Z0-9_]+)\s*(?:=\s*(-?\w+))?\s*,?", lines[i].strip())
if m:
if m.group(2):
val = int(m.group(2), 0)
out[val] = m.group(1)
val += 1
i += 1
return out
def scan(path, enum):
txt = open(path, encoding="utf-8", errors="replace").read().replace("\r", "")
counts = collections.Counter()
for m in RECORD_RE.finditer(txt):
counts[(m.group(1), int(m.group(2)))] += 1
declared = txt.count("/* Type = ")
return counts, declared
def main(argv):
enum = load_prop_enum()
# Self-test / positive control: a "0 foliage" result is only meaningful if
# the detector can fire at all. Print the foliage props the enum defines
# and that FOLIAGE_RE recognises, so a zero count below reads as "not
# placed", never as "the matcher never matched anything".
known = sorted(n for n in enum.values() if FOLIAGE_RE.match(n))
print("PROP enum: %d entries; foliage props recognised (%d): %s"
% (len(enum), len(known), ", ".join(known)))
if not known:
print("ERROR: foliage detector matches nothing -- results below are meaningless")
return 1
names = argv[1:] or ["UsetupsevxZ.c", "UsetupsevxbZ.c"]
for name in names:
path = name if os.path.exists(name) else os.path.join(SETUP_DIR, os.path.basename(name))
counts, declared = scan(path, enum)
print("=" * 70)
print(os.path.basename(path), "-- %d object records matched of %d total records"
% (sum(counts.values()), declared))
foliage = 0
for (typ, obj), n in sorted(counts.items(), key=lambda kv: -kv[1]):
nm = enum.get(obj, "?")
tag = " <== FOLIAGE" if FOLIAGE_RE.match(nm) else ""
if tag:
foliage += n
print(" %4d %-22s obj=%4d %s%s" % (n, typ, obj, nm, tag))
print(" FOLIAGE PROPS PLACED: %d" % foliage)
if __name__ == "__main__":
main(sys.argv)
+308
View File
@@ -0,0 +1,308 @@
#!/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))
+122
View File
@@ -0,0 +1,122 @@
#!/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))