Files
BT411/game/reconstructed/ammobin.hpp
T
arcattackandClaude Fable 5 5ae4410914 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>
2026-07-25 14:58:16 -05:00

302 lines
14 KiB
C++

//============================================================================//
// File: ammobin.hpp //
// Project: BattleTech //
// Contents: Ammunition bin -- holds rounds, feeds a ProjectileWeapon, and //
// cooks off when overheated. //
//----------------------------------------------------------------------------//
// Date Who Modification //
// -------- --- ------------------------------------------------------------//
// 04/13/95 JM Initial coding. //
//----------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//============================================================================//
//
// RECONSTRUCTED from the shipped binary (Ghidra pseudo-C, module cluster
// @004bd2c0-@004bdb94 + CSS @004bd6f0 in part_013.c). Only the test fragment
// AMMOBIN.TCP survives (AmmoBin::TestClass -> True; AmmoBin::ResetToInitialState
// -> empty). Member/method names are inferred from the streamed resource keys
// ("AmmoCount"/"FeedRate"/"AmmoClassID"/"ProjectileClassID"/"MissileClassID"/
// "AmmoModelFile"/"ExplosionModelFile") and from usage; see ammobin.cpp for the
// per-method @ADDR evidence. Names past the HeatWatcher base are best-effort.
//
// Inheritance chain established from the decomp:
// Subsystem -> HeatableSubsystem -> HeatWatcher (ctor @4aeb40, vtable
// @0050ed10) -> AmmoBin (ctor @4bd5c4, vtable @0051286c, classID 0x0BCB).
// AmmoBin IS a HeatWatcher: it watches a temperature against the resource's
// degradation/failure thresholds, and when the watch alarm (this+0x140) reaches
// FAILURE it arms a randomised cook-off timer that dumps all stored rounds'
// heat into the mech and empties the bin. A ProjectileWeapon links to it as a
// SharedData connection (launcher this[0x10F]@0x43C, connection ctor @4bcbb0,
// vtable @00512424) and pulls a round via FeedAmmo() each time it fires.
//
// Object byte offsets (this is an int* in the decomp, word index in parens).
// Everything below +0x180 is the HeatWatcher / HeatableSubsystem / Subsystem
// base (watch heatAlarm level @0x140 = word 0x50; base status alarm @0x2C).
//
#if !defined (AMMOBIN_HPP)
# define AMMOBIN_HPP
# if !defined(HEATFAMILY_RESLICE_HPP)
# include <heatfamily_reslice.hpp> // HeatWatcher base (heat family)
# endif
//######################### Forward Class Declarations ########################
class Mech;
//###########################################################################
//################# AmmoBin Model Resource ##############################
//###########################################################################
//
// Streamed by AmmoBin::CreateStreamedSubsystem @004bd6f0 (record size 0x104,
// classID 0x0BCB). The ammo-specific block sits at +0xF0 past the heat-watcher
// resource header. "AmmoClassID" accepts the symbolic forms "ProjectileClassID"
// (stamps 0x0BD1) and "MissileClassID" (stamps 0x0BBA), naming the entity class
// the bin spawns when a round is fired.
//
struct AmmoBin__SubsystemResource:
public HeatWatcher::SubsystemResource
{
int ammoClassID; // +0xF0 "AmmoClassID"/"ProjectileClassID"/"MissileClassID"
int ammoModelFile; // +0xF4 "AmmoModelFile" (resolved model index)
int explosionModelFile; // +0xF8 "ExplosionModelFile" (resolved model index)
int ammoCount; // +0xFC "AmmoCount"
Scalar feedRate; // +0x100 "FeedRate" (seconds per round feed)
}; // ends 0x104
static_assert(offsetof(AmmoBin__SubsystemResource, ammoClassID) == 0xF0, "AmmoBin ammoClassID must be at 0xF0");
static_assert(offsetof(AmmoBin__SubsystemResource, feedRate) == 0x100, "AmmoBin feedRate must be at 0x100");
static_assert(sizeof(AmmoBin__SubsystemResource) == 0x104, "AmmoBin record must be 0x104");
//###########################################################################
//######################### CLASS -- AmmoBin #########################
//###########################################################################
//
// (vtable @0051286c, ctor @004bd5c4, dtor @004bd6b0, CSS @004bd6f0.)
//
class AmmoBin:
public HeatWatcher
{
friend struct AmmoBinLayoutCheck; // compile-time offset locks (ammobin.cpp)
friend int *BTAmmoBinCountPtr(void *bin); // panel ammo counter bridge (Streak fix)
friend int BTAmmoBinFeeding(void *bin); // panel reload-state bridge
friend int BTAmmoRoundModelResource(void *bin); // splash-radius resolve (round GameModel +0x50)
friend int BTAmmoBinCookOffArmed(void *bin); // #47: the eng-panel FIRE icon (bin+0x18C)
friend int BTAmmoBinCookOffTime(void *bin); // #47: the cook-off countdown numeric (bin+0x190)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Shared Data Support
//
public:
static Derivation ClassDerivations; // IsDerivedFrom tag 0x512488
static Receiver::MessageHandlerSet MessageHandlers;
static AttributeIndexSet AttributeIndex;
static SharedData DefaultData; // resolved as &DAT_005125bc
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Ammo feed state machine (this->ammoAlarm @0x194, level @0x1A8 = word 0x6A)
//
// The 6-level AlarmIndicator carries the feed cycle. Levels confirmed from
// usage; 3/4 decoded 2026-07-23 from the EjectAmmo handler @004bb9b8 (press
// raises 3 while the eject countdown runs; a tap-release blips 4 then 1
// before the FeedAmmo pull cycles the round out).
//
public:
enum AmmoState {
Feeding = 0, // a round is in transit (feedTimer counting down)
Loaded = 1, // a round is chambered and ready to fire
Empty = 2, // no rounds remain
Ejecting = 3, // EJECT held -- countdown live (@004bb9b8 press)
Ejected = 4, // tap-release blip before the round cycles out
Dumped = 5 // transient pulse raised by DumpAmmo() before Empty
};
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Model Support
//
public:
enum {
CookingOff = HeatWatcher::StateCount,
StateCount
};
// Attribute Support -- audio binds an AudioStateWatcher to AmmoState; it
// resolves to ammoAlarm (@0x194, a 0x54 StateIndicator-compatible alarm) so
// the feed/reload/empty audio fires on the AmmoState transition. The static
// AttributeIndex (ammobin.cpp) is now chained to HeatWatcher -- previously it
// was a bare, unchained index, so even SimulationState resolved to NULL.
enum {
AmmoStateAttributeID = HeatWatcher::NextAttributeID,
FireCountdownStartedAttributeID, // F18: the ammo COOK-OFF warning (binary
// AmmoBin table @0x512600 -> +0x18C); the 4
// authored start/stop match watchers gate the
// cook-off countdown klaxon on it
NextAttributeID
};
static const IndexEntry AttributePointers[];
typedef void
(AmmoBin::*Performance)(Scalar time_slice);
// @004bd394 -- the registered Performance. Drives the watch alarm, ticks
// the feed timer (Feeding -> Loaded), arms/fires the cook-off, and trips
// the Empty alarm when exhausted.
void
AmmoBinSimulation(Scalar time_slice);
void
SetPerformance(Performance performance)
{
Check(this);
activePerformance = (Simulation::Performance)performance;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Ammo feed interface (called by the linked ProjectileWeapon)
//
public:
// @004bd4f4 -- request one round. When Loaded: chamber the next round,
// reset the feed timer to feedRate, decrement ammoCount, and return the
// remaining count (Empty alarm raised at 0). Returns 0 if not ready /
// already feeding / empty / destroyed.
int
FeedAmmo();
// @004bd588 -- forcibly empty the bin (eject / strip): zero ammoCount and
// pulse the alarm Dumped(5) -> Empty(2).
void
DumpAmmo();
// The feed alarm's current level (this+0x1A8) -- the linked weapon's
// ProjectileWeaponSimulation @004bbd04 reads it raw off the resolved bin:
// gate 2 @4bbd80 (level 2/3 or bin destroyed -> weapon alarm 7), the
// Loading recoil-clamp Loaded test @4bbe25 (level 1 -> weapon Loaded), and
// the post-fire Empty test @4bbf11 (level 2 -> weapon alarm 7). Named
// accessor per the databinding rule (never a raw offset on our layout).
int
GetAmmoState() const { return ammoAlarm.GetLevel(); }
// The EjectAmmo handler @004bb9b8 drives the feed alarm from the linked
// weapon (press -> Ejecting(3); tap-release -> Ejected(4) then Loaded(1)
// ahead of the FeedAmmo pull). Named setter per the databinding rule.
void
SetAmmoState(int level) { ammoAlarm.SetLevel(level); }
// Rounds remaining (this+0x180) -- read-only, for the AMMO digits gauge and
// the [ammo] fire trace (named accessor per the databinding rule).
int
GetAmmoCount() const { return ammoCount; }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Subsystem virtual overrides (vtable @0051286c)
//
public:
Logical
HandleMessage(int message); // slot 8, @004bdb94
void
ReadUpdateRecord(UpdateRecord *update); // slot 9, @004bd2c0
void
ResetToInitialState(); // slot 10 (AMMOBIN.TCP: empty)
// issue #22 (PORT, fresh-mech respawn emulation): refill to the ctor
// state. AMMOBIN.TCP's RTIS is authentically EMPTY (kept so); the
// binary's respawn refilled ammo by building a NEW mech -- our
// entity-reuse respawn refills here instead.
void
DeathReset(int reset_command);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Local helpers
//
protected:
// @004bd300 -- cook-off: inject (ammoCount * heatPerRound) heat into the
// mech, then zero ammoCount and raise the Empty alarm.
void
CookOff();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// TestClass Support (AMMOBIN.TCP)
//
public:
static Logical
TestClass(Mech &); // AMMOBIN.TCP: returns True
Logical
TestInstance() const; // @004bd1e0
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Construction and Destruction
//
public:
typedef AmmoBin__SubsystemResource SubsystemResource;
AmmoBin( // @004bd5c4
Mech *owner,
int subsystem_ID,
SubsystemResource *subsystem_resource
);
~AmmoBin(); // @004bd6b0
static int
CreateStreamedSubsystem( // @004bd6f0 (record size 0x104)
NotationFile *model_file,
const char *model_name,
const char *subsystem_name,
SubsystemResource *subsystem_resource,
NotationFile *subsystem_file,
const ResourceDirectories *directories,
int passes = 1
);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Local data appended past the HeatWatcher base (offsets best-effort).
//
protected:
int ammoCount; // @0x180 (word 0x60) rounds remaining
Scalar feedTimer; // @0x184 (word 0x61) seconds left in current feed
int ammoClassID; // @0x188 (word 0x62) entity class spawned per round
int cookOffArmed; // @0x18C (word 0x63) cook-off timer running
int cookOffTime; // @0x190 (word 0x64) game clock at which it detonates
WatcherGaugeAlarm ammoAlarm; // @0x194 (0x54) 6-level feed alarm (level @0x1A8)
// (WAVE 4) `int statusState` DELETED -- it was a this+0x40 SHADOW of the
// inherited MechSubsystem::simulationState; the reads now use that base
// member. The 0x54 ammoAlarm (was an 8-byte HeatAlarm) lands ammoModelFile
// at 0x1E8 -- exact binary layout, locked by AmmoBinLayoutCheck.
int ammoModelFile; // @0x1E8 (word 0x7A) round model index
int explosionModelFile; // @0x1EC (word 0x7B) cook-off explosion model index
// @0x1F0..0x21C (words 0x7C..0x87): the cook-off DAMAGE record -- a real
// engine `Damage` (0x30 bytes: type@+0, amount@+4, force@+8, normal@+0x14,
// impact@+0x20, burstCount@+0x2C). Gitea #46 CORRECTION: the old
// reconstruction modeled this span as an opaque "heat-injection
// descriptor" (`cookOffHeatSource/heatPerRound/cookOffHeatRest`) -- wrong
// on three counts, all [T1]:
// * FUN_0041db7c is `Damage::Damage()` (raw disasm == the T0
// DAMAGE.cpp:34 ctor: amount 0, force identity, normal (0,1,0),
// burst 1);
// * "@0x1F4 = heatPerRound" is the record's `damageAmount` slot -- the
// LINKED WEAPON's per-round damage, stamped by the ProjectileWeapon
// ctor @004bc3fc (bin+0x1F0..0x21C <- weapon->damageData @0x3A8,
// via owner->roster[0x128][resource+0x1C0]; the projweap.cpp comment
// calling this "the bin's HUD display block" was wrong, and the
// stamping had never been wired -- so the record stayed all-zero);
// * CookOff() does not "inject heat": it builds a local copy with
// amount = ammoCount x damageAmount (fild [0x180]; fmul [0x1F4])
// and hands it to @004ac274 = MechSubsystem::DistributeCriticalHit,
// the per-DamageZone TakeDamage fan-out (see mechsub.cpp).
Damage cookOffDamage; // @0x1F0 (words 0x7C..0x87)
int initialAmmoCount; // @0x220 (word 0x88) ammoCount captured at ctor
Scalar feedRate; // @0x224 (word 0x89) resource FeedRate
int reserved; // @0x228 (word 0x8A) init 0 (unused)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Cross-family stamp (ProjectileWeapon ctor @004bc3fc -> this bin).
//
public:
// The binary writes the roster slot UNGUARDED (no AmmoBin type test) --
// every authored weapon's AmmoBin index names a real bin, so the port
// keeps the same contract behind this typed accessor.
void
StampCookOffDamage(const Damage &weapon_damage)
{ cookOffDamage = weapon_damage; }
};
#endif