diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index 7defb72..0c28a1c 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -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) diff --git a/scratchpad/destest.py b/scratchpad/destest.py new file mode 100644 index 0000000..495629e --- /dev/null +++ b/scratchpad/destest.py @@ -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")) diff --git a/scratchpad/sim3.py b/scratchpad/sim3.py new file mode 100644 index 0000000..f8f17e2 --- /dev/null +++ b/scratchpad/sim3.py @@ -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))