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
This commit is contained in:
co-authored by
Claude Opus 5
parent
2dd4ee3910
commit
2518e43719
@@ -483,7 +483,38 @@ void
|
||||
scenarioRole->GetReturnFromDeath() - 1);
|
||||
}
|
||||
|
||||
simulationFlags |= 0x1; // request a forced update
|
||||
//
|
||||
// MARK FOR REPLICATION (Gitea #59). The binary's instruction here is
|
||||
// `or word ptr [ebx + 0x18], 1` (verified with capstone at 0x4c0155). Offset
|
||||
// +0x18 is `Simulation::updateModel`, a **Word**, and the 16-bit `or word ptr`
|
||||
// confirms it -- `simulationFlags` is an LWord at +0x28 and would assemble as
|
||||
// `or dword ptr`. So the authentic operation is
|
||||
// `updateModel |= DefaultUpdateModelFlag`, which IS exactly
|
||||
// `Simulation::ForceUpdate()` (SIMULATE.h:146-147).
|
||||
//
|
||||
// The old transcription wrote `simulationFlags |= 0x1` instead. Bit 0 of
|
||||
// simulationFlags is **`DelayWatchersFlag`** (SIMULATE.h:170), and
|
||||
// `Simulation::Simulate` skips `ExecuteWatchers()` for good whenever it is set
|
||||
// (SIMULATE.cpp:461). Nothing clears it -- the only `ClearWatcherDelay()` in
|
||||
// the tree is inside the encore path (UPDATE.cpp:215), which a Player never
|
||||
// takes. Net effect: **the first death of any pilot permanently disabled
|
||||
// watcher execution on that player's simulation**, and the replication mark
|
||||
// the binary intended was never set at all.
|
||||
//
|
||||
// (`context/reconstruction-gotchas.md` flagged this exact line as the one
|
||||
// un-audited sibling of the #12 dirty-bit mis-mapping class, asking for the
|
||||
// disasm before changing it. That is the disasm.)
|
||||
//
|
||||
ForceUpdate(); // updateModel |= DefaultUpdateModelFlag
|
||||
// Gitea #59 regression guard. The PLAYER's watcher-delay flag must never be
|
||||
// set by the death path again -- the old `simulationFlags |= 0x1` set it and
|
||||
// nothing in the tree clears it, permanently killing ExecuteWatchers for this
|
||||
// Player. Silent unless the bug returns. Verified live 2026-07-25:
|
||||
// watchersDelayed=0 across 4 consecutive death/respawn cycles.
|
||||
if (AreWatchersDelayed())
|
||||
DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this "
|
||||
"player's watchers DELAYED -- ExecuteWatchers is dead for it now\n"
|
||||
<< std::flush;
|
||||
Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1)
|
||||
|
||||
//
|
||||
@@ -1647,7 +1678,16 @@ void BTPilotSetObjectiveMech(void *pilot, void *target_vehicle)
|
||||
{
|
||||
if (pilot == 0)
|
||||
return;
|
||||
((BTPlayer *)pilot)->objectiveMech = (Mech *)target_vehicle;
|
||||
BTPlayer *p = (BTPlayer *)pilot;
|
||||
p->objectiveMech = (Mech *)target_vehicle;
|
||||
// PROOF LINE for the 0x284 bug (Gitea #48/#57): the designation must land on
|
||||
// objectiveMech and must leave deathPending ALONE. Before the fix this wrote
|
||||
// a Mech* into deathPending and killed the pilot's respawn for the session.
|
||||
if (getenv("BT_SCORE_LOG"))
|
||||
DEBUG_STREAM << "[score] designate: objectiveMech=" << (void *)p->objectiveMech
|
||||
<< " deathPending=" << p->deathPending
|
||||
<< (p->deathPending == 0 ? " (clean)" : " *** LATCH SET -- BUG ***")
|
||||
<< "\n" << std::flush;
|
||||
}
|
||||
|
||||
void *BTPilotObjectiveMech(void *pilot)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,140 @@
|
||||
"""3-mech live combat simulation -- verify the designation fix end to end.
|
||||
|
||||
Three pods on one relay, each driving at the enemy with the trigger held, so they
|
||||
actually converge, shoot, kill and respawn. What this proves or disproves:
|
||||
|
||||
* #57 every own-death should now produce a PLAYER_DEAD record and a completed
|
||||
respawn ([respawn] ... RESET ... latch cleared). Before the fix, any
|
||||
target designation set deathPending and the death was swallowed.
|
||||
* the SWALLOWED warning must NOT appear -- if it does, a latch is still sticking.
|
||||
* #45 kills/deaths still expected WRONG cross-node (not fixed yet) -- record
|
||||
what each node thinks, as the baseline for that work.
|
||||
* stability: no crash across repeated death/respawn cycles.
|
||||
|
||||
Kills only the PIDs it spawns.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
REPO = r"C:\git\bt411"
|
||||
CONTENT = os.path.join(REPO, "content")
|
||||
EXE = os.path.join(REPO, "build", "Release", "btl4.exe")
|
||||
EGG = "_sim3.egg"
|
||||
RELAY_PORT = 2800
|
||||
OUT = os.path.join(REPO, "scratchpad", "sim3")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for f in os.listdir(OUT):
|
||||
try: os.remove(os.path.join(OUT, f))
|
||||
except Exception: pass
|
||||
|
||||
RUN_SECONDS = int(sys.argv[1]) if len(sys.argv) > 1 else 260
|
||||
|
||||
procs = []
|
||||
def spawn(cmd, env, tag):
|
||||
p = subprocess.Popen(cmd, cwd=CONTENT, env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
procs.append((tag, p))
|
||||
print(" %-8s pid %d" % (tag, p.pid))
|
||||
return p
|
||||
|
||||
base = dict(os.environ)
|
||||
base.pop("BT_NO_PILOTLIST", None)
|
||||
|
||||
# relay
|
||||
renv = dict(base)
|
||||
relay_log = os.path.join(OUT, "relay.log")
|
||||
p = subprocess.Popen(
|
||||
["python", os.path.join(REPO, "tools", "btconsole.py"),
|
||||
"--relay", str(RELAY_PORT), EGG, "--bind", "127.0.0.1"],
|
||||
cwd=CONTENT, env=renv,
|
||||
stdout=open(relay_log, "w"), stderr=subprocess.STDOUT)
|
||||
procs.append(("relay", p))
|
||||
print(" relay pid %d" % p.pid)
|
||||
time.sleep(3)
|
||||
|
||||
# three combatants; #1 is the observer (gauges + screenshots)
|
||||
for i in (1, 2, 3):
|
||||
e = dict(base)
|
||||
e.update({
|
||||
"BT_RELAY": "127.0.0.1:%d" % RELAY_PORT,
|
||||
"BT_SELF": "10.99.0.%d:1502" % i,
|
||||
"BT_START_INSIDE": "1",
|
||||
"BT_GOTO": "enemy", # close on the nearest enemy
|
||||
"BT_AUTOFIRE": "1", # hold the trigger
|
||||
"BT_MATCHLOG": "1",
|
||||
"BT_SCORE_LOG": "1",
|
||||
"BT_AUDIO_LOG": "1", # exposes AreWatchersDelayed (Gitea #59 probe)
|
||||
"BT_LOG": os.path.join(OUT, "pod%d.log" % i),
|
||||
})
|
||||
if i == 2:
|
||||
e["BT_MP_FORCE_DMG"] = "1" # fast kills: hit the victim once a second
|
||||
if i == 1:
|
||||
e["BT_DEV_GAUGES"] = "1"
|
||||
e["BT_SHOT"] = os.path.join(REPO, "scratchpad_sim3.png")
|
||||
spawn([EXE, "-egg", EGG, "-net", "1%d01" % i], e, "pod%d" % i)
|
||||
time.sleep(1.5)
|
||||
|
||||
print("\nrunning %ds ..." % RUN_SECONDS)
|
||||
t0 = time.time()
|
||||
shots = 0
|
||||
try:
|
||||
while time.time() - t0 < RUN_SECONDS:
|
||||
time.sleep(20)
|
||||
src = os.path.join(REPO, "scratchpad_sim3.png")
|
||||
if os.path.exists(src):
|
||||
try:
|
||||
shutil.copy(src, os.path.join(OUT, "shot_%03d.png" % shots)); shots += 1
|
||||
except Exception:
|
||||
pass
|
||||
el = int(time.time() - t0)
|
||||
d = 0
|
||||
for i in (1, 2, 3):
|
||||
lp = os.path.join(OUT, "pod%d.log" % i)
|
||||
if os.path.exists(lp):
|
||||
d += len(re.findall(r"death cycle START", open(lp, errors="replace").read()))
|
||||
print(" t=%3ds death-cycles so far: %d" % (el, d))
|
||||
finally:
|
||||
for tag, p in procs:
|
||||
try: p.terminate()
|
||||
except Exception: pass
|
||||
time.sleep(2)
|
||||
|
||||
print("\n================ RESULTS ================")
|
||||
for i in (1, 2, 3):
|
||||
lp = os.path.join(OUT, "pod%d.log" % i)
|
||||
if not os.path.exists(lp):
|
||||
print("pod%d: NO LOG" % i); continue
|
||||
t = open(lp, errors="replace").read()
|
||||
print("\n--- pod%d" % i)
|
||||
print(" death cycle START : %d" % len(re.findall(r"death cycle START", t)))
|
||||
print(" respawn RESET : %d" % len(re.findall(r"RESET at drop zone", t)))
|
||||
print(" SWALLOWED (BAD) : %d" % len(re.findall(r"SWALLOWED", t)))
|
||||
print(" emitter FIRED : %d" % len(re.findall(r"\[emitter\] FIRED", t)))
|
||||
dl = re.findall(r"delayed=(\d+)", t)
|
||||
print(" watchers delayed=1: %d of %d samples (Gitea #59 -- must stay 0)"
|
||||
% (sum(1 for x in dl if x != "0"), len(dl)))
|
||||
print(" DESTROYED : %d" % len(re.findall(r"\*\*\* .* DESTROYED", t)))
|
||||
for pat in (r"^\[respawn\].*$", r"^\[layout\].*$"):
|
||||
for l in re.findall(pat, t, re.M)[:4]:
|
||||
print(" " + l[:120])
|
||||
if re.search(r"exception|access violation|assert", t, re.I):
|
||||
print(" !! fault text present")
|
||||
|
||||
# matchlogs written this run
|
||||
ml = os.path.join(CONTENT, "matchlogs")
|
||||
recent = sorted((os.path.getmtime(os.path.join(ml, f)), f)
|
||||
for f in os.listdir(ml) if os.path.getmtime(os.path.join(ml, f)) > t0)
|
||||
print("\n--- matchlogs from this run: %d" % len(recent))
|
||||
for _m, f in recent:
|
||||
t = open(os.path.join(ml, f), errors="replace").read()
|
||||
print(" %-46s DEATH(M)=%d PLAYER_DEAD=%d RESPAWN=%d SCORE(kill)=%d" % (
|
||||
f[:46],
|
||||
len(re.findall(r"^DEATH.*inst=M", t, re.M)),
|
||||
len(re.findall(r"^PLAYER_DEAD", t, re.M)),
|
||||
len(re.findall(r"^RESPAWN", t, re.M)),
|
||||
len(re.findall(r"^SCORE.*type=2", t, re.M))))
|
||||
print("\nshots: %d in %s" % (shots, OUT))
|
||||
Reference in New Issue
Block a user