#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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c50236a5e6
commit
56f15b569a
@@ -0,0 +1,73 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""#98 pixel proof: locate the FLASHING lamp by temporal variance.
|
||||
|
||||
A flashing lamp alternates between two appearances, so across frames of an
|
||||
otherwise-static cockpit it is the pixels that CHANGE. Comparing a leaking run
|
||||
against an undamaged control run localises the flash without anyone having to
|
||||
guess panel coordinates first.
|
||||
"""
|
||||
import glob, sys
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# The 2D cockpit panels only -- excludes the 3D viewport so world/particle
|
||||
# motion cannot be mistaken for a lamp. (Viewport is ~x 280-1170, y 225-660.)
|
||||
PANEL_LEFT = (slice(0, 300), slice(0, 330)) # (rows, cols) -- the coolant panel
|
||||
|
||||
def frames(prefix, take=40):
|
||||
# the last frame is often truncated (the process is killed mid-write), and a
|
||||
# corrupt frame must not be mistaken for change -- skip anything unreadable
|
||||
fs = sorted(glob.glob("lamp_%s_*.png" % prefix))
|
||||
out = []
|
||||
for f in reversed(fs):
|
||||
if len(out) >= take:
|
||||
break
|
||||
try:
|
||||
out.append(np.asarray(Image.open(f).convert("L"), dtype=np.float32))
|
||||
except Exception:
|
||||
continue
|
||||
if not out:
|
||||
raise SystemExit("no readable frames for %s" % prefix)
|
||||
return np.stack(out)
|
||||
|
||||
|
||||
def report(prefix):
|
||||
a = frames(prefix)
|
||||
print("%-6s %d frames, %s" % (prefix, a.shape[0], a.shape[1:]))
|
||||
sd = a.std(axis=0) # per-pixel temporal std-dev
|
||||
panel = sd[PANEL_LEFT]
|
||||
print(" coolant-panel std: mean=%.3f max=%.1f pixels>10: %d"
|
||||
% (panel.mean(), panel.max(), int((panel > 10).sum())))
|
||||
if panel.max() > 10:
|
||||
ys, xs = np.where(panel > 10)
|
||||
print(" hotspot bbox rows %d-%d, cols %d-%d (centroid %d,%d)"
|
||||
% (ys.min(), ys.max(), xs.min(), xs.max(), int(ys.mean()), int(xs.mean())))
|
||||
return sd
|
||||
|
||||
|
||||
print("=" * 64)
|
||||
leak = report("leak")
|
||||
print()
|
||||
ctl = report("ctl")
|
||||
|
||||
print("\n" + "=" * 64)
|
||||
pl, pc = leak[PANEL_LEFT], ctl[PANEL_LEFT]
|
||||
print("VERDICT")
|
||||
print(" leaking run : %d panel pixels flashing (std>10)" % int((pl > 10).sum()))
|
||||
print(" control run : %d panel pixels flashing (std>10)" % int((pc > 10).sum()))
|
||||
if (pl > 10).sum() > 0 and (pc > 10).sum() == 0:
|
||||
print(" -> a lamp flashes ONLY when a condenser is leaking. PASS")
|
||||
elif (pl > 10).sum() == 0:
|
||||
print(" -> nothing flashes on the leaking run. FAIL")
|
||||
else:
|
||||
print(" -> both runs show panel motion; inspect the bboxes above")
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# #98 PIXEL PROOF: does a leaking condenser's cooling-loop button visibly FLASH?
|
||||
#
|
||||
# The log proves the lamp RESOLVES and SetAlertState(True) is called. It does
|
||||
# not prove a pilot sees anything. A flash is a lamp alternating between two
|
||||
# appearances, so it shows up as TEMPORAL VARIANCE in that button's pixels --
|
||||
# and nowhere else on a static cockpit.
|
||||
#
|
||||
# Two legs, identical except for the damage:
|
||||
# leak -- condenser 6 leaking (dz_rarm)
|
||||
# ctl -- no self-damage at all
|
||||
# Frames are captured from the cockpit view (the player's actual view) and
|
||||
# compared by per-pixel variance across frames in leaklamp_pixel.py.
|
||||
#
|
||||
# PASS = a variance hotspot in the coolant panel on the leak leg that is absent
|
||||
# on the control leg.
|
||||
set -x
|
||||
. /c/git/bt411/scratchpad/night6/bench_common.sh
|
||||
cd /c/git/bt411/content || exit 1
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
sed "s/^map=.*/map=grass/; s/^time=.*/time=day/" MP.EGG > LEAK.EGG
|
||||
|
||||
LEG="${1:-leak}"
|
||||
rm -f lamp_${LEG}_*.png "leakpix_${LEG}.log"
|
||||
|
||||
# (bt_launch is a shell FUNCTION -- export into the environment, do not `env`.)
|
||||
if [ "$LEG" = "leak" ]; then
|
||||
export BT_SELF_DAMAGE=4 BT_SELF_DAMAGE_ZONE=dz_rarm
|
||||
else
|
||||
unset BT_SELF_DAMAGE BT_SELF_DAMAGE_ZONE
|
||||
fi
|
||||
# Stand still and do nothing but leak: no autofire, no enemy, no motion, so the
|
||||
# ONLY thing changing in the cockpit is the lamp we are testing.
|
||||
export BT_LAMP_LOG=1
|
||||
export BT_SHOT_EVERY=12 BT_SHOT_PREFIX=lamp_${LEG}
|
||||
bt_launch "leakpix_${LEG}.log" LEAK.EGG 0x03
|
||||
|
||||
sleep 100
|
||||
taskkill //F //IM btl4.exe > /dev/null 2>&1
|
||||
sleep 2
|
||||
echo "=== lamp resolution ==="
|
||||
grep -E "^\[galarm\] condition 2" "leakpix_${LEG}.log" | sort -u
|
||||
echo "=== frames ==="
|
||||
ls lamp_${LEG}_*.png 2>/dev/null | wc -l
|
||||
Reference in New Issue
Block a user