Files
BT411/game/reconstructed/ammobin.cpp
T
arcattackandClaude Opus 5 ef8e449a17 #55 steps 0/1/2/4: the 1995 DEATH pass restored, the arg gate made real, and 5 subsystems the respawn sweep never reached
THE HEADLINE: the port's death sweep was a total no-op, and fixing it required
fixing the arg gate in the same commit -- otherwise corpses refill their ammo.

STEP 2 -- the authentic dispatch shape:
  * engine/MUNGA/SUBSYSTM.h: Subsystem::DeathShutdown gains the binary's
    universal base body { DeathReset(c) } (@004ad10e).  It was empty, and NO
    port class overrode it, so everything the 1995 game does at death was
    skipped entirely.
  * mech4.cpp death sweep now passes 0, not 1 (the binary's @0049fe0c arg) --
    the wreck shape: alarms/state settle, nothing refills.
  * ARG PROPAGATION (mandatory once the sweep runs): AmmoBin::DeathReset is now
    arg-gated exactly as @004bd26c -- refill only when arg != 0, ammoAlarm
    unconditional.  Ignoring the arg was harmless only while the sweep was
    dead; with it restored, every corpse would have re-armed.  Also forwarded
    in PoweredSubsystem / HeatSink / Condenser / HeatableSubsystem / Generator
    (each binary body forwards it -- verified addresses in the plan).

STEP 4 -- coverage for 5 classes that had an authentic slot-10 body but no
DeathReset, so the respawn sweep fell through to the empty base:
  Torso (this is Gitea #70 -- "loosing torso twist function after a death"),
  Gyroscope, Myomers, HUD, Seeker.

STEPS 0/1 -- observability + the David chain:
  * Three unconditional matchlog rows: DEAD_NOTIFY (mech + resolved link),
    PLAYER_LINK (player <-> vehicle), RESPAWN (mode/alive/zones/subsys/pos).
    A respawn was previously INVISIBLE in the matchlog -- night 3's analysis
    had to infer them from ammo arithmetic.
  * Mech::PlayerLinkMessageHandler + the death dispatch site: when the engine's
    one-shot registry lookup misses (no null check, no retry -> the whole
    death/respawn cycle silently swallowed), recover the SAME object via the
    reverse link the binary's own respawn branch walks (player+0x1FC ==
    playerVehicle).  Complete-type TU, no raw offsets.
  * deathPending cleared in the first-spawn branch (a latch carried in would
    permanently kill every later respawn -- the #57 class).

VERIFIED on the 2-pod rig (madcat vs thor, forced kills, 5 death/respawn cycles
per pod): build clean, ZERO new /FORCE unresolved externs from the 5 new
overrides; refill lines appear ONLY immediately before a Mech::Reset and NEVER
between a death and the next respawn (the corpse-refill regression this commit
had to pre-empt); all three probe rows present in both pods' matchlogs; every
DEAD_NOTIFY carried a non-null link.

DELIBERATELY DEFERRED (documented, not forgotten): the mechsub.cpp rename pair
(ResetToInitialState -> GenerateFault, ClearStatus -> the root reset @004ac22c),
deleting HeatableSubsystem::ResetToInitialState, and deleting RespawnRepair.
Those need the HeatableSubsystem vtable-slot-10 pre-flight the plan calls for,
and we have no binary image here to dump the vtable from -- guessing at it
risks the vptr-alias trap.  Next session with the decomp shards open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:09:31 -05:00

634 lines
27 KiB
C++

//============================================================================//
// File: ammobin.cpp //
// Project: BattleTech //
// Contents: Ammunition bin (AmmoBin : HeatWatcher) -- feed + cook-off //
//----------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//============================================================================//
//
// RECONSTRUCTED from the shipped binary. Only AMMOBIN.TCP survives
// (AmmoBin::TestClass -> True, AmmoBin::ResetToInitialState -> empty). Behaviour
// follows the Ghidra pseudo-C in part_013.c; each non-trivial method cites the
// originating @ADDR. Names past the HeatWatcher base are best-effort.
//
// Coverage:
// confident : ctor @004bd5c4 (HeatWatcher chain, resource copy, alarm/cook-off
// init), dtor thunk @004bd6b0, CreateStreamedSubsystem @004bd6f0,
// FeedAmmo @004bd4f4, DumpAmmo @004bd588, CookOff @004bd300,
// HandleMessage @004bdb94, ReadUpdate @004bd2c0,
// AmmoBinSimulation @004bd394, TestInstance @004bd1e0
// best-effort: the cook-off heat-injection descriptor layout (@0x1F0..0x21C),
// the precise meaning of base status word @0x40 (subsystem damage
// state) and the alarm level names
// excluded : the 0x41xxxx engine vtable slots and the HeatWatcher /
// HeatableSubsystem base bodies (FUN_004aeb40 / FUN_004ac644 chain)
//
// 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
// AmmoBin record: classID @+0x20 = 0x0BCB, size @+0x24 = 0x104
//
// Helper-function name mapping (engine internals referenced by the decomp):
// FUN_004aeb40 HeatWatcher base constructor (owner,id,resource,name,shared)
// FUN_004aebe8 HeatWatcher destructor FUN_004aec54 HeatWatcher CSS
// 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 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 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
//
#include <bt.hpp>
#pragma hdrstop
#if !defined(AMMOBIN_HPP)
# include <ammobin.hpp>
#endif
#if !defined(TESTBT_HPP)
# include <testbt.hpp>
#endif
//
// Feed-tick guard, read as a read-only global in the decomp.
//
static const Scalar AmmoFeedTickFloor = 0.0f; // _DAT_004bd4e8
//
// Reconstruction support: small proxy types for the streamed-resource parser.
//
struct AmmoModelRecord{ int index; };
static const Scalar _DAT_004bdb74 = -1.0f; // FeedRate "unset" sentinel
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
//
// 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);
}
//###########################################################################
//###########################################################################
// AmmoBin
//###########################################################################
//###########################################################################
//#############################################################################
// Shared Data Support
//
Derivation
AmmoBin::ClassDerivations(
HeatWatcher::GetClassDerivations(),
"AmmoBin"
);
Receiver::MessageHandlerSet AmmoBin::MessageHandlers;
// AmmoState -> ammoAlarm (@0x194). Chained to HeatWatcher so the inherited
// SimulationState (and any parent attrs) resolve -- the old bare AttributeIndex
// had no parent, so nothing resolved on an AmmoBin.
const AmmoBin::IndexEntry
AmmoBin::AttributePointers[]=
{
ATTRIBUTE_ENTRY(AmmoBin, AmmoState, ammoAlarm), // @0x194 (0x54 StateIndicator-compatible feed alarm)
ATTRIBUTE_ENTRY(AmmoBin, FireCountdownStarted, cookOffArmed) // F18 @0x18C -- ammo cook-off warning gate
};
Simulation::AttributeIndexSet AmmoBin::AttributeIndex(
ELEMENTS(AmmoBin::AttributePointers),
AmmoBin::AttributePointers,
HeatWatcher::GetAttributeIndex()
);
AmmoBin::SharedData
AmmoBin::DefaultData( // resolved as &DAT_005125bc
&AmmoBin::ClassDerivations,
AmmoBin::MessageHandlers,
AmmoBin::AttributeIndex,
AmmoBin::StateCount
);
//#############################################################################
// Construction
//
// @004bd5c4 AmmoBin::AmmoBin(Mech *owner, int id, SubsystemResource *resource)
//
// Chains the HeatWatcher base ctor (which sets up the temperature watch and
// its degradation/failure thresholds), stamps the AmmoBin vtable, builds the
// 6-level feed alarm and the cook-off heat descriptor, and copies the ammo
// resource fields. The feed timer starts at 0 and the alarm at Loaded(1).
// The active cook-off/feed Performance (PTR_FUN_00512654 -> AmmoBinSimulation)
// is installed only for an authoritative (non-ghost) mech.
//
AmmoBin::AmmoBin(
Mech *owner,
int subsystem_ID,
SubsystemResource *resource)
:
// HeatWatcher base ctor (owner, id, resource, shared default data).
HeatWatcher(owner, subsystem_ID, resource, DefaultData) // @004aeb40
{
// vtable installed by the compiler (PTR_FUN_0051286c)
// (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)
// @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
feedRate = resource->feedRate; // @0x224 resource +0x100
ammoClassID = resource->ammoClassID; // @0x188 resource +0xF0
ammoModelFile = resource->ammoModelFile; // @0x1E8 resource +0xF4
explosionModelFile = resource->explosionModelFile; // @0x1EC resource +0xF8
feedTimer = 0; // @0x184
// install the feed/cook-off Performance for the authoritative simulation only
if ((owner->simulationFlags & 0xC) == 0 && (owner->simulationFlags & 0x100) != 0) // owner+0x28
SetPerformance(&AmmoBin::AmmoBinSimulation); // PTR_FUN_00512654
ammoAlarm.SetLevel(Loaded); // @0x1A8 FUN_0041bbd8(this+0x65, 1)
initialAmmoCount = ammoCount; // @0x220
cookOffArmed = 0; // @0x18C
}
//#############################################################################
// Destruction
//
// @004bd6b0 ~AmmoBin() -- restores the vtable, tears down the feed alarm, then
// chains the HeatWatcher base destructor.
//
AmmoBin::~AmmoBin()
{
// vtable reset, alarm teardown and base ~HeatWatcher chaining handled by the
// compiler.
}
//#############################################################################
// FeedAmmo -- request one round (called by the linked ProjectileWeapon)
//
// @004bd4f4
//
// Returns 1 when a round was dispensed, 0 when the bin cannot deliver
// (destroyed / mid-feed / empty). Only the Loaded state dispenses: it switches
// to Feeding, reloads the feed timer to feedRate, decrements ammoCount (clamped
// at 0) and raises the Empty alarm if that was the last round.
//
// Panel bridges (Streak-gauge fix, 2026-07-12): the BallisticWeaponCluster
// read the bin's rounds via raw `bin+0x180` and the reload state via raw
// `bin+0x18c` -- the BINARY's offsets, garbage on our compiled layout (the
// HeatWatcher base sizes differ), so the ammo counter showed a constant and
// the reload wipe never armed. Complete-type reads of the NAMED members.
int *BTAmmoBinCountPtr(void *bin)
{
return (bin != 0) ? &((AmmoBin *)bin)->ammoCount : 0;
}
int BTAmmoBinFeeding(void *bin)
{
// "reloading" for the panel = the bin mid-feed (alarm state Feeding).
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
// GameModel record +0x50 (part_013.c:10184). The port re-expresses missiles as
// local flying rounds, so BTResolveSplashRadius (mech4.cpp) walks THIS path to
// recover the authored blast radius. Complete-type read of the named member.
int BTAmmoRoundModelResource(void *bin)
{
return (bin != 0) ? ((AmmoBin *)bin)->ammoModelFile : 0;
}
int AmmoBin::FeedAmmo()
{
if (simulationState == 1) // this+0x40 == 1 (destroyed)
return 0;
switch (ammoAlarm.Level()) // this+0x1A8
{
case Loaded: // 1
ammoAlarm.SetLevel(Feeding); // 0
feedTimer = feedRate; // this+0x184 <- this+0x224
--ammoCount; // this+0x180
if (ammoCount < 1)
ammoAlarm.SetLevel(Empty); // 2
if (ammoCount < 0)
ammoCount = 0; // clamp
return 1; // dispensed
case Feeding: // 0 -- still chambering
case Empty: // 2
default:
return 0;
}
}
//#############################################################################
// DumpAmmo -- forcibly empty the bin (eject / strip)
//
// @004bd588
//
void AmmoBin::DumpAmmo()
{
if (ammoAlarm.Level() != Empty) // this+0x1A8 != 2
{
// ALWAYS log the bay dump (rare + forensic -- eject-hold / strip)
DEBUG_STREAM << "[ammo] " << GetName() << " bay DUMPED overboard ("
<< ammoCount << " rounds jettisoned)\n" << std::flush;
ammoCount = 0; // this+0x180
ammoAlarm.SetLevel(Dumped); // 5 (transient pulse)
ammoAlarm.SetLevel(Empty); // 2
}
}
//#############################################################################
// CookOff -- detonate the remaining rounds
//
// @004bd300 (raw disasm, scratchpad/disammo.py -- Gitea #46)
//
// 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()
{
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
}
//#############################################################################
// AmmoBinSimulation -- the registered Performance
//
// @004bd394
//
// 1. Drive the watch alarm from the monitored temperature (HeatWatcher base).
// 2. If destroyed, force Empty.
// 3. Tick the feed timer while Feeding; on expiry, chamber (-> Loaded).
// 4. When the watch alarm hits FAILURE (level 2) and rounds remain, arm a
// randomised cook-off; on expiry damage the subsystem, fire the explosion
// notification (vtable slot 13) and CookOff(). Cancel if the bin empties.
// 5. Trip the Empty alarm whenever ammoCount drops below 1.
//
void AmmoBin::AmmoBinSimulation(Scalar time_slice)
{
WatchSimulation(time_slice); // FUN_004aeac4(this) -- drive this+0x140
if (simulationState == 1) // this+0x40 (destroyed)
ammoAlarm.SetLevel(Empty);
// feed timer (only while a round is in transit)
if (AmmoFeedTickFloor < time_slice && ammoAlarm.Level() == Feeding)
{
if (time_slice < feedTimer)
feedTimer -= time_slice;
else
{
feedTimer = 0;
ammoAlarm.SetLevel(Loaded);
}
}
// 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 = 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
DEBUG_STREAM << "[ammo] " << GetName()
<< " bay fire EXTINGUISHED (bin empty)\n" << std::flush;
}
else if (cookOffTime < BTGameClockNowTicks())
{
cookOffArmed = 0;
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
}
}
if (ammoCount < 1)
ammoAlarm.SetLevel(Empty);
}
//#############################################################################
// HandleMessage (slot 8, @004bdb94)
//
// Message 1 begins the cook-off countdown (arm the randomised timer once);
// any other message delegates to the HeatableSubsystem base.
//
Logical AmmoBin::HandleMessage(int message)
{
if (message != 1)
return MechSubsystem::HandleMessage(message); // base delegate (AmmoBin : HeatWatcher : MechSubsystem)
if (cookOffArmed == 0)
{
cookOffArmed = 1;
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;
}
//#############################################################################
// ReadUpdate (slot 9, @004bd2c0)
//
// Applies a network update via the HeatWatcher base; if the synced status has
// just transitioned to destroyed (state 1), dump all rounds and mark Empty.
//
void AmmoBin::ReadUpdateRecord(UpdateRecord *update)
{
int previousState = simulationState; // this+0x40 (inherited MechSubsystem)
HeatWatcher::ReadUpdateRecord(update); // FUN_004aea84
if (simulationState == 1 && previousState != 1)
{
ammoCount = 0; // this+0x180
ammoAlarm.SetLevel(Empty); // 2
}
}
//#############################################################################
// DeathReset -- issue #22 (PORT, fresh-mech respawn emulation)
//
// The binary respawn built a NEW mech: a fresh AmmoBin ctor = full bin,
// Loaded feed alarm, idle feed timer, cook-off disarmed. Our entity-reuse
// respawn restores exactly those ctor values. (ResetToInitialState below
// stays authentically EMPTY per AMMOBIN.TCP.)
//
void AmmoBin::DeathReset(int reset_command)
{
//
// THE ARG GATE IS AUTHENTIC AND LOAD-BEARING (@004bd26c, #55 step 2):
// if (arg) { feedTimer = 0; [0x228] = 0; ammoCount = initialAmmoCount;
// cookOffArmed = 0; }
// ammoAlarm.SetLevel(Loaded); // UNCONDITIONAL
// The refill runs on a RESPAWN (arg 1) and must NOT run at DEATH (arg 0) --
// a wreck keeps its counts. This body used to ignore the arg, which was
// harmless only while the death sweep was a no-op; the moment that sweep
// was restored (SUBSYSTM.h DeathShutdown -> DeathReset with arg 0) an
// unguarded refill would have re-armed every corpse.
//
if (reset_command != 0)
{
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[respawn] ammo bin " << subsystemID << " refilled "
<< ammoCount << " -> " << initialAmmoCount << "\n" << std::flush;
ammoCount = initialAmmoCount; // @0x180 <- @0x220 (ctor value)
feedTimer = 0; // @0x184
cookOffArmed = 0; // @0x18C
}
ammoAlarm.SetLevel(Loaded); // @0x1A8 -- unconditional per the binary
}
//#############################################################################
// ResetToInitialState (slot 10)
//
// AMMOBIN.TCP ships this as an empty override.
//
void AmmoBin::ResetToInitialState()
{
}
//#############################################################################
// CreateStreamedSubsystem
//
// @004bd6f0 (record size 0x104, classID 0x0BCB)
//
// Chains the HeatWatcher resource parser, stamps the AmmoBin classID/size, then
// reads the ammo block. "AmmoClassID" accepts the symbolic forms
// "ProjectileClassID" (-> 0x0BD1) and "MissileClassID" (-> 0x0BBA). The model
// files are resolved to indices; every key is required (missing -> fail).
//
int AmmoBin::CreateStreamedSubsystem(
NotationFile *model_file,
const char *model_name,
const char *subsystem_name,
SubsystemResource *resource,
NotationFile *subsystem_file,
const ResourceDirectories *directories,
int passes)
{
if (!HeatWatcher::CreateStreamedSubsystem(model_file, model_name, subsystem_name, resource, // FUN_004aec54
subsystem_file, directories, passes))
return 0;
resource->classID = (RegisteredClass::ClassID)0x0BCB; // resource +0x20
resource->subsystemModelSize = 0x104; // resource +0x24
if (passes == 1)
{
resource->ammoCount = -1; // +0xFC sentinel
resource->feedRate = -1.0f; // +0x100 sentinel
resource->ammoClassID = -1; // +0xF0 sentinel
resource->ammoModelFile = -1; // +0xF4 sentinel
resource->explosionModelFile = -1; // +0xF8 sentinel
}
if (!ReadInt(subsystem_file, subsystem_name, "AmmoCount", &resource->ammoCount) &&
resource->ammoCount == -1)
{ ReportMissing(subsystem_name, "missing AmmoCount"); return 0; }
if (!ReadScalar(subsystem_file, subsystem_name, "FeedRate", &resource->feedRate) &&
resource->feedRate == _DAT_004bdb74 /* sentinel */)
{ ReportMissing(subsystem_name, "missing FeedRate"); return 0; }
const char *id = "Unspecified";
ReadString(subsystem_file, subsystem_name, "AmmoClassID", &id);
if (strcmp(id, "Unspecified") == 0 && resource->ammoClassID == -1)
{ ReportMissing(subsystem_name, "missing AmmoClassID"); return 0; }
if (strcmp(id, "Unspecified") != 0)
{
if (strcmp(id, "ProjectileClassID") == 0) resource->ammoClassID = 0x0BD1;
else if (strcmp(id, "MissileClassID") == 0) resource->ammoClassID = 0x0BBA;
else if (resource->ammoClassID == -1)
{ ReportMissing(subsystem_name, "invalid AmmoClassID"); return 0; }
}
const char *ammoModel = "Unspecified";
ReadString(subsystem_file, subsystem_name, "AmmoModelFile", &ammoModel);
if (strcmp(ammoModel, "Unspecified") != 0)
{
AmmoModelRecord *m = LookupModel(model_file, ammoModel, 1, -1); // FUN_00406ff8
if (m == 0 && resource->ammoModelFile == -1)
{ ReportMissing(ammoModel, "must be declared before .bld"); return 0; }
if (m != 0) resource->ammoModelFile = m->index;
}
else if (resource->ammoModelFile == -1)
{ ReportMissing(subsystem_name, "missing AmmoModelFile"); return 0; }
const char *exModel = "Unspecified";
ReadString(subsystem_file, subsystem_name, "ExplosionModelFile", &exModel);
if (strcmp(exModel, "Unspecified") != 0)
{
AmmoModelRecord *m = LookupModel(model_file, exModel, 1, -1);
if (m == 0 && resource->explosionModelFile == -1)
{ ReportMissing(exModel, "cannot find in resource file"); return 0; }
if (m != 0) resource->explosionModelFile = m->index;
}
else if (resource->explosionModelFile == -1)
{ ReportMissing(model_name, "missing ExplosionModelFile"); return 0; }
return 1;
}
//#############################################################################
// TestInstance
//
// @004bd1e0 IsDerivedFrom(ClassDerivations tag 0x512488).
//
Logical AmmoBin::TestInstance() const
{
return IsDerivedFrom(AmmoBin::ClassDerivations);
}
//#############################################################################
// TestClass (AMMOBIN.TCP)
//
Logical AmmoBin::TestClass(Mech &)
{
return True;
}
//#############################################################################
// WAVE 4 -- compile-time layout locks (ZERO-headroom: sizeof MUST be 0x22C).
// Own fields are protected -> the friend AmmoBinLayoutCheck grants offsetof.
// The `ammoModelFile == 0x1E8` lock is LOAD-BEARING: it fails the build if
// either the ammoAlarm retype (HeatAlarm 8B -> WatcherGaugeAlarm 0x54) or the
// statusState deletion is missed. HeatWatcher is locked at 0x180.
//#############################################################################
struct AmmoBinLayoutCheck
{
static_assert(sizeof(AmmoBin) == 0x22C, "sizeof(AmmoBin) must be 0x22C (factory alloc, part_012.c:10091)");
static_assert(offsetof(AmmoBin, ammoCount) == 0x180, "AmmoBin own block starts at 0x180 (HeatWatcher ends 0x180)");
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");
// #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)");
};
//===========================================================================//
// Factory bridge -- AmmoBin (factory case 0xBCB, "JumpJet" label).
// The real class at 0xBCB (ctor @004bd5c4) is AmmoBin; the factory built a
// JumpJet RECON_SUBSYS stub. alloc 0x22c, ctor(owner,id,resource) -- the ctor
// supplies AmmoBin::DefaultData to the HeatWatcher base itself. No cache
// write; not a weapon (raw part_012.c:10090-10099 stores roster-only).
// NOTE: the Blackhawk (all-laser) carries NO 0xBCB; exercise with a ballistic/
// missile mech (MECH2.EGG = Mad Cat, LRMs). Its FeedAmmo consumer
// (ProjectileWeapon/MissileLauncher 0xBCD/0xBD0) is still stubbed, so full
// effect couples to the weapons wave -- but the bin's own feed/cook-off ticks.
//===========================================================================//
Subsystem *CreateAmmoBinSubsystem(Mech *owner, int id, void *seg)
{
return (Subsystem *) new (Memory::Allocate(0x22c))
AmmoBin(owner, id, (AmmoBin::SubsystemResource *)seg);
}