Files
BT411/scratchpad/valvetest.py
T
arcattackandClaude Opus 5 2dd4ee3910 ROOT CAUSE: target designation wrote a Mech* into deathPending, disabling respawn (#57/#48)
MEASURED our compiled BTPlayer layout (new one-shot [layout] ctor diagnostic):
  sizeof=652 (0x28c)  killCount@0x270  pad_0x280@0x274
  objectiveMech@0x278  deathPending@0x284

The three target-designation sites wrote RAW at pilotArray[0]+0x284 intending the
BINARY's objectiveMech.  On our object 0x284 is deathPending -- the death-cycle
latch.  So EVERY target designation (the Comm bank 0x30-0x37, or the typed
t/y/u/i/o keys) stored a Mech pointer into deathPending, and a non-zero
deathPending makes VehicleDeadMessageHandler take its dedup early-return, which
skips the warp, ++deathCount, the PLAYER_DEAD record AND the drop-zone hunt.

=> designating a target silently disabled your respawn for the rest of the
   process.  That is #57's missing first cause: it explains David's very first
   death being swallowed with no prior death, and the 3-of-3 swallowed deaths in
   the final round.  It also explains the operator's persistent observation that
   the trouble began when the comms panel went live -- before the pilot list
   populated, there was nothing to designate.
   And the designation never worked either: the value never reached
   objectiveMech (input-audit finding #1).

FIX: all three sites now use named-member bridges in the complete-BTPlayer TU --
BTPilotSetObjectiveMech / BTPilotObjectiveMech / BTPilotVehicle / BTPilotPosition
/ BTVehicleDestroyed.  Also retires the raw +0x1fc (playerVehicle) reads and the
raw +0x100 position reads in ChooseNearestPilot, and routes its destroyed-check
through the real Mech predicate instead of the Is_Destroyed stub.

LAYOUT LOCKS: static_asserts on objectiveMech@0x278, deathPending@0x284,
killCount@0x270 and sizeof==0x28c, in the friend fn BTPlayerLayoutSelfCheck, so
a member shift fails the BUILD instead of silently clobbering the latch again.

Rigs: scratchpad/commstest.py (drives designation + captures panels),
valvetest.py, lampgallery.py (428-bitmap gallery for #48 visual search).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 08:33:22 -05:00

94 lines
3.0 KiB
Python

"""#48 solo test: does moving a coolant VALVE after the coolant BARS have
repainted leave a stuck XOR block on the Heat panel?
Hypothesis (from operator_1.log): VertNormalSlider paints its indicator with an
XOR fill and "erases" by XORing the same spot. Firing drops CoolantMass, which
repaints the coolant vertBars over the valve track; a later valve move then XORs
pixels that are no longer the indicator -> a permanently stuck block.
Sequence: autofire (coolant falls) + periodic 'C' valve presses, capturing the
composite each cycle, then diff the Heat panel region for pixels that turn ON and
STAY on.
Kills only the PID it spawns.
"""
import ctypes
import os
import shutil
import subprocess
import sys
import time
KEYUP = 0x0002
VK_C = ord('C')
user32 = ctypes.windll.user32
REPO = r"C:\git\bt411"
CONTENT = os.path.join(REPO, "content")
EXE = os.path.join(REPO, "build", "Release", "btl4.exe")
SHOT = os.path.join(REPO, "scratchpad_valve.png")
LOG = os.path.join(REPO, "scratchpad_valve.log")
OUT = os.path.join(REPO, "scratchpad", "valve")
os.makedirs(OUT, exist_ok=True)
for f in (SHOT, LOG):
if os.path.exists(f):
os.remove(f)
env = dict(os.environ)
env.update({
"BT_START_INSIDE": "1",
"BT_KEY_NOFOCUS": "1",
"BT_DEV_GAUGES": "1", # cockpit surround -> BT_SHOT captures the panels
"BT_AUTOFIRE": "1", # hold the trigger: coolant mass falls, bars repaint
"BT_SHOT": SHOT,
"BT_LOG": LOG,
})
proc = subprocess.Popen([EXE, "-egg", "LAST.EGG"], cwd=CONTENT, env=env)
print("pid", proc.pid)
def tap(vk, hold=0.12):
user32.keybd_event(vk, 0, 0, 0)
time.sleep(hold)
user32.keybd_event(vk, 0, KEYUP, 0)
frames = []
try:
# wait for the mission (the shot file only appears once rendering starts)
deadline = time.time() + 220
while time.time() < deadline:
if os.path.exists(SHOT) and os.path.getsize(SHOT) > 20000:
break
time.sleep(2)
else:
print("FAIL: never rendered"); sys.exit(2)
time.sleep(6)
for cycle in range(6):
# let autofire burn coolant so the bars repaint
time.sleep(7)
dst = os.path.join(OUT, "f%d_before_valve.png" % cycle)
try:
shutil.copy(SHOT, dst); frames.append(dst)
except Exception as e:
print(" copy failed:", e)
# move a coolant valve -> XOR erase at the old row
tap(VK_C)
print(" cycle %d: valve tap sent" % cycle)
time.sleep(4)
dst = os.path.join(OUT, "f%d_after_valve.png" % cycle)
try:
shutil.copy(SHOT, dst); frames.append(dst)
except Exception as e:
print(" copy failed:", e)
finally:
proc.terminate()
time.sleep(1)
print("\ncaptured %d frames in %s" % (len(frames), OUT))
txt = open(LOG, errors="replace").read() if os.path.exists(LOG) else ""
import re
print("[valve] events :", len(re.findall(r"^\[valve\]", txt, re.M)))
for l in re.findall(r"^\[valve\].*$", txt, re.M)[:8]:
print(" ", l)
print("[emitter] FIRED:", len(re.findall(r"\[emitter\] FIRED", txt)))