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])))