#108 THE EJECT GHOST: the death-edge latch tested the wrong field

One substitution, three field symptoms.  Mech::TakeDamageMessageHandler arms
the whole death tail -- kill report, VehicleDead, death blast -- from a
was-alive-at-entry latch:

    const int deathBlastArmed = !IsMechDestroyed();   // graphicAlarm >= 9

The binary tests movementMode 9|10 there (@0x4a0303).  The port swapped in the
graphic alarm and justified it: "the death transition sets mode 9 synchronously
with the structural flag on every path through here, so the edges coincide".
True of every DAMAGE path.  False of the one that matters:
Mech::EjectPilotMessageHandler raises graphicAlarm to 10 (the EJECT state)
BEFORE dispatching its self-damage, while movementMode is still 1.  So on an
eject the handler entered already reading "destroyed", the latch never armed,
and the death tail was skipped entirely -- including VehicleDead, which IS the
respawn trigger.

Everything the field reported on night 13 follows from that:
  * "they all self destructed with panic button and didn't respawn properly"
    -- no VehicleDead, so no drop-zone hunt, so no respawn;
  * the EJECT GHOST -- the peer wrecks the mech and never un-wrecks it, because
    the un-wreck rides the master's respawn.  Normal deaths replicated fine all
    along (9 deaths -> 8 un-wrecks, benched), which is why only ejects ghosted;
  * the manual chart's "-1000 ejecting" never materialised -- the negated kill
    award and the death cost both live in the tail that never ran.

Fix: use the binary's own predicate.  MovementMode is untouched by the eject's
alarm write, so the latch arms on an eject exactly as on a combat death.

WHY SEVEN RIGS MISSED IT: the punch-out was being REFUSED, not undelivered.
EvaluateEjectPermission (@0049fa1c) grants only on
  liveWeapons < ejectMinWeapons || liveGenerators == 0 || coolantFrac < 0.05
  || (leg-gimped && !simLive)
-- armour damage satisfies none of them, and every bench ejected a healthy
mech.  An [ejecttest] receipt (2 lines) proved the dispatch fired every time
and the handler declined; the "[eject] REFUSED (mech not crippled enough)" line
was sitting in the very first bench log, ungrepped.  BT_KILL_SUBSYS's
comma-list form ("GeneratorA,GeneratorB,...") was already built for this bench.

Verified 2-node (scratchpad/night13/ejectreal.sh), before -> after:
  PUNCH-OUT landed        0 (785 refusals)  ->  1, charge=500
  peer wreck-enters       1                 ->  1
  peer UN-WRECKS          0  (the ghost)    ->  1
  eject score (type=2)    absent            ->  award=-1000.00, score 1000 -> 0
  death cost              never ran         ->  APPLYING, penalty=500

