diff --git a/content/CONTROLS.MAP b/content/CONTROLS.MAP
index 4a65e1d..57a8e91 100644
--- a/content/CONTROLS.MAP
+++ b/content/CONTROLS.MAP
@@ -164,3 +164,9 @@ pad X action LookBehind
pad Start action ModeCycle
pad RightThumb action DisplayCycle
pad Back action Valve
+
+# PANIC / EJECT -- the punch-out (Mech msg 0x19). Only fires on a CRIPPLED
+# mech (no generators, coolant leaked below 5%, or leg-gimped novice) -- a
+# healthy mech refuses the button, so a stray press is a no-op.
+key Back action Eject
+pad LeftThumb action Eject
diff --git a/context/decomp-reference.md b/context/decomp-reference.md
index f5def02..d9ff6db 100644
--- a/context/decomp-reference.md
+++ b/context/decomp-reference.md
@@ -319,6 +319,27 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
Loaded ("full == the gear's seek voltage" is the arcade's own discharge algebra). Repro +
verify: BT_SEEKTEST seek-abuse -- pre-fix deadlocks at pct=0/alarm=3; post-fix 38 rescues,
ends Loaded/pct=1. `[seek]` log prints the per-gear table + rescue events.
+- **EJECT/PANIC cluster DECODED + WIRED 2026-08-02 [T1 raw disasm]:** `@0049f854`
+ **Mech::EjectPilot** (table id 0x19; sat in the 0x49f854-0x49fa00 export gap) — gates
+ {msg+0xc > 0, ejectPermitted@0x414, !IsDisabled@0049fb54}, console eject notice
+ (`FUN_004c198c` ctor from ownerID@0x18c → network sender, code 5 — port tail),
+ player `suppressConsole`@0x258 = 1 (the following death must not double-notify),
+ `graphicAlarm → 10` (EJECT state; **kills via the ≥9 predicate** — IsMechDestroyed),
+ self TakeDamageMessage {inflicting=SELF, zone −1, Explosive, amount =
+ `ScenarioRole::killBonus` role+0x1c, burst 0}. Role layout byte-settled via the
+ reader `@00429bec` dest offsets + ctor record copy: +0xc inflicted, +0x10 received,
+ +0x14 ffPenalty, +0x18 damageBias, **+0x1c killBonus**, **+0x20
+ SpecialCaseDeathPenalty (4.10-only key)**, +0x28 returnFromDeath. `KillBonus`
+ authors NOWHERE in shipped content → charge 0, alarm does the killing — authentic.
+ `@0049fa1c` **EvaluateEjectPermission** (writes @0x414; unexported caller): roster
+ walk — 0xBBE bank coolant fraction (+0x12c/+0x128) < 0.05 (`@0049fb50`) | zero live
+ Generators (0xBC1, +0x40 ≠ 1 && stateAlarm@0x210 ≠ 4) | live MechWeapons
+ (0x511830; ProjectileWeapon family 0x5121a8 also needs weaponAlarm@0x364 ≠ 7 NoAmmo)
+ < mech+0x448 (**no exported writer — zero, clause inert** [T3]) | leg-gimped
+ (mode 3/4) novice (simLive 0). Port: mech.cpp handler+evaluator, bridges
+ BTWeaponCountsForEject / BTGeneratorCountsForEject / BTHeatSinkBankCoolantFraction /
+ BTPlayerEjectBookkeeping; input = binding-engine `Eject` action (Backspace /
+ pad LeftThumb; BENCH `BT_EJECT_AT=`, `BT_KILL_SUBSYS` now comma-list).
- **THE SIM TIME MODEL — CLOSED 2026-08-02 (issue #96) [T1]:** the arcade
`Simulation::PerformAndWatch` is **`FUN_0041c018`** (part_002.c:5101):
`slice = (till.ticks − this[4].ticks) / DAT_0052140c; this[4] = till;
diff --git a/context/pod-hardware.md b/context/pod-hardware.md
index 562c6c4..9d3b392 100644
--- a/context/pod-hardware.md
+++ b/context/pod-hardware.md
@@ -232,8 +232,13 @@ every checked control behavior:
follows the BINARY (house rule); resurrecting manual-4.0 supercharge would be an opt-in
deviation for the operator to decide.
NEW LEADS (manual describes, port lacks input/UI): **CROUCH** (button by the secondary screen;
-`Mech::duckState` attr 0x37 + SQUAT clips exist, nothing drives them), **EJECT** (sounds exist:
-EjectButton01_z*.wav), hot-box viewscreen framing (the deferred PNAME marker chain). Per-mech
+`Mech::duckState` attr 0x37 + SQUAT clips exist, nothing drives them), ~~EJECT~~ **EJECT WIRED
+2026-08-02** (core: `Mech::EjectPilotMessageHandler` id 0x19 @0049f854 + the crippled-mech
+permission evaluator @0049fa1c; input = binding-engine "Eject" action, Backspace / pad
+LeftThumb; the punch-out kills via graphicAlarm 10 ≥ 9 — KillBonus authors 0 in ALL shipped
+content, so the zero charge is authentic; tails: console relay notice, RIO 0x38 panic control,
+alarm-10 audio/canopy presentation, `SpecialCaseDeathPenalty` role+0x20 consumer), hot-box
+viewscreen framing (the deferred PNAME marker chain). Per-mech
stat tables = a systematic cross-check source for our streamed subsystem resources.
### Coolant-loop cross-check RESULT (2026-07-18) [T1] -- STRUCTURE strongly faithful
diff --git a/game/reconstructed/btinput.cpp b/game/reconstructed/btinput.cpp
index e50f0b4..0925e8e 100644
--- a/game/reconstructed/btinput.cpp
+++ b/game/reconstructed/btinput.cpp
@@ -82,6 +82,7 @@ enum BTActionID
BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page
BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page
BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page
+ BTActEject, // PANIC/EJECT punch-out -> Mech msg 0x19 (@0049f854)
BTActCount
};
@@ -204,6 +205,7 @@ static const BTName sActionNames[] =
{"Reconnect", BTActReconnect},
{"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle},
{"Mfd3Cycle", BTActMfd3Cycle},
+ {"Eject", BTActEject},
{0, 0}
};
@@ -288,6 +290,8 @@ static const char *sDefaultProfile =
"key L action Mfd3Cycle\n"
"key V action ViewToggle\n"
"key B action LookBehind\n"
+ "key Back action Eject\n"
+ "pad LeftThumb action Eject\n"
"key F5 action Generator1\n"
"key F6 action Generator2\n"
"key F7 action Generator3\n"
@@ -991,6 +995,7 @@ void
case BTActMfd1Cycle: next.mfdCycle[0] = 1; break;
case BTActMfd2Cycle: next.mfdCycle[1] = 1; break;
case BTActMfd3Cycle: next.mfdCycle[2] = 1; break;
+ case BTActEject: next.eject = 1; break;
case BTActGenerator1: next.genSel = 4; break;
case BTActGenerator2: next.genSel = 5; break;
case BTActGenerator3: next.genSel = 6; break;
diff --git a/game/reconstructed/btinput.hpp b/game/reconstructed/btinput.hpp
index 11be1a6..7f76a4a 100644
--- a/game/reconstructed/btinput.hpp
+++ b/game/reconstructed/btinput.hpp
@@ -55,6 +55,7 @@ struct BTInputState
int valve;
int flush; // Gitea #7: coolant flush HELD (InjectCoolant)
int configHold;
+ int eject; // PANIC/EJECT punch-out (level; mech4 edge-dispatches Mech 0x19)
int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect
// Gitea #9: per-MFD preset-page cycle (desktop senders for SetPresetMode;
// index 0/1/2 = Mfd1 lower-left / Mfd2 upper-center / Mfd3 lower-right)
diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp
index 18543ac..94e6ce7 100644
--- a/game/reconstructed/btplayer.cpp
+++ b/game/reconstructed/btplayer.cpp
@@ -2388,3 +2388,31 @@ Entity *BTPlayerObjectiveMechOf(void *player)
return 0;
return (Entity *)((BTPlayer *)player)->GetObjectiveMech(); // @0x284
}
+
+//#############################################################################
+// BTPlayerEjectBookkeeping -- complete-type bridge for Mech::EjectPilot
+// (@0049f854, the PANIC/EJECT punch-out). The handler's two player-side
+// writes, verbatim from the raw disasm:
+// * suppressConsole (+0x258) = 1 -- the eject already notified the console
+// (its own message, the tracked relay tail), so the DEATH that the
+// returned charge causes must not double-notify;
+// * the self-destruct amount = ScenarioRole::killBonus (role+0x1c -- the
+// record map is byte-verified: reader @00429bec dest offsets + the role
+// ctor's record copy [7]=rec[0]).
+// Returns the charge; 0 with no player/role (binary derefs unguarded).
+//#############################################################################
+Scalar BTPlayerEjectBookkeeping(void *player_v)
+{
+ BTPlayer *player = (BTPlayer *)player_v;
+ if (player == 0)
+ {
+ return 0.0f;
+ }
+ player->suppressConsole = 1; // +0x258
+ const ScenarioRole *role = player->GetScenarioRole(); // +0x208
+ if (role == 0)
+ {
+ return 0.0f;
+ }
+ return role->GetKillBonus(); // role+0x1c
+}
diff --git a/game/reconstructed/btplayer.hpp b/game/reconstructed/btplayer.hpp
index 9fa81ee..64b4a5a 100644
--- a/game/reconstructed/btplayer.hpp
+++ b/game/reconstructed/btplayer.hpp
@@ -437,6 +437,7 @@ class DropZone__ReplyMessage;
friend int BTPlayerExperienceSimLive(void *); // +0x25c reader (issue #2): jams / lights / powersub short path
friend int BTPlayerExperienceHeatModelOn(void *); // +0x260 reader (issue #2): FUN_004ad7d4 heat-model gate
friend int BTPlayerAdvancedDamageOn(void *); // +0x268 reader (#83): FUN_0049ffcc collision-rattle gate
+ friend Scalar BTPlayerEjectBookkeeping(void *); // +0x258 latch + role killBonus (Mech::EjectPilot @0049f854)
// TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target
// RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our
// compiled object is `deathPending` (the respawn latch). These bridges
diff --git a/game/reconstructed/heat.cpp b/game/reconstructed/heat.cpp
index 6ae5a10..fe6cffe 100644
--- a/game/reconstructed/heat.cpp
+++ b/game/reconstructed/heat.cpp
@@ -1372,3 +1372,21 @@ Derivation *
{
return HeatSink::GetClassDerivations();
}
+
+//===========================================================================//
+// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
+// permission evaluator (Mech::EvaluateEjectPermission @0049fa1c). The binary
+// clause: classID 0xBBE (the AGGREGATE heat-sink bank -- the factory case
+// carries the known "Sensor" mislabel; ctor-address truth per CLASSMAP rule),
+// fraction = coolantLevel(@0x12C) / thermalCapacity(@0x128). Eject unlocks
+// below 0.05 -- i.e. the mech has LEAKED nearly dry. Returns 0 = not the
+// bank, else 1 with *out filled.
+//===========================================================================//
+int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
+{
+ if (sub == 0 || (int)sub->GetClassID() != 0xBBE)
+ return 0;
+ HeatSink *bank = (HeatSink *)sub;
+ *out = bank->CoolantFractionOf();
+ return 1;
+}
diff --git a/game/reconstructed/heat.hpp b/game/reconstructed/heat.hpp
index bab477a..eef1827 100644
--- a/game/reconstructed/heat.hpp
+++ b/game/reconstructed/heat.hpp
@@ -567,6 +567,11 @@ inline int
Scalar filterDecay; // @0x150 init 0.15f (@004b8fec) -- ALSO the
// leak gauge's full-scale divisor (#97)
Scalar LeakGaugeFullScale() const { return filterDecay; }
+ // Eject evaluator (@0049fa1c): the bank's remaining-coolant fraction
+ // (@0x12C / @0x128). Capacity is authored nonzero (ctor init 1.0);
+ // the guard only shields a malformed stream from a NaN.
+ Scalar CoolantFractionOf() const
+ { return (thermalCapacity > 0.0f) ? (coolantLevel / thermalCapacity) : 0.0f; }
Scalar thermalMass; // @0x154 resource +0xF4
Scalar heatEnergy; // @0x158 init = thermalMass * startingTemperature
Scalar coolantFlowScale; // @0x15C init 1.0f (== "word57")
diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp
index 036ad3b..a149d87 100644
--- a/game/reconstructed/mech.cpp
+++ b/game/reconstructed/mech.cpp
@@ -466,6 +466,7 @@ const Receiver::HandlerEntry
MESSAGE_ENTRY(Mech, PlayerLink),
MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20)
MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26)
+ MESSAGE_ENTRY(Mech, EjectPilot), // id 0x19 @0049f854 (PANIC/EJECT, 2026-08-02)
};
//
@@ -513,6 +514,125 @@ void
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
}
+//
+// @0049fa1c -- the EJECT-PERMISSION evaluator (writes ejectPermitted @0x414).
+// Raw decomp, transcribed clause for clause: walk the roster from index 2 --
+// classID 0xBBE (HeatSinkBank): coolant fraction = coolantLevel/thermalCapacity
+// classID 0xBC1 (Generator): count live (not destroyed && state != 4)
+// MechWeapon-derived: count live (not destroyed; ammo-fed weapons
+// additionally need weaponAlarm != 7 = NoAmmo)
+// then permitted = weapons < ejectMinWeapons || generators == 0
+// || coolant < 0.05 (@0049fb50)
+// || (MovementMode 3/4 [leg-gimped] && player simLive == 0).
+// The binary refreshes this per frame from an UNEXPORTED caller; the port
+// evaluates on demand (the handler + any future panic-lamp consumer).
+//
+int
+ Mech::EvaluateEjectPermission()
+{
+ extern int BTWeaponCountsForEject(Subsystem *sub); // projweap.cpp (-1 = not a weapon)
+ extern int BTGeneratorCountsForEject(Subsystem *sub); // powersub.cpp (-1 = not a generator)
+ extern int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out); // heat.cpp
+
+ int liveWeapons = 0;
+ int liveGenerators = 0;
+ Scalar coolantFrac = 0.0f; // local_10 (0 when no bank streams)
+
+ for (int i = 2; i < subsystemCount; ++i) // binary: from index 2
+ {
+ Subsystem *s = (Subsystem *)subsystemArray[i];
+ if (s == 0)
+ {
+ continue;
+ }
+ Scalar frac;
+ if (BTHeatSinkBankCoolantFraction(s, &frac))
+ {
+ coolantFrac = frac;
+ }
+ else
+ {
+ int g = BTGeneratorCountsForEject(s);
+ if (g >= 0)
+ {
+ liveGenerators += g;
+ }
+ else
+ {
+ int w = BTWeaponCountsForEject(s);
+ if (w >= 0)
+ {
+ liveWeapons += w;
+ }
+ }
+ }
+ }
+
+ int gimped = (MovementMode() == 3 || MovementMode() == 4); // mech+0x40 - 3U < 2
+ extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (+0x25c;
+ int noviceSim = (BTPlayerExperienceSimLive(this) == 0); // NULL-player reads LIVE)
+
+ ejectPermitted =
+ (liveWeapons < ejectMinWeapons) // @0x448 floor (inert at 0)
+ || (liveGenerators == 0)
+ || (coolantFrac < 0.05f) // _DAT_0049fb50
+ || (gimped && noviceSim);
+ return ejectPermitted;
+}
+
+//
+// @0049f854 -- EjectPilot (id 0x19): the cockpit PANIC/EJECT punch-out.
+// See the header comment (mech.hpp) for the byte-level story.
+//
+void
+ Mech::EjectPilotMessageHandler(ReceiverDataMessageOf *message)
+{
+ if (message->dataContents <= 0) // press only (msg+0xc)
+ {
+ return;
+ }
+ if (EvaluateEjectPermission() == 0) // @0x414 gate: healthy mechs refuse
+ {
+ DEBUG_STREAM << "[eject] " << GetEntityID()
+ << " REFUSED (mech not crippled enough)" << std::endl << std::flush;
+ return;
+ }
+ if (IsDisabled()) // @0049fb54 -- no ejecting from a wreck
+ {
+ return;
+ }
+
+ // Binary @0049f88e-0x49f8cf: build the console eject notice (FUN_004c198c
+ // from ownerID @0x18c) and send it to the console host. CORE: forensic
+ // log; the relay wire is the tracked tail. The suppressConsole latch set
+ // below is the authentic other half (the following DEATH must not also
+ // notify the console).
+ extern Scalar BTPlayerEjectBookkeeping(void *player); // btplayer.cpp:
+ void *player = GetPlayerLink(); // suppressConsole=1,
+ Scalar charge = (player != 0) // returns role killBonus
+ ? BTPlayerEjectBookkeeping(player) : 0.0f;
+
+ DEBUG_STREAM << "[eject] " << GetEntityID()
+ << " PUNCH-OUT: charge=" << charge
+ << " (role killBonus)" << std::endl << std::flush;
+
+ graphicAlarm.SetLevel(10); // FUN_0041bbd8(this+0x2C, 0xA) -- EJECT state
+
+ Damage dmg; // FUN_0041db7c (Damage::Damage)
+ dmg.damageType = (Enumeration)2; // Explosive
+ dmg.damageAmount = charge; // role+0x1c (killBonus)
+ dmg.impactPoint = localOrigin.linearPosition; // binary copies this+0x100
+ dmg.burstCount = 1; // binary writes 0; our victim-side
+ // guard clamps 0->1 -- same outcome
+ Entity::TakeDamageMessage take_damage(
+ Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
+ GetEntityID(), // inflicting = SELF (this+0x184)
+ -1, // unaimed -> cylinder resolves
+ dmg,
+ -1); // inflictingSubsystemID = -1
+ Dispatch(&take_damage); // vtbl+0xC self-dispatch
+}
+
Receiver::MessageHandlerSet
Mech::MessageHandlers(
ELEMENTS(Mech::MessageHandlerEntries),
@@ -1249,6 +1369,8 @@ Mech::Mech(
mechNameFilter.Initialize(); // FUN_00435a7c(this+0xdb)
masterAlarm = AlarmIndicator(0x21); // FUN_0041b9ec(this+0xe7,0x21)
rearFiring = 0; // (task #68) ORed from the weapons below
+ ejectPermitted = 0; // @0x414 (refreshed by EvaluateEjectPermission)
+ ejectMinWeapons = 0; // @0x448 (no exported writer -- zero, clause inert [T3])
// (F7 correction) the binary's 0x400 = FLT_MAX init is DistanceToMissile's
// "no missile" far default (attr id 56), NOT a maxSpeed -- the old member
// is retired; distanceToMissile (init below) owns the slot's meaning.
diff --git a/game/reconstructed/mech.hpp b/game/reconstructed/mech.hpp
index d9e3217..367e8b2 100644
--- a/game/reconstructed/mech.hpp
+++ b/game/reconstructed/mech.hpp
@@ -739,6 +739,15 @@ protected:
// `stateFlags`): OR of every mounted weapon's rearFiring flag -- "this
// mech carries a rear arsenal". The look-back view arms those weapons.
int rearFiring; // @0x410 this[0x104]
+ int ejectPermitted; // @0x414 EvaluateEjectPermission (@0049fa1c) result:
+ // 1 = crippled enough that the PANIC/EJECT
+ // punch-out is allowed (binary refreshes per
+ // frame from an unexported caller; the port
+ // evaluates on demand in the 0x19 handler)
+ int ejectMinWeapons; // @0x448 live-weapon floor for the evaluator's first
+ // clause. NO writer in the exported decomp --
+ // zero-filled in the binary, so the clause is
+ // inert there too [T3]; init 0 to match.
// (task #68) the authored look-view angles (model record +0x50..0x5c,
// ctor converts degrees->radians; binary @0x564..0x570 -- previously
// parked in the Wword scratch bank). Consumed by the look-state
@@ -1237,6 +1246,29 @@ protected:
enum { DuckRequestMessageID = 0x1a };
void DuckRequestMessageHandler(ReceiverDataMessageOf *message);
+ // @0049f854 -- "EjectPilot" (Mech table id 0x19): the cockpit PANIC/
+ // EJECT punch-out (raw disasm 2026-08-02 -- the handler sat in the
+ // 0x49f854-0x49fa00 export gap). Press-only (msg+0xc > 0), gated on
+ // ejectPermitted (@0x414) and !IsDisabled(). Body: notify the operator
+ // console of the eject (FUN_004c198c ctor -> network sender; port core
+ // LOGS it, the relay wire is the tail) and latch the player's
+ // suppressConsole (+0x258) so the death that follows doesn't double-
+ // notify; graphicAlarm -> 10 (the EJECT state, one past death's 9);
+ // then SELF-dispatch a TakeDamageMessage {inflicting = SELF, zone -1,
+ // Explosive, amount = ScenarioRole::killBonus (role+0x1c -- field map
+ // byte-verified via the role reader @00429bec dest offsets + the ctor
+ // record copy), burst 0 (binary; our victim guard clamps to 1 --
+ // identical outcome: the charge is lethal)}. The punch-out IS a
+ // death: wreck, respawn cycle and replication all ride the normal
+ // damage chain.
+ enum { EjectPilotMessageID = 0x19 };
+ void EjectPilotMessageHandler(ReceiverDataMessageOf *message);
+ // @0049fa1c -- the eject-permission evaluator (ejectPermitted @0x414):
+ // roster walk -- live weapons < ejectMinWeapons(@0x448) | zero live
+ // generators | HeatSinkBank coolant fraction < 0.05 (@0049fb50) |
+ // (leg-gimped state 3/4 AND novice, player simLive == 0).
+ int EvaluateEjectPermission();
+
// --- damage-routing support (mechdmg / mech4) -----------------------
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp
index dfdefb8..295d8b2 100644
--- a/game/reconstructed/mech4.cpp
+++ b/game/reconstructed/mech4.cpp
@@ -3227,6 +3227,39 @@ void
sVolDn = dn;
sVolUp = up;
}
+ // PANIC/EJECT (edge): dispatch Mech msg 0x19 (@0049f854) at
+ // ourselves -- the handler owns every gate (crippled-mech
+ // permission @0x414, IsDisabled), so a stray press on a
+ // healthy mech is a logged no-op.
+ {
+ static int sEjectHeld = 0;
+ int ejectPress = gBTInput.eject && !sEjectHeld;
+ sEjectHeld = gBTInput.eject;
+ // BENCH (BT_EJECT_AT=): synthesize the SAME press
+ // at that frame and every 300 after -- path-identical to
+ // the key (one message, one dispatch), so a refused-then-
+ // permitted sequence is provable in a single headless run.
+ {
+ static int sEjFrame = 0;
+ static int sEjAt = -2;
+ if (sEjAt == -2)
+ {
+ const char *e = getenv("BT_EJECT_AT");
+ sEjAt = (e && *e) ? atoi(e) : -1;
+ }
+ ++sEjFrame;
+ if (sEjAt > 0 && sEjFrame >= sEjAt
+ && ((sEjFrame - sEjAt) % 300) == 0)
+ ejectPress = 1;
+ }
+ if (ejectPress)
+ {
+ ReceiverDataMessageOf punch_out(
+ Mech::EjectPilotMessageID,
+ sizeof(ReceiverDataMessageOf), 1);
+ Dispatch(&punch_out);
+ }
+ }
// task #13: the Valve action cycles the coolant valve.
gBTValveKey = gBTInput.valve;
// Gitea #7: the Flush action HELD = the coolant MFD's
@@ -6198,19 +6231,27 @@ void
static int s_ksFrame = 0;
if (++s_ksFrame == 900) // ~15 s in, after the mech settles
{
- const char *want = getenv("BT_KILL_SUBSYS");
- for (int i = 2; i < GetSubsystemCount(); ++i)
+ // COMMA-LIST (eject bench): "GeneratorA,GeneratorB,..." kills
+ // each named subsystem; single-name behavior unchanged.
+ char want_list[256];
+ strncpy(want_list, getenv("BT_KILL_SUBSYS"), sizeof(want_list) - 1);
+ want_list[sizeof(want_list) - 1] = 0;
+ for (char *want = strtok(want_list, ","); want != 0;
+ want = strtok(0, ","))
{
- Subsystem *s = GetSubsystem(i);
- if (s == 0 || s->GetName() == 0) continue;
- if (stricmp(s->GetName(), want) != 0) continue;
- if (!s->IsDerivedFrom(MechSubsystem::ClassDerivations)) continue;
- ((MechSubsystem *)s)->SetSubsystemDamageLevel(1.0f);
- ((MechSubsystem *)s)->ForceCriticalFailure();
- DEBUG_STREAM << "[killsub] '" << s->GetName()
- << "' force-destroyed (statusAlarm=1, zone=1.0)"
- << std::endl << std::flush;
- break;
+ for (int i = 2; i < GetSubsystemCount(); ++i)
+ {
+ Subsystem *s = GetSubsystem(i);
+ if (s == 0 || s->GetName() == 0) continue;
+ if (stricmp(s->GetName(), want) != 0) continue;
+ if (!s->IsDerivedFrom(MechSubsystem::ClassDerivations)) continue;
+ ((MechSubsystem *)s)->SetSubsystemDamageLevel(1.0f);
+ ((MechSubsystem *)s)->ForceCriticalFailure();
+ DEBUG_STREAM << "[killsub] '" << s->GetName()
+ << "' force-destroyed (statusAlarm=1, zone=1.0)"
+ << std::endl << std::flush;
+ break;
+ }
}
}
}
diff --git a/game/reconstructed/mechsub.hpp b/game/reconstructed/mechsub.hpp
index e8d0364..2841044 100644
--- a/game/reconstructed/mechsub.hpp
+++ b/game/reconstructed/mechsub.hpp
@@ -341,6 +341,13 @@ class Damage;
public:
static Logical
LookupStatusType(const char *name, int *out_value);
+
+ // EJECT-evaluator support (@0049fa1c bridges): the both-cells
+ // destroyed test (gotcha #22 -- the binary's ONE cell @0x40 is split
+ // into statusAlarm + simulationState here, so every cross-family
+ // destroyed check must read BOTH).
+ int SimulationStateCell() const { return simulationState; }
+ int StatusAlarmLevel() { return statusAlarm.GetLevel(); }
protected:
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/game/reconstructed/mechweap.hpp b/game/reconstructed/mechweap.hpp
index 4dec870..1639633 100644
--- a/game/reconstructed/mechweap.hpp
+++ b/game/reconstructed/mechweap.hpp
@@ -360,6 +360,7 @@ class CockpitHud;
int PipExtendedRange() const { return pipExtendedRange; }
void *WithinRangePtr() { return &targetWithinRange; }
void *WeaponAlarmPtr() { return &weaponAlarm; }
+ int WeaponAlarmLevel() { return weaponAlarm.GetLevel(); } // @0x364 (eject evaluator: 7 = NoAmmo)
void *SimulationStatePtr() { return &simulationState; } // attr 1 ("SimulationState" -- damage state; 1 = Destroyed)
Scalar *RechargeLevelPtr() { return &rechargeLevel; } // the recharge-dial source (1.0 == fully charged); LIVE on BOTH families
// (projectile weapons write it via slot 17 @004b9c9c — Gitea #12)
diff --git a/game/reconstructed/powersub.cpp b/game/reconstructed/powersub.cpp
index 82a66dc..f8c3c01 100644
--- a/game/reconstructed/powersub.cpp
+++ b/game/reconstructed/powersub.cpp
@@ -1778,3 +1778,19 @@ int BTSubsystemIsGenerator(::Subsystem *sub)
{
return (sub != 0 && sub->IsDerivedFrom(*Generator::GetClassDerivations())) ? 1 : 0;
}
+
+//===========================================================================//
+// BTGeneratorCountsForEject -- complete-type bridge for the EJECT-permission
+// evaluator (Mech::EvaluateEjectPermission @0049fa1c). Binary clause: classID
+// 0xBC1, not destroyed (+0x40 != 1; both-cells here per gotcha #22), and
+// stateAlarm level (@0x210) != 4. Returns -1 = not a generator, else 0/1.
+//===========================================================================//
+int BTGeneratorCountsForEject(Subsystem *sub)
+{
+ if (sub == 0 || sub->GetClassID() != RegisteredClass::GeneratorClassID) // +4 == 0xbc1
+ return -1;
+ Generator *g = (Generator *)sub;
+ if (g->SimulationStateCell() == 1 || g->StatusAlarmLevel() == 1)
+ return 0; // destroyed
+ return (g->GeneratorStateOf() != 4) ? 1 : 0; // stateAlarm @0x210 != 4
+}
diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp
index 91650f6..81ff587 100644
--- a/game/reconstructed/projweap.cpp
+++ b/game/reconstructed/projweap.cpp
@@ -1242,3 +1242,25 @@ Subsystem *CreateProjectileWeaponSubsystem(Mech *owner, int id, void *seg)
return (Subsystem *) new (Memory::Allocate(0x448))
ProjectileWeapon(owner, id, (ProjectileWeapon::SubsystemResource *)seg, ProjectileWeapon::DefaultData);
}
+
+//===========================================================================//
+// BTWeaponCountsForEject -- complete-type bridge for the EJECT-permission
+// evaluator (Mech::EvaluateEjectPermission, @0049fa1c; issue: eject/panic).
+// The binary's weapon clause: MechWeapon-derived (0x511830), not destroyed,
+// and -- for the ProjectileWeapon family only (0x5121a8) -- weaponAlarm
+// (@0x364) != 7 (NoAmmo): a dry ballistic rack is not a fighting weapon.
+// Destroyed is the BOTH-CELLS test (gotcha #22: statusAlarm level + the
+// port's split simulationState). Returns -1 = not a weapon, else 0/1.
+//===========================================================================//
+int BTWeaponCountsForEject(Subsystem *sub)
+{
+ if (sub == 0 || !sub->IsDerivedFrom(MechWeapon::ClassDerivations))
+ return -1;
+ MechWeapon *w = (MechWeapon *)sub;
+ if (w->SimulationStateCell() == 1 || w->StatusAlarmLevel() == 1)
+ return 0; // destroyed (both cells)
+ if (sub->IsDerivedFrom(ProjectileWeapon::ClassDerivations)
+ && ((ProjectileWeapon *)sub)->WeaponAlarmLevel() == 7)
+ return 0; // NoAmmo (@0x364 == 7)
+ return 1;
+}