Gitea #46: ammo bay fire now DETONATES and KILLS (+ #47 fire icon) -- three stacked kill-switches removed, the fuse decoded, and a vptr-alias corruption caught by regression
Field report (night 3): "two ammo bay fires and no death" (Cyd + RajelAran; one
purged, one left burning). Root cause = THREE independent kill-switches stacked
on the same path, all in ammobin.cpp:
1. `GameClock::Now() { return 0; }` -- `cookOffTime < Now()` was `0 < 0`, so
an ARMED bay fire never detonated.
2. `InjectHeat(void*) {}` -- the detonation body was a no-op.
3. The bin's Damage record was never stamped -- 0 damage of type 0 (which the
mech TakeDamage handler drops) even if 1+2 had fired.
THE FUSE (raw disasm, scratchpad/disammo.py): the old "RandomDelay" was a Ghidra
carve artifact -- FUN_004dcd94 is __ftol and the export DROPPED the caller's x87
expression. The real bytes @004bd450: fld 10.0 / fmul [ticksPerSecond] /
fadd 0.5 / __ftol -- a FIXED 10.0-SECOND fuse in clock ticks. New gotcha #19
(reconstruction-gotchas.md) documents the __ftol export blind spot.
THE DAMAGE RECORD: bin+0x1F0..0x21C is a real engine `Damage` (FUN_0041db7c IS
Damage::Damage(), byte-matched to T0 DAMAGE.cpp). The linked ProjectileWeapon's
ctor @004bc3fc stamps it from weapon->damageData @0x3A8 via
owner->roster[0x128][res+0x1C0] -- projweap.cpp's old comment called this "the
bin's HUD display block ... wired in the AmmoBin family"; both halves were wrong
and it was wired nowhere. Now stamped (before MissileLauncher's ctor divides by
missileCount -- a missile bin authentically holds the per-SALVO amount).
THE DETONATION (@004ac274 = MechSubsystem::DistributeCriticalHit -- the old
"HeatableSubsystem::InjectHeat" label was wrong, and the old reconstruction
iterated a stand-in CriticalChain whose First()/Next() returned 0):
statusAlarm pulse Exploding(2)->Destroyed(1) (slot 13 = the printSimulationState
state PRINT @004ac8c0, not an "explosion notify"), own private zone pinned
destroyed, then collect the mech DamageZones whose crit entries plug the bin
(the binary filters plug classID 0x4E = DamageZoneClassID -- VDATA.h idx 78,
cross-checked via idx 28 = AudioStateTrigger), split the amount evenly, and send
the OWNER one full Entity::TakeDamageMessage per zone: inflictingEntity = SELF,
damageZone = the zone index, inflictingSubsystemID = the bin (the message-
manager explosion-bundling key, ENTITY3.h's own NOTE), printing the binary's
exact "ammo explosion damaging <zoneName>" @0050df61.
Port shape: Mech::AmmoExplosionFanOut (mechdmg.cpp) behind a databinding bridge;
guarded deviation: zoneCount==0 warns instead of the binary's unguarded divide.
CriticalChain/CriticalEntry stand-ins DELETED from mechrecon.hpp.
VERIFIED LIVE (BT_BAYTEST hook = message 1, the crit-induced arm channel):
scratchpad/baytest.py : arm -> 10s -> "20 rounds x 35 = 700 (type 2)" ->
"ammo explosion damaging dz_ltorso" -> zone cascade -> mech DESTROYED
(authentic death list).
scratchpad/baypurge.py: arm -> eject-hold purge -> "bay fire EXTINGUISHED
(bin empty)", no detonation.
scratchpad/sim3.py : the HEAT route arms organically in combat (overheated
AFC100), detonates "11 x 25 = 275 (type 1)" split across dz_larm + dz_lgun.
BAYBOOM matchlog record added for MP field forensics.
FIX-OF-THE-FIX (caught by the sim3 regression, would have shipped a crash):
MechSubsystem's ReconDamageZone proxy puts structureLevel at OFFSET 0 -- which
ALIASES THE REAL DamageZone's VTABLE POINTER (the private zone is `new
DamageZone`, mechsub.cpp:154; mechsub.hpp:260 documents the alias). My first
DistributeCriticalHit kept the old body's `damageZone->structureLevel = 1.0f`
and OVERWROTE THE ZONE'S VPTR with 0x3F800000; the respawn sweep's virtual
SetGraphicState (vtable+0xC) then called through it -> AV at 0x3f80000c in
RespawnRepair, one frame after a bay-fire death. ALL EIGHT proxy-view sites in
mechsub.cpp swept to the engine view (((DamageZone*)damageZone)->damageLevel
@0x158) -- including two silently-wrong LIVE readers: GetStatusFlags (vptr as
float -> always "intact") and ApplyDamageAndMeasure (the crit cascade's
measure). Ruled out first by evidence: the weapon->bin stamps were all clean
(six stamps, all classID 0xbcb, logged).
#47 (half 1 -- the FIRE ICON): BallisticWeaponCluster::Execute @004c9a38 reads
bin+0x18C = cookOffArmed into the btefire.pcc TwoState, and while armed computes
(Now - cookOffTime)/ticksPerSecond -- the COOK-OFF COUNTDOWN -- into the numeric
beside it. The old reconstruction misread 0x18C as "the reload state" and
bridged the icon to BTAmmoBinFeeding, so it blinked on every feed and never lit
on a bay fire (RajelAran: "it doesn't"). Now driven by the
BTAmmoBinCookOffArmed/CookOffTime complete-type bridges. [T2 -- the data path
is rig-verified; the pixels await the next live session.]
#47 (half 2 -- the ENG-BUTTON FLASH): fully mapped, deliberately NOT built this
session. The authored data SHIPS (BTL4.RES carries exactly one type-31
GaugeAlarmStream); the chain is alarm SetLevel -> gauge-watcher socket ->
Renderer msg 7 -> GaugeAlarmManager::Activate @00448d00 (T0) -> the BTL4
override @004cc148..@004cc2fc (btl4galm.cpp's provenance note claiming "no
override body exists" is WRONG -- corrected in-file) -> LampManager::FindLamp
@00444c80 -> Lamp::SetAlertState @00444e64 (flash counter) -> the L4 flush
@00474e94 emitting flashFast states 0x37/0x13 (== T0 L4LAMP.cpp:234-239).
Missing: the override bodies, the gauge-watcher sender, the aux-button lamps.
3-piece plan in context/open-questions.md.
Also logged: HandleMessage is vtable slot 8/9 in the binary but NON-virtual
across 10 reconstruction classes (bit the BT_BAYTEST hook; typed call used, gap
documented in open-questions).
KB: combat-damage.md (the full cook-off section), decomp-reference.md (the
cluster addresses + the GaugeAlarm/lamp map + BT_BAYTEST env), gauges-hud.md
(the fire-icon correction), reconstruction-gotchas.md #19 (__ftol),
open-questions.md (2 entries), btl4galm.cpp provenance correction.
checkctx CLEAN. 40 LNK2019 unchanged (the two pre-existing families).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d39227ef39
commit
5ae4410914
+112
-28
@@ -24,8 +24,9 @@
|
||||
// excluded : the 0x41xxxx engine vtable slots and the HeatWatcher /
|
||||
// HeatableSubsystem base bodies (FUN_004aeb40 / FUN_004ac644 chain)
|
||||
//
|
||||
// Decoded constant:
|
||||
// Decoded constants (raw disasm, scratchpad/disammo.py -- Gitea #46):
|
||||
// _DAT_004bd4e8 = 0.0f (feed-tick guard: advance only when time_slice > 0)
|
||||
// _DAT_004bd4ec = 10.0f _DAT_004bd4f0 = 0.5f (the cook-off FUSE, below)
|
||||
//
|
||||
// Resource classID stamps (CreateStreamedSubsystem @004bd6f0):
|
||||
// "ProjectileClassID" -> 0x0BD1 "MissileClassID" -> 0x0BBA
|
||||
@@ -37,10 +38,27 @@
|
||||
// FUN_004aea84 HeatWatcher::ReadUpdate (slot 9 base)
|
||||
// FUN_004aeac4 HeatWatcher::WatchSimulation (drive watch alarm from temp)
|
||||
// FUN_004ac1d4 HeatableSubsystem::HandleMessage (slot 8 base)
|
||||
// FUN_004ac274 HeatableSubsystem::InjectHeat(descriptor)
|
||||
// FUN_004ac274 MechSubsystem::DistributeCriticalHit -- the ammo-explosion
|
||||
// per-DamageZone TakeDamage fan-out (mechsub.cpp). The old
|
||||
// "HeatableSubsystem::InjectHeat" label was WRONG (#46).
|
||||
// FUN_0041b9ec AlarmIndicator::Init(levels) FUN_0041baa4 AlarmIndicator::Destroy
|
||||
// FUN_0041bbd8 AlarmIndicator::SetLevel FUN_0041db7c init heat descriptor
|
||||
// FUN_00414b60 GameClock::Now() FUN_004dcd94 random delay
|
||||
// FUN_0041bbd8 AlarmIndicator::SetLevel
|
||||
// FUN_0041db7c Damage::Damage() -- the T0 DAMAGE.cpp ctor, byte-matched.
|
||||
// The old "init heat descriptor" label was WRONG (#46).
|
||||
// FUN_00414b60 Now() -- returns &Time (pause-aware tick counter); the
|
||||
// caller dereferences .ticks (same mapping as btl4pb.cpp).
|
||||
// FUN_004dcd94 __ftol (float->int). The old "random delay" label was a
|
||||
// GHIDRA ARTIFACT: the export dropped the caller's x87
|
||||
// expression and left a bare `iVar = FUN_004dcd94();`. The
|
||||
// raw bytes @004bd450 are:
|
||||
// fld dword [0x4bd4ec] ; 10.0f
|
||||
// fmul dword [0x52140c] ; x SystemClock ticksPerSecond
|
||||
// fadd dword [0x4bd4f0] ; + 0.5f (rounding)
|
||||
// call __ftol
|
||||
// i.e. the cook-off fuse is a FIXED 10.0 SECONDS, not random.
|
||||
// (DAT_0052140c is the ticks-per-second global -- set to 28.0
|
||||
// or 18.206 at startup, part_000.c:921/926, and used as the
|
||||
// ticks->seconds divisor everywhere else.) [T1]
|
||||
// FUN_0041a1a4 IsDerivedFrom FUN_004022d0 operator delete
|
||||
//
|
||||
|
||||
@@ -60,23 +78,36 @@
|
||||
static const Scalar AmmoFeedTickFloor = 0.0f; // _DAT_004bd4e8
|
||||
|
||||
//
|
||||
// Reconstruction support: small proxy types + unrecovered-helper stand-ins for
|
||||
// the cook-off heat descriptor, the streamed-resource parser and the game clock.
|
||||
// Reconstruction support: small proxy types for the streamed-resource parser.
|
||||
//
|
||||
struct HeatRecord { Scalar amount; };
|
||||
struct AmmoModelRecord{ int index; };
|
||||
static const Scalar _DAT_004bdb74 = -1.0f; // FeedRate "unset" sentinel
|
||||
|
||||
static void InitCookOffHeatSource(void *) {} // FUN_0041db7c
|
||||
static void InjectHeat(void *) {} // FUN_004ac274
|
||||
static void SetStatusLevel(int) {} // FUN_0041bbd8 (status alarm)
|
||||
static int ReadInt(NotationFile *f, const char *p, const char *k, int *v) { return f ? f->GetEntry(p, k, v) : 0; }
|
||||
static int ReadScalar(NotationFile *f, const char *p, const char *k, Scalar *v) { return f ? f->GetEntry(p, k, v) : 0; }
|
||||
static int ReadString(NotationFile *f, const char *p, const char *k, const char **v){ return f ? f->GetEntry(p, k, v) : 0; }
|
||||
static void ReportMissing(const char *, const char *) {} // FUN_004dbb24
|
||||
static AmmoModelRecord *LookupModel(NotationFile *, const char *, int, int) { return 0; } // FUN_00406ff8
|
||||
namespace GameClock { static int Now() { return 0; } } // FUN_00414b60
|
||||
static int RandomDelay() { return 0; } // FUN_004dcd94
|
||||
|
||||
//
|
||||
// Gitea #46 -- THE COOK-OFF CLOCK. Three stand-ins used to sit here and each
|
||||
// was a kill-switch stacked on the same path:
|
||||
// * `GameClock::Now() { return 0; }` -> `cookOffTime < Now()` was `0 < 0`,
|
||||
// so an ARMED bay fire never detonated (the field report exactly:
|
||||
// "two ammo bay fires and no death");
|
||||
// * `RandomDelay() { return 0; }` -> a misdecode; see the header block --
|
||||
// the real fuse is a FIXED 10.0 seconds in clock ticks;
|
||||
// * `InjectHeat(void*) {}` -> even a firing cook-off applied
|
||||
// NOTHING; the real @004ac274 is the per-DamageZone TakeDamage fan-out.
|
||||
//
|
||||
static int BTGameClockNowTicks() // FUN_00414b60, dereferenced
|
||||
{
|
||||
return ((volatile Time &)Now()).ticks;
|
||||
}
|
||||
static int BTCookOffFuseTicks() // fld 10.0; fmul tps; fadd 0.5; __ftol
|
||||
{
|
||||
return (int)(10.0f * (float)SystemClock::GetTicksPerSecond() + 0.5f);
|
||||
}
|
||||
|
||||
//###########################################################################
|
||||
//###########################################################################
|
||||
@@ -144,7 +175,10 @@ AmmoBin::AmmoBin(
|
||||
// (WAVE 4) statusState=0 removed -- the base MechSubsystem ctor owns
|
||||
// simulationState@0x40; the reads below use the inherited member.
|
||||
ammoAlarm.Initialize(6); // @0x194 FUN_0041b9ec(this+0x65, 6)
|
||||
InitCookOffHeatSource(&cookOffHeatSource); // @0x1F0 FUN_0041db7c(this+0x7c)
|
||||
// @0x1F0: FUN_0041db7c(this+0x7c) is Damage::Damage() -- our cookOffDamage
|
||||
// member ctor already ran it (amount 0, normal (0,1,0), burst 1). The
|
||||
// REAL type/amount arrive later, stamped by the linked ProjectileWeapon's
|
||||
// ctor @004bc3fc (StampCookOffDamage <- weapon->damageData). [#46]
|
||||
reserved = 0; // @0x228
|
||||
|
||||
ammoCount = resource->ammoCount; // @0x180 resource +0xFC
|
||||
@@ -200,6 +234,23 @@ int BTAmmoBinFeeding(void *bin)
|
||||
return (bin != 0 && ((AmmoBin *)bin)->ammoAlarm.Level() == AmmoBin::Feeding) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Gitea #47: the eng-panel FIRE icon + cook-off countdown bridges. The
|
||||
// binary's BallisticWeaponCluster::Execute (@004c9a38) reads
|
||||
// this[0x3b] = *(bin + 0x18C) <- cookOffArmed (the btefire TwoState)
|
||||
// this[0x3e] = (*Now() - *(bin+0x190)) / ticksPerSecond
|
||||
// <- seconds relative to DETONATION
|
||||
// The old gauge reconstruction misread bin+0x18C as "the reload state" and
|
||||
// bridged it to BTAmmoBinFeeding -- so the FIRE icon blinked on every reload
|
||||
// feed and never lit on a bay fire (RajelAran: "it doesn't").
|
||||
int BTAmmoBinCookOffArmed(void *bin)
|
||||
{
|
||||
return (bin != 0) ? ((AmmoBin *)bin)->cookOffArmed : 0;
|
||||
}
|
||||
int BTAmmoBinCookOffTime(void *bin)
|
||||
{
|
||||
return (bin != 0) ? ((AmmoBin *)bin)->cookOffTime : 0;
|
||||
}
|
||||
|
||||
// The round's GameModel resource ID (ammoModelFile @0x1E8, word 0x7A). In the
|
||||
// arcade the MissileLauncher seeds each spawned Missile's model from AmmoBin+0x1e8
|
||||
// (part_013.c:8778) and the Missile ctor reads SplashRadius from that model's
|
||||
@@ -256,17 +307,31 @@ void AmmoBin::DumpAmmo()
|
||||
//#############################################################################
|
||||
// CookOff -- detonate the remaining rounds
|
||||
//
|
||||
// @004bd300
|
||||
// @004bd300 (raw disasm, scratchpad/disammo.py -- Gitea #46)
|
||||
//
|
||||
// Loads the prebuilt heat descriptor, sets its heat amount to
|
||||
// (ammoCount * heatPerRound), injects it into the mech via the HeatableSubsystem
|
||||
// base, then zeroes the bin and raises the Empty alarm.
|
||||
// Copies the weapon-stamped cook-off Damage record to a local, scales its
|
||||
// amount to (ammoCount x per-round damageAmount):
|
||||
// fild dword [ebx+0x180] ; ammoCount (int)
|
||||
// fmul dword [ebp-0x2c] ; x damageAmount (FLOAT -- fmul, not fild;
|
||||
// ; the old "(float)param_1[0x7d]" int
|
||||
// ; reading was a Ghidra rendering)
|
||||
// then hands it to @004ac274 = MechSubsystem::DistributeCriticalHit -- the
|
||||
// status-alarm pulse + own-zone gut + per-DamageZone TakeDamage fan-out
|
||||
// (mechsub.cpp) -- and finally zeroes the bin and raises the Empty alarm.
|
||||
//
|
||||
void AmmoBin::CookOff()
|
||||
{
|
||||
HeatRecord record = *(HeatRecord *)&cookOffHeatSource; // words 0x7C, 0x7E..0x87
|
||||
record.amount = (Scalar)ammoCount * heatPerRound; // this[0x60] * this[0x7D]
|
||||
InjectHeat(&record); // FUN_004ac274(this, &record)
|
||||
Damage record = cookOffDamage; // words 0x7C..0x87 -> locals
|
||||
record.damageAmount =
|
||||
(Scalar)ammoCount * cookOffDamage.damageAmount; // fild [0x180] * fmul [0x1F4]
|
||||
|
||||
// Rare + forensic -- always log (the DumpAmmo precedent).
|
||||
DEBUG_STREAM << "[ammo] " << GetName() << " BAY FIRE DETONATION: "
|
||||
<< ammoCount << " rounds x " << cookOffDamage.damageAmount
|
||||
<< " = " << record.damageAmount
|
||||
<< " (type " << (int)record.damageType << ")\n" << std::flush;
|
||||
|
||||
DistributeCriticalHit(record); // FUN_004ac274(this, &record)
|
||||
ammoCount = 0; // this+0x180
|
||||
ammoAlarm.SetLevel(Empty); // 2
|
||||
}
|
||||
@@ -303,25 +368,38 @@ void AmmoBin::AmmoBinSimulation(Scalar time_slice)
|
||||
}
|
||||
}
|
||||
|
||||
// arm cook-off when overheated and not already empty
|
||||
// arm cook-off when overheated and not already empty: the 10-second fuse
|
||||
if (heatAlarm.Level() == 2 /* FAILURE, this+0x140 */ &&
|
||||
ammoAlarm.Level() != Empty && cookOffArmed == 0)
|
||||
{
|
||||
cookOffArmed = 1; // this+0x18C
|
||||
cookOffTime = GameClock::Now() + RandomDelay(); // this+0x190
|
||||
cookOffTime = BTGameClockNowTicks() + BTCookOffFuseTicks(); // this+0x190
|
||||
// Rare + forensic -- always log (mirrors the detonation line).
|
||||
DEBUG_STREAM << "[ammo] " << GetName() << " BAY FIRE: cook-off armed, "
|
||||
<< ammoCount << " rounds, detonation in 10s (purge to cancel)\n" << std::flush;
|
||||
}
|
||||
|
||||
// fire / cancel cook-off
|
||||
if (cookOffArmed != 0)
|
||||
{
|
||||
if (ammoAlarm.Level() == Empty)
|
||||
{
|
||||
cookOffArmed = 0; // nothing left -- cancel
|
||||
else if (cookOffTime < GameClock::Now())
|
||||
DEBUG_STREAM << "[ammo] " << GetName()
|
||||
<< " bay fire EXTINGUISHED (bin empty)\n" << std::flush;
|
||||
}
|
||||
else if (cookOffTime < BTGameClockNowTicks())
|
||||
{
|
||||
cookOffArmed = 0;
|
||||
SetStatusLevel(1); // FUN_0041bbd8(this+0xb, 1) -- subsystem damaged
|
||||
// slot 13 (explosion notify): authoritative-only virtual call in the
|
||||
// shipped code; runs through the installed vtable at run time.
|
||||
statusAlarm.SetLevel(1); // FUN_0041bbd8(this+0xb=+0x2C, 1) -- Destroyed
|
||||
// @004bd4b1: `if (this[0x41]) (*vtbl+0x34)(this)` -- this+0x104 is
|
||||
// printSimulationState and slot 13 is the OnAlarmChanged state
|
||||
// PRINT ("<name> = __Destroyed/__Exploding", @004ac8c0). The old
|
||||
// "authoritative-only explosion notify" comment was wrong [#46];
|
||||
// the explosion VISUAL rides the TakeDamageMessage's
|
||||
// inflictingSubsystemID (the message-manager explosion bundling).
|
||||
if (printSimulationState)
|
||||
OnAlarmChanged();
|
||||
CookOff(); // @004bd300
|
||||
}
|
||||
}
|
||||
@@ -344,7 +422,9 @@ Logical AmmoBin::HandleMessage(int message)
|
||||
if (cookOffArmed == 0)
|
||||
{
|
||||
cookOffArmed = 1;
|
||||
cookOffTime = GameClock::Now() + RandomDelay(); // FUN_00414b60 + FUN_004dcd94
|
||||
cookOffTime = BTGameClockNowTicks() + BTCookOffFuseTicks(); // Now() + the 10s fuse
|
||||
DEBUG_STREAM << "[ammo] " << GetName() << " BAY FIRE (message): cook-off armed, "
|
||||
<< ammoCount << " rounds, detonation in 10s\n" << std::flush;
|
||||
return True;
|
||||
}
|
||||
return False;
|
||||
@@ -510,7 +590,11 @@ struct AmmoBinLayoutCheck
|
||||
static_assert(offsetof(AmmoBin, ammoAlarm) == 0x194, "AmmoBin ammoAlarm @0x194 (0x54-byte WatcherGaugeAlarm)");
|
||||
static_assert(offsetof(AmmoBin, ammoModelFile) == 0x1E8, "AmmoBin ammoModelFile @0x1E8 (proves ammoAlarm==0x54 AND statusState deleted)");
|
||||
static_assert(offsetof(AmmoBin, explosionModelFile) == 0x1EC, "AmmoBin explosionModelFile @0x1EC");
|
||||
static_assert(offsetof(AmmoBin, heatPerRound) == 0x1F4, "AmmoBin heatPerRound @0x1F4");
|
||||
// #46: the cook-off Damage record occupies exactly words 0x7C..0x87.
|
||||
static_assert(offsetof(AmmoBin, cookOffDamage) == 0x1F0, "AmmoBin cookOffDamage @0x1F0 (words 0x7C..0x87)");
|
||||
static_assert(sizeof(Damage) == 0x30, "Damage must be 0x30 (12 words -- the binary descriptor span)");
|
||||
static_assert(offsetof(Damage, damageAmount) == 0x4, "Damage::damageAmount @+4 -- @004bd300 fmul reads bin+0x1F4");
|
||||
static_assert(offsetof(Damage, burstCount) == 0x2C, "Damage::burstCount @+0x2C -- @004bc3fc writes bin+0x21C");
|
||||
static_assert(offsetof(AmmoBin, initialAmmoCount) == 0x220, "AmmoBin initialAmmoCount @0x220");
|
||||
static_assert(offsetof(AmmoBin, feedRate) == 0x224, "AmmoBin feedRate @0x224");
|
||||
static_assert(offsetof(AmmoBin, reserved) == 0x228, "AmmoBin reserved @0x228 (+4 => sizeof 0x22C)");
|
||||
|
||||
Reference in New Issue
Block a user