That -1000 is the manual chart's eject row to the digit: killBonus 500 plus the
500 self-damage tally, negated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
This commit is contained in:
Joe DiPrima
2026-08-07 15:38:50 -05:00
co-authored by Claude Opus 5
parent 29b4d68ba6
commit 61f21107b4
3 changed files with 118 additions and 1 deletions
+25 -1
View File
@@ -1070,7 +1070,31 @@ void
// Capturing it after the divert (where #89 first placed it) meant a
// COLLISION death could never arm the tail.
//
const int deathBlastArmed = !IsMechDestroyed(); // [ebp-0x10], inverted
// WAS-ALIVE-AT-ENTRY, on the BINARY's predicate (movementMode), not the
// graphic alarm. FIXED 2026-08-07 -- this substitution was the eject-ghost.
//
// The binary tests movementMode 9|10 here; the port used IsMechDestroyed()
// (graphicAlarm >= 9) and justified it with "the death transition sets mode
// 9 synchronously with the structural flag on every path through here, so
// the edges coincide". That is true of every DAMAGE path and false of the
// one that matters: Mech::EjectPilotMessageHandler raises graphicAlarm to
// 10 (the EJECT state) BEFORE dispatching its self-damage, while
// movementMode is still 1. So on an eject the handler entered already
// reading "destroyed", the latch never armed, and the whole death tail was
// skipped -- no VehicleDead, which IS the respawn trigger.
//
// Consequences, all three reported from the field on the same night:
// * the ejecting player never respawns ("panic button, didn't respawn");
// * the peer wrecks the mech and never un-wrecks it, because the un-wreck
// rides the master's respawn -> the permanent EJECT GHOST (#108);
// * the eject scores only its -500 self-damage: no negated kill award and
// no -500 death cost, because both live in the tail that never ran --
// which is why the manual chart's "-1000 ejecting" never materialised.
//
// MovementMode 9|10 is untouched by the eject's alarm write, so the latch
// now arms on the eject exactly as it does on a combat death.
const int deathBlastArmed =
!(MovementMode() == 9 || MovementMode() == 10); // [ebp-0x10], inverted (@0x4a0303)
//
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
+14
View File
@@ -3311,9 +3311,23 @@ void
sEjAt = (e && *e) ? atoi(e) : -1;
}
++sEjFrame;
// Receipt: five separate rigs failed to fire a punch-out
// and it was never established whether this hook is even
// REACHED. Announce once a second while armed.
if (sEjAt > 0)
{
static int sEjLog = 0;
if ((++sEjLog % 60) == 0)
DEBUG_STREAM << "[ejecttest] armed at " << sEjAt
<< ", frame " << sEjFrame << "\n" << std::flush;
}
if (sEjAt > 0 && sEjFrame >= sEjAt
&& ((sEjFrame - sEjAt) % 300) == 0)
{
ejectPress = 1;
DEBUG_STREAM << "[ejecttest] FIRING punch-out at frame "
<< sEjFrame << "\n" << std::flush;
}
}
if (ejectPress)
{
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# =========================================================================
# A REAL PANIC EJECT -- at last. (#108 ghost + the chart's "-1000 ejecting")
#
# WHY SIX RIGS FAILED. It was never the trigger. BT_EJECT_AT reaches the
# dispatch every time (proved with an [ejecttest] receipt: "FIRING punch-out
# at frame 600/900/1200"), and the HANDLER refuses it:
#
# [eject] 1:139 REFUSED (mech not crippled enough)
#
# Mech::EjectPilotMessageHandler gates on EvaluateEjectPermission() (@0x414) --
# a healthy mech cannot punch out. Every bench so far ejected a pristine mech.
# In the field players eject BECAUSE they are wrecked, which is why it works
# for them and never for me. (The button seam was never broken either; that
# earlier conclusion was wrong too.)
#
# So: CRIPPLE FIRST, then eject. BT_SELF_DAMAGE grinds A down; BT_EJECT_AT
# retries every 300 frames, so the first retry after permission is granted
# takes it.
#
# WHAT THIS SETTLES:
# chart "-1000 ejecting" -- the eject total, currently arithmetic only
# #108 eject-ghost -- does the peer un-wreck after an EJECT death?
# (normal deaths replicate fine: 9 deaths -> 8
# un-wrecks, benched)
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f er_a.log er_b.log er_relay.log matchlog_*.txt
bt_expert_egg MP.EGG ER.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" ER.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch er_b.log ER.EGG 0x0C -net 1601 )
sleep 2
# A: grind itself down, then punch out once permission is granted
# THE GATE, decoded from Mech::EvaluateEjectPermission (@0049fa1c):
# permitted = liveWeapons < ejectMinWeapons || liveGenerators == 0
# || coolantFrac < 0.05 || (leg-gimped && !simLive)
# Armour damage satisfies NONE of them -- which is why grinding A down gave
# 53 attempts and 785 refusals. Killing the GENERATORS is the direct lever,
# and BT_KILL_SUBSYS's comma-list form was built for precisely this bench.
# It fires at frame 900, so arm the eject after that.
( export BT_KILL_SUBSYS=GeneratorA,GeneratorB,GeneratorC,GeneratorD BT_EJECT_AT=1200
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_DMG_LOG=1
bt_launch er_a.log ER.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py ER.EGG 127.0.0.1:1501 127.0.0.1:1601 > er_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]+" er_a.log | head -1 | cut -d= -f2)
echo "=================== REAL EJECT ==================="
echo "A entity: ${AID:-UNKNOWN}"
echo "--- 1. did a punch-out finally LAND? ---"
echo -n "FIRING attempts : "; grep -ac "FIRING punch-out" er_a.log
echo -n "REFUSED : "; grep -ac "REFUSED" er_a.log
echo -n "PUNCH-OUT : "; grep -ac "PUNCH-OUT" er_a.log
grep -aE "PUNCH-OUT|DeathWithoutHonor" er_a.log | head -3
echo
echo "--- 2. CHART '-1000 ejecting': the score trajectory around it ---"
grep -ah "player=2:1 type=" matchlog_*.txt | tail -6 | cut -c1-115
grep -a "\[deathcost\]" er_a.log | head -2 | cut -c1-140
echo
echo "--- 3. #108: does the peer UN-WRECK after an eject death? ---"
echo -n "B wreck-enters for A : "; grep -a "entered wreck state" er_b.log | grep -ac "${AID:-@@@}"
echo -n "B un-wrecks for A : "; grep -a "un-wrecked + warp" er_b.log | grep -ac "${AID:-@@@}"
grep -aE "entered wreck state|un-wrecked \+ warp" er_b.log | tail -6 | cut -c1-120
echo -n "B ghost lines : "; grep -ac "\[ghost\]" er_b.log