From 4642129e76fd1b8be86071742f3ee0b04fa25764 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Fri, 7 Aug 2026 07:48:04 -0500 Subject: [PATCH] #108 peer-side WRECK receipt: make ghosts countable The un-wreck receipt had no partner, so counting ghosts in a field log meant pairing it against [BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback which is a MISSING-ASSET WARNING, not a death -- it only prints for chassis whose wreck model is absent. Night 13's census found ONE ghost while testers reported many, and there was no way to separate a real count from a chassis accident. Emit one ungated line for every REPLICANT entering the wreck state, symmetric with the existing un-wreck line, so a log's ghost count is exactly (wreck-enters minus un-wrecks) per entity: [wreck] replicant H:E entered wreck state (mode X->9) at (x,z) [respawn] replicant H:E un-wrecked + warp (mode 9->1) at (x,z) Verified 2-node (200s, force-damage victim): 5 enters, 5 exits, exactly paired -- while the old marker printed ZERO times in the same run. That gap is the point: five real deaths, invisible to what the census was reading. Also lands the night-13 census tooling (ghostcensus.py) and the eject benches that did NOT reproduce, with their failure modes in the headers so the next attempt does not repeat them: five rigs failed to trigger a punch-out at all (BT_BTNTEST never reached the mapper for 0x3D or 0x14; BT_EJECT_AT did not fire either). Panic-eject replication remains UNTESTED by bench. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ --- game/reconstructed/mechdmg.cpp | 25 ++++++++ scratchpad/night13/ejectat.sh | 73 ++++++++++++++++++++++++ scratchpad/night13/ejectghost.sh | 80 ++++++++++++++++++++++++++ scratchpad/night13/ejectlight.sh | 95 +++++++++++++++++++++++++++++++ scratchpad/night13/ghostcensus.py | 85 +++++++++++++++++++++++++++ 5 files changed, 358 insertions(+) create mode 100644 scratchpad/night13/ejectat.sh create mode 100644 scratchpad/night13/ejectghost.sh create mode 100644 scratchpad/night13/ejectlight.sh create mode 100644 scratchpad/night13/ghostcensus.py diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index edb89ed..803e0f0 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -1358,6 +1358,31 @@ void const bool nowDead = (mode == 2 || mode == 9); const bool prevDead = (oldMode == 2 || oldMode == 9); prevMode = mode; + // #108 GHOST CENSUS, the ENTER edge (ungated, 2026-08-07). The + // un-wreck receipt below has had no partner, so counting ghosts in a + // field log meant pairing it against + // "[BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback" -- + // which is a MISSING-ASSET WARNING, not a death: it only prints for + // chassis whose wreck model is absent. Night 13's census therefore + // found ONE ghost while testers reported many, and there was no way to + // tell a real count from a chassis accident. This line is emitted for + // EVERY replicant that enters the wreck state, so a log's ghost count + // is exactly (wreck-enters minus un-wrecks) per entity. + if (!prevDead && nowDead + && owner->GetInstance() == Entity::ReplicantInstance) + { + DEBUG_STREAM << "[wreck] replicant " << owner->GetEntityID() + << " entered wreck state (mode " + << oldMode << "->" << mode << ") at (" + << owner->localOrigin.linearPosition.x << "," + << owner->localOrigin.linearPosition.z << ")\n" << std::flush; + if (BTMatchLogActive()) + BTMatchLog("WRECK", "mech=%d:%d mode=%d->%d x=%.1f z=%.1f", + BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(), + oldMode, mode, + (float)owner->localOrigin.linearPosition.x, + (float)owner->localOrigin.linearPosition.z); + } if (prevDead && !nowDead && owner->GetInstance() == Entity::ReplicantInstance) { diff --git a/scratchpad/night13/ejectat.sh b/scratchpad/night13/ejectat.sh new file mode 100644 index 0000000..fcb5bb9 --- /dev/null +++ b/scratchpad/night13/ejectat.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# ========================================================================= +# PANIC EJECT -> does the respawn replicate? (#108, night 13) +# +# Fourth attempt at the trigger. The first three drove the panic button +# through BT_BTNTEST (EmitButton -> RIO queue). It never reached the +# mapper: no [eject], no PUNCH-OUT, not even a lamp change -- and the same +# seam failed to toggle the searchlight (0x14), so it was the SEAM, not the +# button address. BT_EJECT_AT is the purpose-built hook (mech4.cpp:3310): +# it synthesizes the identical press inside the input path -- "one message, +# one dispatch", the same Mech::EjectPilotMessageID the key sends -- and +# repeats every 300 frames, so one run yields many punch-outs. +# +# PASS : for every punch-out on A, B logs +# "[respawn] replicant un-wrecked + warp (mode 9->1)" +# FAIL : punch-outs on A with no matching un-wreck <- the ghost +# +# Control already banked (ejectghost.sh): 9 force-kill deaths -> 8 peer +# un-wrecks, 0 ghost lines. NORMAL death replication is good on this build, +# so an eject-only shortfall here is the defect, not a broken rig. +# ========================================================================= +set -x +. /c/git/bt411/scratchpad/night6/bench_common.sh +cd /c/git/bt411/content || exit 1 + +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +rm -f ea_a.log ea_b.log ea_relay.log + +bt_expert_egg MP.EGG EA.EGG +sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" EA.EGG + +( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_FOG_LOG=1 + bt_launch ea_b.log EA.EGG 0x0C -net 1601 ) +sleep 2 +( export BT_AUTODRIVE=0.6 BT_EJECT_AT=1800 + export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 + bt_launch ea_a.log EA.EGG 0x03 -net 1501 ) +sleep 5 +python ../tools/btconsole.py EA.EGG 127.0.0.1:1501 127.0.0.1:1601 > ea_relay.log 2>&1 & +RELAY=$! +sleep 300 +kill $RELAY 2>/dev/null +sleep 3 +bt_kill_ours +sleep 2 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 3 + +AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" ea_a.log | head -1 | cut -d= -f2) +echo "=================== PANIC-EJECT REPLICATION ===================" +echo "A entity: ${AID:-UNKNOWN}" +echo +echo "--- 1. did punch-outs actually happen? (if 0 the run is VOID) ---" +echo -n "DeathWithoutHonor notices : "; grep -ac "DeathWithoutHonor" ea_a.log +echo -n "PUNCH-OUT lines : "; grep -ac "PUNCH-OUT" ea_a.log +grep -a "PUNCH-OUT" ea_a.log | head -6 +echo +echo "--- 2. A's own death/respawn cycles ---" +echo -n "death cycle STARTs : "; grep -ac "death cycle START" ea_a.log +echo -n "Mech::Reset : "; grep -ac "Mech::Reset ${AID:-@@@}" ea_a.log +echo +echo "--- 3. B: the peer's un-wrecks for A (THE NUMBER THAT MATTERS) ---" +echo -n "un-wrecks seen : "; grep -a "respawn\] replicant" ea_b.log | grep -ac "${AID:-@@@}" +grep -a "respawn\] replicant" ea_b.log | grep -a "${AID:-@@@}" | head -8 +echo +echo "--- 4. B: ghost detector + searchlight cones ---" +echo -n "ghost lines : "; grep -ac "\[ghost\]" ea_b.log +grep -a "\[ghost\]" ea_b.log | head -4 +echo -n "cone SHOWN/HIDDEN : "; grep -ac "\[spot\] cone" ea_b.log +echo +echo "--- 5. VERDICT INPUTS ---" +echo "punch-outs=$(grep -ac 'PUNCH-OUT' ea_a.log) A-respawns=$(grep -ac 'death cycle START' ea_a.log) B-unwrecks=$(grep -a 'respawn\] replicant' ea_b.log | grep -ac "${AID:-@@@}")" diff --git a/scratchpad/night13/ejectghost.sh b/scratchpad/night13/ejectghost.sh new file mode 100644 index 0000000..a9727d2 --- /dev/null +++ b/scratchpad/night13/ejectghost.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# ========================================================================= +# EJECT-GHOST bench (#108 / night 13) -- does a PANIC EJECT replicate its +# respawn to peers? +# +# FIELD REPORT: many ghost mechs tonight, all from panic-button self +# destructs, none respawning properly -- and the operator has seen +# eject-respawn work every time before tonight, so this is a REGRESSION +# (night 12 = build 774, tonight = 817). +# +# FIELD EVIDENCE (staged logs): the ejecting mech's OWNER ran the whole +# death path and respawned and kept driving in the same round, while BOTH +# peers created his wreck and never processed the un-wreck. The same mech's +# NORMAL deaths replicated their respawns 4-6 times in that same session. +# +# THE TEST: A punches out via the real click seam (button 0x3D, the panic +# button -- the same addr the field log shows). B watches. +# PASS : B logs "[respawn] replicant un-wrecked + warp (mode 9->1)" +# FAIL : B logs the wreck and never the un-wreck <- the ghost +# A CONTROL kill comes first (B force-damages A), so the SAME run shows a +# normal death replicating correctly -- otherwise a silent B proves nothing. +# +# Press polls: >=900 per the harness contract (round-start jitter eats +# earlier presses). Panic twice, so one missed press does not read as a +# pass. +# ========================================================================= +set -x +. /c/git/bt411/scratchpad/night6/bench_common.sh +cd /c/git/bt411/content || exit 1 + +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +rm -f eg_a.log eg_b.log eg_relay.log + +bt_assert_player_env +bt_expert_egg MP.EGG EG.EGG +sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" EG.EGG + +# ---- node B: OBSERVER + the control killer ------------------------------- +( export BT_MP_FORCE_DMG=1 + export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 + bt_launch eg_b.log EG.EGG 0x0C -net 1601 ) +sleep 2 +# ---- node A: walks, gets killed (control), then PUNCHES OUT twice -------- +( export BT_AUTODRIVE=0.6 + export BT_BTNTEST=0x3d,900,960 # panic eject #1 + export BT_BTNTEST2=0x3d,2400,2460 # panic eject #2 + export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 + bt_launch eg_a.log EG.EGG 0x03 -net 1501 ) +sleep 5 +python ../tools/btconsole.py EG.EGG 127.0.0.1:1501 127.0.0.1:1601 > eg_relay.log 2>&1 & +RELAY=$! +sleep 300 +kill $RELAY 2>/dev/null +sleep 3 +bt_kill_ours +sleep 2 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 3 + +AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" eg_a.log | head -1 | cut -d= -f2) +echo "=================== EJECT-GHOST RESULT ===================" +echo "A's entity id: ${AID:-UNKNOWN}" +echo +echo "--- did the scripted panic actually press? ---" +grep -a "btntest" eg_a.log +echo +echo "--- A: punch-outs + its own death/respawn cycle ---" +grep -aE "\[eject\]|PUNCH-OUT|death cycle START|dz\] GRANTED|Mech::Reset" eg_a.log | head -20 +echo +echo "--- B: the peer's view of A, in order (wreck / un-wreck) ---" +grep -aE "wreck:|respawn\] replicant" eg_b.log | grep -a "${AID:-@@@}" | head -20 +echo +echo "--- B: totals for A ---" +echo -n "wrecks seen : "; grep -a "wreck:" eg_b.log | grep -ac "${AID:-@@@}" +echo -n "respawns seen : "; grep -a "respawn\] replicant" eg_b.log | grep -ac "${AID:-@@@}" +echo +echo "--- B: ghost detector ---" +grep -a "\[ghost\]" eg_b.log | head -5 +echo -n "ghost lines: "; grep -ac "\[ghost\]" eg_b.log diff --git a/scratchpad/night13/ejectlight.sh b/scratchpad/night13/ejectlight.sh new file mode 100644 index 0000000..ee6b595 --- /dev/null +++ b/scratchpad/night13/ejectlight.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# ========================================================================= +# EJECT + SEARCHLIGHT (#108, night 13) -- the operator's observation was +# that the ghost mechs all had their SEARCHLIGHTS ON. A plain panic eject +# respawns fine (ejectghost.sh), so the searchlight is the variable. +# +# WHY IT IS PLAUSIBLE, mechanically: the searchlight is the only thing that +# attaches EXTRA GEOMETRY into a peer's mech render tree -- btl4vid.cpp +# builds a spot.bgf cone as a DPLStaticChildRenderable parented to the +# lamp's mount-segment renderable. The death path then does a wreck swap +# ('victim -> thrdbr.bgf + debris') and respawn does 'rebuilt intact model +# (N segs restored, hulk dropped)'. A child renderable held across those +# two rebuilds is a lifetime hazard, and it would only bite mechs whose +# lamp was ON -- which is exactly the reported population. +# +# SEQUENCE on A: searchlight ON (0x14) -> confirm it lit -> panic eject +# (0x3D) -> respawn. B watches for the un-wreck. +# PASS : B logs "[respawn] replicant un-wrecked + warp" +# FAIL : wreck with no un-wreck <- the ghost, and the searchlight is it +# +# NB 0x14 is the searchlight per the shipped bindings comment (left column +# 0x10-0x15 = map+/map-/IR/CROUCH/searchlight/display). The hardware name +# table calls it "Secondary5" -- the FUNCTION comes from the streamed .CTL +# rows, so the script VERIFIES the lamp actually lit before trusting the +# result; a silent B with a lamp that never came on proves nothing. +# ========================================================================= +# +# MODE=light -- searchlight ON, then punch out +# MODE=nolight -- punch out with the lamp OFF (the A/B control) +# +# NB no BT_MP_FORCE_DMG here. ejectghost.sh had it, and that is why its +# panic presses did nothing: the "control killer" had A dead or mid-respawn +# for most of the run, and a punch-out press on a dead mech is a no-op. +# That run still produced its control result -- 8 force-kill deaths, 8 peer +# un-wrecks, so NORMAL death replication is proven good on this build. +set -x +MODE="${1:-light}" +. /c/git/bt411/scratchpad/night6/bench_common.sh +cd /c/git/bt411/content || exit 1 + +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +rm -f el_${MODE}_a.log el_${MODE}_b.log el_${MODE}_relay.log + +bt_expert_egg MP.EGG EL.EGG +# NIGHT map: the searchlight is a night system and the fog swap only has +# meaning there; also the field population was night-map rounds. +sed -i "s/^map=.*/map=cavern/; s/^time=.*/time=night/; s/^vehicle=.*/vehicle=madcat/" EL.EGG + +( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SPLASH_LOG=1 + bt_launch el_${MODE}_b.log EL.EGG 0x0C -net 1601 ) +sleep 2 +( export BT_AUTODRIVE=0.6 + if [ "$MODE" = "light" ]; then + export BT_BTNTEST=0x14,600,660 # SEARCHLIGHT on + export BT_BTNTEST2=0x3d,1500,1560 # then punch out + else + export BT_BTNTEST=0x3d,1500,1560 # punch out, lamp OFF + fi + export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 + bt_launch el_${MODE}_a.log EL.EGG 0x03 -net 1501 ) +sleep 5 +python ../tools/btconsole.py EL.EGG 127.0.0.1:1501 127.0.0.1:1601 > el_${MODE}_relay.log 2>&1 & +RELAY=$! +sleep 300 +kill $RELAY 2>/dev/null +sleep 3 +bt_kill_ours +sleep 2 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 3 + +AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" el_${MODE}_a.log | head -1 | cut -d= -f2) +echo "=================== EJECT+SEARCHLIGHT RESULT ===================" +echo "A's entity id: ${AID:-UNKNOWN}" +echo +echo "--- 1. did the scripted presses land? ---" +grep -a "btntest" el_${MODE}_a.log +echo +echo "--- 2. DID THE SEARCHLIGHT ACTUALLY COME ON? (if not, the run is void) ---" +grep -aiE "searchlight|\[spot\]|lightState" el_${MODE}_a.log | head -10 +echo +echo "--- 3. A: punch-out + own respawn ---" +grep -aE "\[eject\]|PUNCH-OUT|dz\] GRANTED" el_${MODE}_a.log | head -10 +echo +echo "--- 4. B: the peer's view of A (wreck / un-wreck, in order) ---" +grep -aE "wreck:|respawn\] replicant" el_${MODE}_b.log | grep -a "${AID:-@@@}" | head -20 +echo -n "wrecks: "; grep -a "wreck:" el_${MODE}_b.log | grep -ac "${AID:-@@@}" +echo -n "respawns: "; grep -a "respawn\] replicant" el_${MODE}_b.log | grep -ac "${AID:-@@@}" +echo +echo "--- 5. B: searchlight cones + ghost detector ---" +grep -aE "\[spot\]" el_${MODE}_b.log | head -6 +echo -n "cone skipped: "; grep -ac "cone skipped" el_${MODE}_b.log +echo -n "ghost lines : "; grep -ac "\[ghost\]" el_${MODE}_b.log +grep -a "\[ghost\]" el_${MODE}_b.log | head -3 diff --git a/scratchpad/night13/ghostcensus.py b/scratchpad/night13/ghostcensus.py new file mode 100644 index 0000000..768ee1d --- /dev/null +++ b/scratchpad/night13/ghostcensus.py @@ -0,0 +1,85 @@ +"""Night 13: census every 'wreck with no following un-wreck' across all logs. + +A peer's view of a mech death is a pair: + [BTrender] wreck: ... (entity=H:E) <- the wreck appears + [respawn] replicant H:E un-wrecked + warp <- it comes back +An UNPAIRED wreck -- one with no un-wreck before that log's session ends -- +is the ghost signature. Report per SESSION so 'was it just one game?' is +answerable, and dump the lines immediately before the first one. +""" +import re, sys, os, io + +LOGDIR = r"C:\git\bt411\scratchpad\night13" +SESS = re.compile(r"===== BT411 SESSION.*?local=(\S+ \S+)") +WRECK = re.compile(r"\[BTrender\] wreck:.*?entity=(\d+:\d+)") +UNWRECK = re.compile(r"\[respawn\] replicant (\d+:\d+) un-wrecked") +EJECT = re.compile(r"\[eject\]|PUNCH-OUT") + +def scan(path): + sessions = [] # (startline, stamp) + events = [] # (line, kind, ent) + with io.open(path, "r", encoding="latin-1", errors="replace") as f: + for n, line in enumerate(f, 1): + m = SESS.search(line) + if m: + sessions.append((n, m.group(1))) + continue + m = WRECK.search(line) + if m: + events.append((n, "wreck", m.group(1))); continue + m = UNWRECK.search(line) + if m: + events.append((n, "unwreck", m.group(1))); continue + if EJECT.search(line): + events.append((n, "eject", "-")) + return sessions, events + +def session_of(sessions, line): + idx, stamp = 0, "?" + for i, (sl, st) in enumerate(sessions): + if sl <= line: + idx, stamp = i + 1, st + else: + break + return idx, stamp + +for fn in sorted(os.listdir(LOGDIR)): + if not fn.endswith(".log") or fn.startswith("FAILURE"): + continue + path = os.path.join(LOGDIR, fn) + sessions, events = scan(path) + # pair wrecks to the next un-wreck of the same entity IN THE SAME SESSION + pending = {} # ent -> (line, sessidx) + unpaired = [] + for (n, kind, ent) in events: + si = session_of(sessions, n)[0] + if kind == "wreck": + if ent in pending and pending[ent][1] == si: + unpaired.append(pending[ent]) # wreck superseded by another wreck + pending[ent] = (n, si) + elif kind == "unwreck": + if ent in pending and pending[ent][1] == si: + del pending[ent] + for ent, (n, si) in pending.items(): + unpaired.append((n, si, ent)) + norm = [] + for u in unpaired: + norm.append(u if len(u) == 3 else (u[0], u[1], "?")) + norm.sort() + print("=" * 72) + print("%s sessions=%d wrecks=%d unwrecks=%d ejects=%d" + % (fn, len(sessions), + sum(1 for e in events if e[1] == "wreck"), + sum(1 for e in events if e[1] == "unwreck"), + sum(1 for e in events if e[1] == "eject"))) + if not norm: + print(" no unpaired wrecks") + continue + bysess = {} + for (n, si, ent) in norm: + bysess.setdefault(si, []).append((n, ent)) + for si in sorted(bysess): + stamp = sessions[si - 1][1] if 0 < si <= len(sessions) else "?" + print(" SESSION %d (%s): %d unpaired -> %s" + % (si, stamp, len(bysess[si]), + ", ".join("%s@%d" % (e, n) for n, e in bysess[si][:6])))