Files
BT411/scratchpad/night8/lamptable_check.py
T
Joe DiPrimaandClaude Opus 5 56f15b569a #98: prove the lamp fix -- table pinned to the image, three condensers verified live
Follows the request to prove the whole fix rather than the one case I had.

1. TABLE, all indices: scratchpad/night8/lamptable_check.py asserts the
   reconstructed arrays against BTL4OPT.EXE and exits nonzero on drift.
       kBTCondenserLamp @0051d058  MATCH  07 2F 2E 2D 2B 2A 29
       kBTPlacementLamp @0051d070  MATCH  29 1A 1B 1C 1D
       kFixed (FUN_004cc148 switch) MATCH  2F 2E 2D 2C 2B 2A
   This covers condensers 2/3/5 without hunting zones for them: there is no
   per-index code path, only the array contents, and those are now pinned.

2. LOOKUP PATH, live: condensers 1, 4 and 6 resolve 0x2F, 0x2B and 0x29 --
   exactly the table.  Condenser 4 is the meaningful control: my reverted "fix"
   would have given 0x2C.  Myomers (eng-page path, 0x25/0x21) and SRM6_1 (quad
   button, 0xd) still annunciate, so other subsystem classes are unregressed.

3. GUARD: condenserNumber is parsed from the name's trailing digit
   (NameTrailingNumber, mirroring the binary's atoi), and every condenser in
   BTL4.RES is Condenser1..Condenser6 -- no Condenser0, no bare name.  So the
   1..6 guard covers every shipped case and slot 0 (0x7) is unreachable.  This
   was the real risk in changing `n >= 0` to `n >= 1`; it is closed.

4. PIXELS: leaklamp_pixel.{sh,py} capture a leaking run and an undamaged control
   run from the cockpit and difference their per-pixel temporal variance.  The
   leak is visibly real -- the COOLANT reservoir drains on screen (S 331->330
   while the control sits at 329).

⚠ WHAT THE PIXELS DID NOT SETTLE, and it is not a testing gap.  Lamp 0x29 is
ALSO kBTPlacementLamp[0]: DAT_0051d058[6] and DAT_0051d070[0] are the SAME int32
-- the two tables abut.  So condenser 6's lamp may not be its own loop button at
all, and slot 6 may be an overrun in the BINARY too (its read is unchecked).
Reproducing it is the faithful choice either way, and we now do exactly what the
binary computes -- but whether a pilot sees loop 6's own button light is a
question only someone who played the original can answer.  Asked on the issue.

CORRECTION: 0b98370 claimed @0051d058 "holds gauge-type name strings, so the
provenance is questionable".  That was MY bug -- a PE section-header field-order
mistake (unpacking VirtualSize/VirtualAddress/SizeOfRawData/PointerToRawData
then destructuring in a different order).  The original reconstruction's
provenance was accurate.  The checker in (1) uses the corrected reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:26:47 -05:00

74 lines
2.8 KiB
Python

"""Assert the reconstructed lamp tables byte-for-byte against BTL4OPT.EXE.
#98: the per-condenser lamp table was "corrected" on a consistency argument and
was wrong. This pins every entry to the image so the next person does not have
to trust a comment -- run it and it either matches or it does not.
"""
import re, struct, sys
EXE = r"C:\git\bt411\content\BTL4OPT.EXE"
SRC = r"C:\git\bt411\game\reconstructed\btl4galm.cpp"
d = open(EXE, "rb").read()
e = struct.unpack_from("<I", d, 0x3C)[0]; coff = e + 4
ns = struct.unpack_from("<H", d, coff + 2)[0]
osz = struct.unpack_from("<H", d, coff + 16)[0]
opt = coff + 20; ib = struct.unpack_from("<I", d, opt + 28)[0]; st = opt + osz
# section header +8: VirtualSize, VirtualAddress, SizeOfRawData, PointerToRawData
_raw = [struct.unpack_from("<IIII", d, st + i * 40 + 8) for i in range(ns)]
secs = [(va, vsize, rawptr, rawsize) for (vsize, va, rawsize, rawptr) in _raw]
def off(va):
r = va - ib
for sva, vsize, rawptr, rawsize in secs:
if sva <= r < sva + max(vsize, rawsize):
return rawptr + (r - sva)
raise SystemExit("VA %08x not mapped" % va)
def image_ints(va, n):
o = off(va)
return [struct.unpack_from("<i", d, o + 4 * i)[0] for i in range(n)]
src = open(SRC, encoding="utf-8", errors="replace").read()
def source_array(name):
m = re.search(r"%s\s*\[\s*\d*\s*\]\s*=\s*\{([^}]*)\}" % re.escape(name), src)
if not m:
raise SystemExit("array %s not found in %s" % (name, SRC))
return [int(x.strip(), 0) for x in m.group(1).split(",") if x.strip()]
CASES = [
# (source array, image VA, how many, what it is)
("kBTCondenserLamp", 0x51D058, 7, "per-condenser lamps (index = condenserNumber, 1-based)"),
("kBTPlacementLamp", 0x51D070, 5, "per-placement lamps (index = auxScreenPlacement)"),
]
bad = 0
for name, va, n, what in CASES:
got = source_array(name)[:n]
want = image_ints(va, n)
ok = got == want
bad += (0 if ok else 1)
print("%-20s @%08x %-6s" % (name, va, "MATCH" if ok else "DIFFER"))
print(" image : %s" % ["0x%X" % v for v in want])
print(" source: %s" % ["0x%X" % v for v in got])
print(" (%s)" % what)
# The coolingLoop switch is code, not a table: FUN_004cc148 maps 0..5.
print("\nkFixed (FUN_004cc148 switch) expected 0x2F 0x2E 0x2D 0x2C 0x2B 0x2A")
m = re.search(r"kFixed\s*\[\s*\d*\s*\]\s*=\s*\{([^}]*)\}", src)
fixed = [int(x.strip(), 0) for x in m.group(1).split(",") if x.strip()]
want = [0x2F, 0x2E, 0x2D, 0x2C, 0x2B, 0x2A]
ok = fixed == want
bad += (0 if ok else 1)
print(" source: %s %s" % (["0x%X" % v for v in fixed], "MATCH" if ok else "DIFFER"))
print("\nNOTE the condenser set (2F 2E 2D 2B 2A 29) and the coolingLoop set")
print("(2F 2E 2D 2C 2B 2A) are DIFFERENT -- overlapping but not interchangeable.")
sys.exit(1 if bad else 0)