#89 DEATH BLAST reconstructed: a dying mech splashes its neighborhood

The missing half of Advanced Damage, found by call-scanning
Explosion::SplashDamage @0042fad0: TWO callers, not one -- Missile::Perform
(the known #62 path) and 0x4a0bda, the UN-EXPORTED tail of
Mech::TakeDamageMessageHandler itself.  Raw disasm @0x4a07b8-0x4a0bda:
when the victim ENTERS dead(9)/eject(10) during the applications, the
binary sets the wreck burning (id 0x17, deferred -- handler not yet
reconstructed), spawns the death Explosion (model 0x31 -- our death-list
visuals stand in), and SPLASHES:
  gates : owning player's advancedDamageOn (+0x264) AND NOT
          suppressConsole (+0x258 -- eject sets it: PUNCH-OUTS NEVER
          BLAST, the authentic anti-suicide-bomb rule)
  damage: type 2 Explosive, amount = deathSplashDamage (mech+0x520),
          bursts = round(0.001 * moverMass * 15.0) -- scales with tonnage
  radius: deathSplashRadius (mech+0x524); per-victim falloff
          bursts/dist^1.25 in the shared core
Draco's collision-divert suspicion is settled: the blast is TYPE 2, the
divert never touched it -- the tail was simply never reconstructed.

Port: deathSplashDamage/Radius PROMOTED from the Wword scratch bank to
named Mech members (the bank is one GLOBAL array -- authored per-chassis
values were clobbered to the last-loaded mech); BTSplashCore split out of
the #62 weapon splash and shared; BTApplyDeathSplash + the death-edge arm
in the handler tail; BTPlayerConsoleSuppressed bridge (friend).

Bench (2-node, B parked 8.9u from a self-destructing A): blast fired with
authored madcat data (radius=50, amount=5, mass=75000 -> 1125 base
bursts), B took 73 bursts (falloff exact: 1125/8.91^1.25), cross-pod
delivery + cylinder spray verified on B's own log ([dmghit] type=2
burst=73 across zones).  ~365 damage at 9u -- Draco's 'double kills on
drops' economy restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Joe DiPrima
2026-08-05 16:52:32 -05:00
co-authored by Claude Fable 5
parent 85d6ef4a59
commit 297127d0d7
7 changed files with 211 additions and 2 deletions
+8
View File
@@ -1863,6 +1863,14 @@ int BTPlayerAdvancedDamageOn(void *player_v)
return (player_v != 0) ? ((BTPlayer *)player_v)->advancedDamageOn2 : 0;
}
// #89 death blast, second gate: suppressConsole (+0x258) is SET by the eject
// bookkeeping -- an EJECTED pilot's abandoned mech never blast-splashes
// (raw disasm @0x4a0ac8-0x4a0ad6).
int BTPlayerConsoleSuppressed(void *player_v)
{
return (player_v != 0) ? (int)((BTPlayer *)player_v)->suppressConsole : 0;
}
int BTPilotKills(void *pilot)
{
int k = pilot ? ((BTPlayer *)pilot)->GetKillCount() : 0;
+1
View File
@@ -438,6 +438,7 @@ class DropZone__ReplyMessage;
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)
friend int BTPlayerConsoleSuppressed(void *); // +0x258 reader (#89): death-blast eject gate @0x4a0ace
// 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
+38 -2
View File
@@ -1126,6 +1126,11 @@ void
// nothing (matches ENTITY.cpp:878; the binary would deref -1 -- it can't
// happen there because every mech ships a lookup table).
//
// #89 DEATH-BLAST edge arm: the binary's tail (@0x4a07b8) gates on the
// victim ENTERING dead/eject during THIS message (entry test edx = the
// pre-application disabled state). Capture the pre-state here.
const int deathBlastArmed = !IsMechDestroyed();
if (damageZones != 0 && message->damageZone >= 0
&& message->damageZone < damageZoneCount)
{
@@ -1212,6 +1217,34 @@ void
(float)message->damageData.impactPoint.y,
(float)message->damageData.impactPoint.z);
}
// #89 DEATH BLAST (raw disasm @0x4a07b8-0x4a0bda -- the un-exported tail
// of THIS handler; found by call-scanning Explosion::SplashDamage
// @0042fad0, which has exactly TWO callers: Missile::Perform and here).
// The binary, when the victim ENTERS dead(9)/eject(10) during the
// applications above:
// 1. self-dispatches SetBurningState (id 0x17, Damage payload, zone -1)
// -- the burning wreck. DEFERRED here: our 0x17 handler is not yet
// reconstructed; the port's wreck-fire visuals come from the death
// effect layer meanwhile.
// 2. spawns the death Explosion (Explosion::Make model 0x31 at the mech
// origin) -- the port's kill path already fires the authored death
// list visuals; not double-spawned.
// 3. calls SplashDamage on it, gated on the owning player's
// advancedDamageOn (+0x264) AND NOT suppressConsole (+0x258 -- set
// by the eject bookkeeping: an EJECTED pilot's mech never blasts):
// Damage{type=2 Explosive, amount=deathSplashDamage@0x520,
// impact=mech origin,
// burstCount=round(0.001 * moverMass@0x20c * 15.0)}
// radius=deathSplashRadius@0x524, excluded=the dying mech.
// Splash falloff (bursts / dist^1.25) applies inside per victim.
// 4. ForceUpdate(1). (A score/console post also rides this tail --
// the deferred id-0x16 family, tracked with #134.)
if (deathBlastArmed && IsMechDestroyed())
{
extern void BTApplyDeathSplash(void *mech_v);
BTApplyDeathSplash((void *)this);
}
}
//
@@ -1898,8 +1931,11 @@ Mech::Mech(
Wword(0x68) = model->cameraOffset; // FUN_00408440(this+0x1a0, rec+0xA8)
Wword(0x130) = model->deathEffectResourceID; // rec+0x74 -> mech+0x4c0
Wword(0x148) = model->deathSplashDamage; // rec+0x78 -> mech+0x520
Wword(0x149) = model->deathSplashRadius; // rec+0x7C -> mech+0x524
deathSplashDamage = model->deathSplashDamage; // rec+0x78 -> mech+0x520 (#89:
deathSplashRadius = model->deathSplashRadius; // rec+0x7C -> mech+0x524 named
// members -- the Wword bank is
// GLOBAL and clobbered per-mech
// values chassis-to-chassis)
Wword(0x195) = Wword(0x196) = 0;
Wword(0xfd) = 0;
masterAlarm.SetLevel(0); // FUN_0041bbd8(this+0xe7,0)
+6
View File
@@ -654,6 +654,12 @@ public:
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
// after the layout-locked fields so nothing shifts.
Scalar standingTemplateMaxY; // binary @0x518 collisionTemplate->maxY at ctor
// #89 DEATH BLAST (raw disasm @0x4a07b8-0x4a0bda): the authored death-
// explosion pair, model "gamedata" keys -> ctor. PROMOTED from the Wword
// scratch bank (the bank is one GLOBAL array -- per-mech authored values
// were silently clobbered to the last-loaded chassis).
Scalar deathSplashDamage; // binary @0x520 <- model rec+0x78 "DeathSplashDamage"
Scalar deathSplashRadius; // binary @0x524 <- model rec+0x7C "DeathSplashRadius"
Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX)
+80
View File
@@ -1235,6 +1235,10 @@ static Scalar
return radius;
}
static void
BTSplashCore(Entity *shooter, const Point3D &center, Entity *directVictim,
const Damage &dmgTemplate, Scalar radius); // #89 shared core (below)
void
BTApplySplashDamage(Entity *shooter, int weapon_subsys, const Point3D &center,
Entity *directVictim, const Damage &dmgTemplate)
@@ -1246,7 +1250,18 @@ void
<< " radius=" << radius << "\n" << std::flush;
if (radius <= 0.0f)
return; // this weapon authors no splash
BTSplashCore(shooter, center, directVictim, dmgTemplate, radius);
}
//
// #89: the splash CORE, shared by the weapon-detonation caller above and the
// DEATH BLAST below (the binary's two Explosion::SplashDamage call sites).
//
static void
BTSplashCore(Entity *shooter, const Point3D &center, Entity *directVictim,
const Damage &dmgTemplate, Scalar radius)
{
static const int s_log = getenv("BT_SPLASH_LOG") ? 1 : 0;
extern int BTIsRegisteredMech(Entity *e);
extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut);
Entity *cand[32];
@@ -1322,6 +1337,71 @@ void
}
}
//
// #89 DEATH BLAST -- the second authentic SplashDamage call site (raw disasm
// @0x4a07b8-0x4a0bda, the un-exported TakeDamage tail; provenance banner at
// the caller, mech.cpp). A dying mech's explosion damages every mech inside
// its authored DeathSplashRadius:
// gates : owning player's advancedDamageOn (+0x264) AND
// NOT suppressConsole (+0x258 -- eject sets it: punch-outs never
// blast) [T1 @0x4a0aa8-0x4a0ad6]
// damage : type 2 Explosive [T1 @0x4a0aef], amount = deathSplashDamage
// (mech+0x520, authored "DeathSplashDamage"), impact = mech origin
// bursts : round(0.001 * moverMass(+0x20c) * 15.0) [T1 @0x4a0b21-0x4a0b33]
// -- the blast scales with the chassis mass; the per-victim
// falloff (bursts/dist^1.25) applies in the core
// radius : deathSplashRadius (mech+0x524, authored "DeathSplashRadius")
// excluded: the dying mech itself (the binary's Explosion anchors on it;
// the core's candidate walk already excludes the shooter)
//
void BTApplyDeathSplash(void *mech_v)
{
Mech *m = (Mech *)mech_v;
static const int s_log = getenv("BT_SPLASH_LOG") ? 1 : 0;
if (m == 0)
return;
extern int BTPlayerAdvancedDamageOn(void *player_v);
extern int BTPlayerConsoleSuppressed(void *player_v);
void *player = m->GetPlayerLink();
if (player == 0)
{
// binary derefs mech+0x190 unguarded (every arcade mech had an owner);
// the port's link-less solo dummy skips, loudly under the log.
if (s_log)
DEBUG_STREAM << "[splash] DEATH: no player link, skipped\n" << std::flush;
return;
}
if (!BTPlayerAdvancedDamageOn(player) || BTPlayerConsoleSuppressed(player))
{
if (s_log)
DEBUG_STREAM << "[splash] DEATH: gated (advDmg="
<< BTPlayerAdvancedDamageOn(player)
<< " suppress=" << BTPlayerConsoleSuppressed(player)
<< ")\n" << std::flush;
return;
}
if (!(m->deathSplashRadius > 0.0f))
return; // chassis authors no blast
Damage dmg; // Damage::Damage @0x41db7c
dmg.damageType = (Enumeration)2; // Explosive
dmg.damageAmount = m->deathSplashDamage;
dmg.impactPoint = m->localOrigin.linearPosition;
int bursts = (int)(0.5f + 0.001f * (float)m->moverMass * 15.0f);
if (bursts < 1) bursts = 1;
dmg.burstCount = bursts;
if (s_log)
DEBUG_STREAM << "[splash] DEATH BLAST mech=" << m->GetEntityID()
<< " radius=" << (float)m->deathSplashRadius
<< " amount=" << (float)m->deathSplashDamage
<< " bursts=" << bursts
<< " (mass=" << (float)m->moverMass << ")\n" << std::flush;
BTSplashCore((Entity *)m, m->localOrigin.linearPosition, (Entity *)m,
dmg, m->deathSplashRadius);
}
// Called from ProjectileWeapon / MissileLauncher::FireWeapon (via the extern below)
// with the live muzzle, the SHOOTER mech (to resolve the real launch port), the owner's
// locked target entity + point, the launch speed (|muzzleVelocity|), and per-shot damage.
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# #89 DEATH-BLAST verify: B walks to ~12u of A and stands; A self-destructs
# (fast self-damage); A's death blast must splash B (Explosive, burst-scaled,
# radius-gated). PASS: A log [splash] DEATH BLAST + SPLASH matchlog to B;
# B log [dmghit] type=2 from A after A's death. advancedDamage=1 rides the
# expert egg already.
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 db_a.log db_b.log
bt_expert_egg MP.EGG DB.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" DB.EGG
(
export BT_SELF_DAMAGE=40 BT_SPLASH_LOG=1
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch db_a.log DB.EGG 0x0C -net 1601
)
sleep 2
(
export BT_GOTO=enemy BT_GOTO_STOP=12 BT_KEY_NOFOCUS=1 BT_SPLASH_LOG=1
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch db_b.log DB.EGG 0x03 -net 1501
)
sleep 5
python ../tools/btconsole.py DB.EGG 127.0.0.1:1501 127.0.0.1:1601 > db_relay.log 2>&1 &
RELAY=$!
sleep 120
kill $RELAY 2>/dev/null
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=== A death + blast:"
grep -aE "death cycle START|DEATH BLAST|\[splash\]" db_a.log | head -8
echo "=== B splash received (type=2 from A):"
grep -a "dmghit" db_b.log | grep "type=2" | head -6
echo "=== SPLASH matchlog on A:"
grep -a "SPLASH" db_a.log | head -3
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# #89 DEATH-BLAST verify: B walks to ~12u of A and stands; A self-destructs
# (fast self-damage); A's death blast must splash B (Explosive, burst-scaled,
# radius-gated). PASS: A log [splash] DEATH BLAST + SPLASH matchlog to B;
# B log [dmghit] type=2 from A after A's death. advancedDamage=1 rides the
# expert egg already.
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 db2_a.log db2_b.log
bt_expert_egg MP.EGG DB2.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" DB2.EGG
(
export BT_SELF_DAMAGE=5 BT_SPLASH_LOG=1
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch db2_a.log DB2.EGG 0x0C -net 1601
)
sleep 2
(
export BT_GOTO=enemy BT_GOTO_STOP=12 BT_KEY_NOFOCUS=1 BT_SPLASH_LOG=1
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch db2_b.log DB2.EGG 0x03 -net 1501
)
sleep 5
python ../tools/btconsole.py DB2.EGG 127.0.0.1:1501 127.0.0.1:1601 > db2_relay.log 2>&1 &
RELAY=$!
sleep 190
kill $RELAY 2>/dev/null
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=== A death + blast:"
grep -aE "death cycle START|DEATH BLAST|\[splash\]" db2_a.log | head -8
echo "=== B splash received (type=2 from A):"
grep -a "dmghit" db2_b.log | grep "type=2" | head -6
echo "=== SPLASH matchlog on A:"
grep -a "SPLASH" db2_a.log | head -3