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
95 lines
3.3 KiB
Python
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"))
|