Files
BT411/scratchpad/sim3.py
T
arcattackandClaude Fable 5 5ae4410914 Gitea #46: ammo bay fire now DETONATES and KILLS (+ #47 fire icon) -- three stacked kill-switches removed, the fuse decoded, and a vptr-alias corruption caught by regression
Field report (night 3): "two ammo bay fires and no death" (Cyd + RajelAran; one
purged, one left burning).  Root cause = THREE independent kill-switches stacked
on the same path, all in ammobin.cpp:

  1. `GameClock::Now() { return 0; }` -- `cookOffTime < Now()` was `0 < 0`, so
     an ARMED bay fire never detonated.
  2. `InjectHeat(void*) {}` -- the detonation body was a no-op.
  3. The bin's Damage record was never stamped -- 0 damage of type 0 (which the
     mech TakeDamage handler drops) even if 1+2 had fired.

THE FUSE (raw disasm, scratchpad/disammo.py): the old "RandomDelay" was a Ghidra
carve artifact -- FUN_004dcd94 is __ftol and the export DROPPED the caller's x87
expression.  The real bytes @004bd450: fld 10.0 / fmul [ticksPerSecond] /
fadd 0.5 / __ftol -- a FIXED 10.0-SECOND fuse in clock ticks.  New gotcha #19
(reconstruction-gotchas.md) documents the __ftol export blind spot.

THE DAMAGE RECORD: bin+0x1F0..0x21C is a real engine `Damage` (FUN_0041db7c IS
Damage::Damage(), byte-matched to T0 DAMAGE.cpp).  The linked ProjectileWeapon's
ctor @004bc3fc stamps it from weapon->damageData @0x3A8 via
owner->roster[0x128][res+0x1C0] -- projweap.cpp's old comment called this "the
bin's HUD display block ... wired in the AmmoBin family"; both halves were wrong
and it was wired nowhere.  Now stamped (before MissileLauncher's ctor divides by
missileCount -- a missile bin authentically holds the per-SALVO amount).

THE DETONATION (@004ac274 = MechSubsystem::DistributeCriticalHit -- the old
"HeatableSubsystem::InjectHeat" label was wrong, and the old reconstruction
iterated a stand-in CriticalChain whose First()/Next() returned 0):
statusAlarm pulse Exploding(2)->Destroyed(1) (slot 13 = the printSimulationState
state PRINT @004ac8c0, not an "explosion notify"), own private zone pinned
destroyed, then collect the mech DamageZones whose crit entries plug the bin
(the binary filters plug classID 0x4E = DamageZoneClassID -- VDATA.h idx 78,
cross-checked via idx 28 = AudioStateTrigger), split the amount evenly, and send
the OWNER one full Entity::TakeDamageMessage per zone: inflictingEntity = SELF,
damageZone = the zone index, inflictingSubsystemID = the bin (the message-
manager explosion-bundling key, ENTITY3.h's own NOTE), printing the binary's
exact "ammo explosion damaging <zoneName>" @0050df61.
Port shape: Mech::AmmoExplosionFanOut (mechdmg.cpp) behind a databinding bridge;
guarded deviation: zoneCount==0 warns instead of the binary's unguarded divide.
CriticalChain/CriticalEntry stand-ins DELETED from mechrecon.hpp.

VERIFIED LIVE (BT_BAYTEST hook = message 1, the crit-induced arm channel):
  scratchpad/baytest.py : arm -> 10s -> "20 rounds x 35 = 700 (type 2)" ->
    "ammo explosion damaging dz_ltorso" -> zone cascade -> mech DESTROYED
    (authentic death list).
  scratchpad/baypurge.py: arm -> eject-hold purge -> "bay fire EXTINGUISHED
    (bin empty)", no detonation.
  scratchpad/sim3.py    : the HEAT route arms organically in combat (overheated
    AFC100), detonates "11 x 25 = 275 (type 1)" split across dz_larm + dz_lgun.
  BAYBOOM matchlog record added for MP field forensics.

FIX-OF-THE-FIX (caught by the sim3 regression, would have shipped a crash):
MechSubsystem's ReconDamageZone proxy puts structureLevel at OFFSET 0 -- which
ALIASES THE REAL DamageZone's VTABLE POINTER (the private zone is `new
DamageZone`, mechsub.cpp:154; mechsub.hpp:260 documents the alias).  My first
DistributeCriticalHit kept the old body's `damageZone->structureLevel = 1.0f`
and OVERWROTE THE ZONE'S VPTR with 0x3F800000; the respawn sweep's virtual
SetGraphicState (vtable+0xC) then called through it -> AV at 0x3f80000c in
RespawnRepair, one frame after a bay-fire death.  ALL EIGHT proxy-view sites in
mechsub.cpp swept to the engine view (((DamageZone*)damageZone)->damageLevel
@0x158) -- including two silently-wrong LIVE readers: GetStatusFlags (vptr as
float -> always "intact") and ApplyDamageAndMeasure (the crit cascade's
measure).  Ruled out first by evidence: the weapon->bin stamps were all clean
(six stamps, all classID 0xbcb, logged).

#47 (half 1 -- the FIRE ICON): BallisticWeaponCluster::Execute @004c9a38 reads
bin+0x18C = cookOffArmed into the btefire.pcc TwoState, and while armed computes
(Now - cookOffTime)/ticksPerSecond -- the COOK-OFF COUNTDOWN -- into the numeric
beside it.  The old reconstruction misread 0x18C as "the reload state" and
bridged the icon to BTAmmoBinFeeding, so it blinked on every feed and never lit
on a bay fire (RajelAran: "it doesn't").  Now driven by the
BTAmmoBinCookOffArmed/CookOffTime complete-type bridges.  [T2 -- the data path
is rig-verified; the pixels await the next live session.]

#47 (half 2 -- the ENG-BUTTON FLASH): fully mapped, deliberately NOT built this
session.  The authored data SHIPS (BTL4.RES carries exactly one type-31
GaugeAlarmStream); the chain is alarm SetLevel -> gauge-watcher socket ->
Renderer msg 7 -> GaugeAlarmManager::Activate @00448d00 (T0) -> the BTL4
override @004cc148..@004cc2fc (btl4galm.cpp's provenance note claiming "no
override body exists" is WRONG -- corrected in-file) -> LampManager::FindLamp
@00444c80 -> Lamp::SetAlertState @00444e64 (flash counter) -> the L4 flush
@00474e94 emitting flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239).
Missing: the override bodies, the gauge-watcher sender, the aux-button lamps.
3-piece plan in context/open-questions.md.

Also logged: HandleMessage is vtable slot 8/9 in the binary but NON-virtual
across 10 reconstruction classes (bit the BT_BAYTEST hook; typed call used, gap
documented in open-questions).

KB: combat-damage.md (the full cook-off section), decomp-reference.md (the
cluster addresses + the GaugeAlarm/lamp map + BT_BAYTEST env), gauges-hud.md
(the fire-icon correction), reconstruction-gotchas.md #19 (__ftol),
open-questions.md (2 entries), btl4galm.cpp provenance correction.
checkctx CLEAN.  40 LNK2019 unchanged (the two pre-existing families).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:58:16 -05:00

143 lines
5.4 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_AMMO_LOG": "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))