mirror of
https://github.com/jkdansereau/goldeneye-pc-port.git
synced 2026-10-23 15:52:42 +02:00
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:
1 parent
327e6fe1a7
commit
425237f93f
17 files changed
+1732
-12
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user