Files
BT411/scratchpad/destest.py
T
arcattackandClaude Opus 5 2518e43719 Gitea #59: the first death permanently disabled ExecuteWatchers -- simulationFlags |= 0x1 should be ForceUpdate()
Verified the claim in the binary myself with capstone before changing behaviour:
  0x4c0155:  or word ptr [ebx + 0x18], 1
+0x18 is Simulation::updateModel, a **Word** -- and the 16-bit 'or word ptr'
settles it, since simulationFlags is an LWord at +0x28 and would assemble as
'or dword ptr'.  So the authentic op is updateModel |= DefaultUpdateModelFlag,
which is exactly Simulation::ForceUpdate() (SIMULATE.h:146-147).

The old transcription wrote simulationFlags |= 0x1 instead.  Bit 0 there is
DelayWatchersFlag (SIMULATE.h:170); Simulation::Simulate then skips
ExecuteWatchers() forever (SIMULATE.cpp:461), and NOTHING clears it -- the only
ClearWatcherDelay() in the tree is in the encore path (UPDATE.cpp:215), which a
Player never takes.  So every pilot's first death permanently disabled watcher
execution on their simulation, and the replication mark the binary intended was
never set at all.  context/reconstruction-gotchas.md had flagged this exact line
as the un-audited sibling of the #12 dirty-bit class, asking for the disasm
first; that is now on the record.

LIVE VERIFICATION (scratchpad/sim3.py, 3 mechs + BT_MP_FORCE_DMG, 180s):
4 consecutive death/respawn cycles on pod3, every one completing --
  death cycle START (death #N) -> RESET at drop zone [COMPLETE]
with 'player watchersDelayed=0' after every death, and no crash.  Also
re-confirms the #57 latch fix under repeated deaths (the interleaved SWALLOWED
lines are the authentic dedup of a second notification, each followed by a
completed RESET).

Leaves a silent regression guard: the death path now logs only if it ever finds
the watcher-delay flag set again.

Rigs: sim3.py (3-mech combat + force damage), destest.py (2-node designation
proof: 18 designations, deathPending clean on all of them).

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

95 lines
3.3 KiB
Python

"""Decisive test of the 0x284 fix: with TWO pilots (so the designation sites are
reachable), press the target-designation keys and confirm the write lands on
objectiveMech while deathPending stays 0.
Before the fix, `*(int*)(pilot+0x284) = target` stored a Mech* into deathPending
and silently disabled the pilot's respawn for the rest of the process.
Kills only the PIDs it spawns.
"""
import ctypes
import os
import re
import subprocess
import time
KEYUP = 0x0002
user32 = ctypes.windll.user32
REPO = r"C:\git\bt411"
CONTENT = os.path.join(REPO, "content")
EXE = os.path.join(REPO, "build", "Release", "btl4.exe")
PORT = 2900
OUT = os.path.join(REPO, "scratchpad", "des")
os.makedirs(OUT, exist_ok=True)
for f in os.listdir(OUT):
try: os.remove(os.path.join(OUT, f))
except Exception: pass
procs = []
base = dict(os.environ)
base.pop("BT_NO_PILOTLIST", None)
p = subprocess.Popen(["python", os.path.join(REPO, "tools", "btconsole.py"),
"--relay", str(PORT), "MP_BHMC.EGG", "--bind", "127.0.0.1"],
cwd=CONTENT, env=base,
stdout=open(os.path.join(OUT, "relay.log"), "w"),
stderr=subprocess.STDOUT)
procs.append(p); print("relay pid", p.pid)
time.sleep(3)
for i, tag in ((2, "1602"), (1, "1502")):
e = dict(base)
e.update({
"BT_RELAY": "127.0.0.1:%d" % PORT,
"BT_SELF": "127.0.0.1:%s" % tag,
"BT_START_INSIDE": "1",
"BT_SCORE_LOG": "1",
"BT_LOG": os.path.join(OUT, "pod%d.log" % i),
})
if i == 1:
e["BT_KEY_NOFOCUS"] = "1"
port = 1501 if i == 1 else 1601
q = subprocess.Popen([EXE, "-egg", "MP_BHMC.EGG", "-net", str(port)],
cwd=CONTENT, env=e,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
procs.append(q); print("pod%d pid %d" % (i, q.pid))
time.sleep(1.5)
A = os.path.join(OUT, "pod1.log")
try:
# wait until pod1's roster has both pilots (designation needs pilotCount > 1)
deadline = time.time() + 240
while time.time() < deadline:
if os.path.exists(A):
t = open(A, errors="replace").read()
if re.search(r"roster built: [2-9]", t):
break
time.sleep(3)
print("roster has 2+ pilots; sending designations")
time.sleep(4)
for rnd in range(4):
for ch in "tyuio":
vk = ord(ch.upper())
user32.keybd_event(vk, 0, 0, 0); time.sleep(0.09)
user32.keybd_event(vk, 0, KEYUP, 0); time.sleep(0.12)
time.sleep(3)
print(" round %d sent" % (rnd + 1))
finally:
for q in procs:
try: q.terminate()
except Exception: pass
time.sleep(2)
t = open(A, errors="replace").read() if os.path.exists(A) else ""
des = re.findall(r"^\[score\] designate:.*$", t, re.M)
print("\n=========== RESULT ===========")
print("roster lines :", re.findall(r"roster built: \d+", t)[-1:] or "none")
print("designate lines :", len(des))
for l in des[:8]:
print(" ", l)
bad = [l for l in des if "LATCH SET" in l]
print("\nlatch-corrupting designations:", len(bad))
print("VERDICT:", "PASS -- designation no longer touches deathPending"
if des and not bad else
("FAIL -- latch still being set" if bad else "INCONCLUSIVE -- designation never ran"))