THE GAP: Mech::Reset sweeps every subsystem with the virtual DeathReset (mech4.cpp:1789), but only 7 weapon/ammo classes overrode it -- the entire heat/coolant/power family fell through to the empty Subsystem::DeathReset base (SUBSYSTM.h:161), so the respawn reset was a SILENT NO-OP for them. All the ResetToInitialState bodies already existed; nothing ever called them. Hence the field report: 'respawned with drained coolant'. Added DeathReset forwarders (ResetToInitialState is not virtual, so each class with its own body needs one): HeatableSubsystem, HeatSink, Condenser, PoweredSubsystem, Generator. HeatSink's also serves Reservoir -- the coolant tank -- which has no body of its own; that is the one that refills coolant. TWO MIS-TRANSCRIPTIONS FIXED, both verified against the binary with capstone: 1. HeatSink::ResetToInitialState chained HeatableSubsystem::ResetToInitialState with the comment 'FUN_004ac22c'. But @004ac22c is Subsystem's terminus -- disassembled: it reads the damage zone at this+0xe0, clears zone+0x158, and Set_Alarm_Levels zone+0x10 and this+0x2c to 0. And the binary's HeatSink @004ad760 calls only @004ad884 (ClearHeatFilter), @004ad7f0 (UpdateHeatLoad) and @004ac22c -- never HeatableSubsystem. Worse, HeatableSubsystem's body sets currentTemperature = 300.0f, CLOBBERING the startingTemperature that HeatSink assigned four lines earlier. Dropped the call; the terminus work is already done for every subsystem by MechSubsystem::RespawnRepair (mech4.cpp:1790). 2. Generator::ResetToInitialState chained HeatableSubsystem and set outputVoltage = 0 -- which matches GNRATOR.TCP, but BT's BINARY DIVERGES from the TCP source, and the binary is what shipped. Disassembled @004b215c instruction by instruction: it chains HeatSink (so the coolant refill DOES run for a generator), then generatorOn=1, startTimer=0, stateAlarm 0 then 2, **outputVoltage = ratedVoltage**, coolantAvailable=1, coolantFlowScale=1.0, startTimer=startTime. Every offset matches our declared members exactly. The old version brought generators back from a respawn at ZERO output voltage, so energy weapons could never charge -- no recharge ring, no ready dot, nothing damaged. That is a strong candidate for #54 (David's three dead lasers). LIVE VERIFICATION (sim3.py, 3 mechs + force damage): 4 death/respawn cycles, and every heat sink logged coolant == capacity on every respawn, with temp restored to startingTemperature (77) rather than the clobbered 300. Left as a silent regression guard that only speaks if a respawn leaves coolant short. KNOWN REMAINING [T3, noted in code]: Condenser's body chains HeatableSubsystem rather than HeatSink, so a coolant loop's VALVE DETENT may still persist across a respawn -- the binary's Condenser reset is not yet decompiled. Do not claim valves are fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
142 lines
5.3 KiB
Python
142 lines
5.3 KiB
Python
"""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",
|
|
"BT_HEAT_LOG": "1", # coolant/heat state (Gitea #55 probe) # 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))
|