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>
143 lines
5.4 KiB
Python
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))
|