Files
James Dansereau 97c7e38845 D320: resolve sweep via forced repro — ai_19/ai_9 pins confirmed, ai_12 clean; watchdog generalized (table-driven + uncapped repeat rescue); D318 N64-manifestation corrected (unverified; 1964 deliberate-timing A/B armed)
Also commits the prior session's carried-uncommitted set: D294 room-pool
fix (sqrt + 2x cap, user-verified state), D324 stdlib-shim prototypes,
D323 explicit-LOD census + tools_pc tooling, GRAPHICS-BACKLOG row.
texdump*/ probe artifacts gitignored.
2026-09-22 22:30:32 -04:00

189 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""D294: offline dump + structural analysis of a level's bg portal graph.
Decodes `bg/bg_<level>_all_p.seg` straight out of the ROM (same pipeline as
bg_gdl_census.py) and walks the portal table that `bg.c` uses for its
per-frame room-visibility walk (`bgDetermineVisibleRooms` ->
`sub_GAME_7F0B7F84` / `sub_GAME_7F0B5864`).
File layout (decompressed .seg, big-endian):
word[0] format flag (0 = normal path)
word[1] offset of the bg_room_data array (24 B/record; see bg_gdl_census)
word[2] offset of the g_BgPortals table: 8-byte records
u32 offset_portal (segment-0x0F file offset of the point data,
0 = end of table)
u8 connectedRoom1
u8 connectedRoom2
u8 controlbytes1 (bit0 PORTALFLAG_DISABLED, bit1 _SPECIAL)
u8 controlbytes2
word[3] envdata table offset (not needed here)
Point data at `offset_portal & 0x00FFFFFF` (bg_portal_entry):
u8 numPoints; u8 pad[3]; then numPoints * coord3d (f32 x,y,z).
The analysis section checks the walk's hard limits against the graph:
* portal count vs PORTMAX (200, src/game/bg.h)
* undirected BFS depth from every room vs the walk's `depth >= 16` /
D_8004489C (=0xF) cut-offs (sub_GAME_7F0B7F84)
* per-room portal degree vs the `bgIncrementRoomPortalVisitCount >= 9`
enqueue drop (bgQueuePortalTraversal, depth >= 2)
* worst-case single-frame enqueue volume vs BG_PORTAL_QUEUE_LEN (500 NTSC /
250 EU) -- a rough upper bound: every portal enqueued once per endpoint
room visit.
Usage: python tools_pc/bg_portals.py stat [ntsc-final]
"""
import os
import struct
import sys
import collections
import contextlib
import io
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "tools_pc"))
PORTMAX = 200
QUEUE_LEN_NTSC = 500
QUEUE_LEN_EU = 250
def load_bg_file(name, region):
sys.argv = ["", region]
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
import d43_emit as m
key, row = m.find_row("bg_%s_all_p" % name)
if not key:
raise SystemExit("not in ROM file table: bg_%s_all_p" % name)
addr, size = row
raw = m.rom[addr:addr + size]
import zlib
try:
return zlib.decompress(raw[2:], -15)
except zlib.error:
return raw
def seg_off(v):
return v & 0x00FFFFFF
def main():
level = sys.argv[1] if len(sys.argv) > 1 else "stat"
region = sys.argv[2] if len(sys.argv) > 2 else "ntsc-final"
data = load_bg_file(level, region)
w0, roomlist_off, portal_off = struct.unpack_from(">III", data, 0)
print("word0=%#x rooms@%#x portals@%#x filelen=%d" % (w0, seg_off(roomlist_off), seg_off(portal_off), len(data)))
# --- room array (24 B records; same walk as bg_gdl_census.read_rooms) ---
nrooms = 0
off = seg_off(roomlist_off)
while off + 24 <= len(data):
point, pri, sec = struct.unpack_from(">III", data, off)
if nrooms > 1 and point == 0 and pri == 0 and sec == 0:
break
nrooms += 1
off += 24
print("rooms: %d" % nrooms)
# --- portal table (8 B records, N64 stride; offset_portal==0 terminates) ---
portals = []
poff = seg_off(portal_off)
while poff + 8 <= len(data):
optr, r1, r2, cb1, cb2 = struct.unpack_from(">IBBBB", data, poff)
if optr == 0:
break
portals.append((optr & 0x00FFFFFF, r1, r2, cb1, cb2))
poff += 8
print("portals: %d (PORTMAX=%d%s)" % (len(portals), PORTMAX, " <-- OVER" if len(portals) >= PORTMAX else ""))
# --- point data per portal ---
adj = collections.defaultdict(list) # room -> [portal index]
bad = 0
for i, (poff2, r1, r2, cb1, cb2) in enumerate(portals):
if poff2 + 4 > len(data):
print("portal %d: point data %#x past EOF" % (i, poff2))
bad += 1
continue
npts = data[poff2]
pts = struct.unpack_from(">%df" % (3 * npts), data, poff2 + 4) if npts else ()
for r in (r1, r2):
adj[r].append(i)
if bad:
print("WARNING: %d portals with unreadable point data" % bad)
# flag census
flags = collections.Counter()
for i, (_, _, _, cb1, _) in enumerate(portals):
f = []
if cb1 & 0x01:
f.append("DIS")
if cb1 & 0x02:
f.append("SPE")
flags["+".join(f) or "-"] += 1
print("flags:", dict(flags))
# point-count census (sub_GAME_7F0B5864's points[19] holds numPoints +
# near-plane clip points; >12 authored points is close to the edge)
ptc = collections.Counter()
for i, (poff2, _, _, _, _) in enumerate(portals):
if poff2 + 4 <= len(data):
ptc[data[poff2]] += 1
print("numPoints census:", dict(sorted(ptc.items())))
# per-room degree vs the visit-count limit of 9
hot = [(r, len(ps)) for r, ps in sorted(adj.items()) if len(ps) >= 9]
if hot:
print("rooms with >=9 portals (visit-count drop risk at depth>=2): %s" % hot)
else:
print("max room portal degree: %d (limit 9)" % max((len(p) for p in adj.values()), default=0))
# undirected BFS min-depth from every room
rooms = sorted(adj.keys())
depth_from = {}
for s in rooms:
d = {s: 0}
q = collections.deque([s])
while q:
u = q.popleft()
for pi in adj[u]:
_, r1, r2, _, _ = portals[pi]
v = r1 ^ r2 ^ u
if v not in d:
d[v] = d[u] + 1
q.append(v)
depth_from[s] = d
worst = []
for s in rooms:
d = depth_from[s]
missing = [r for r in rooms if r not in d]
maxd = max(d.values()) if d else -1
if maxd >= 15 or missing:
worst.append((s, maxd, missing))
if worst:
print("rooms unreachable (<16) from some start room (depth>=15 or disconnected):")
for s, maxd, missing in worst:
print(" from room %d: maxdepth=%d missing=%s" % (s, maxd, missing))
else:
allmax = max(max(d.values()) for d in depth_from.values())
print("all rooms pairwise reachable; max BFS depth over all start rooms: %d (walk cut-off 16)" % allmax)
# worst-case enqueue volume: each portal can be enqueued once per frame
# from each endpoint room's visit; the walk visits a room at most once per
# portal path... conservative bound = 2 * nportals.
print("conservative enqueue bound 2*P = %d vs queue %d (NTSC) / %d (EU)" %
(2 * len(portals), QUEUE_LEN_NTSC, QUEUE_LEN_EU))
# dump the full portal table for eyeballing
print()
print("%-4s %-6s %-6s %-4s %-4s %-3s points" % ("idx", "room1", "room2", "cb1", "cb2", "n"))
for i, (poff2, r1, r2, cb1, cb2) in enumerate(portals):
npts = data[poff2] if poff2 + 4 <= len(data) else -1
print("%-4d %-6d %-6d %#-5x %#-5x %-3d" % (i, r1, r2, cb1, cb2, npts))
if __name__ == "__main__":
main()