From 61f21107b4b585129a39e16e2c81e919fd30a198 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Fri, 7 Aug 2026 15:38:50 -0500 Subject: [PATCH] #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 Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ --- game/reconstructed/mech.cpp | 26 ++++++++++- game/reconstructed/mech4.cpp | 14 ++++++ scratchpad/night13/ejectreal.sh | 79 +++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 scratchpad/night13/ejectreal.sh diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 69c8ce5..7942629 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -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; diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 1edeb8f..9f8e240 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -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) { diff --git a/scratchpad/night13/ejectreal.sh b/scratchpad/night13/ejectreal.sh new file mode 100644 index 0000000..884343f --- /dev/null +++ b/scratchpad/night13/ejectreal.sh @@ -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