BT410 Phase 5.3.15: the electrical charge model -- real charge, closed feedback loop

The emitter charge cycle is now the authentic electrical model end to end.

- WIRE-VERIFIED resource fix: the Emitter block is graphicLength /
  dischargeTime / seekVoltage[5] (FRACTIONS of the generator's rated voltage,
  -1 sentinel) / seekVoltageRecommendedIndex. The old two-field guess read
  seekVoltage[3]=0.99 as "dischargeTime 0.99s" -- the true PPC discharge is
  0.2s, and the calibration reads seekV={6000,7000,8000,9900} x rated 10000,
  recommended gear 2: the documented curve exactly.
- Ctor calibration (@004bb120): energyTotal=(dmg+heat)x1e7; EC pins
  E=0.5V^2EC to energyTotal at the recommended gear; voltageScale calibrates
  the exponential charge to reach seekV[rec] in the authored RechargeRate
  seconds cold; damageFraction = the damage share.
- TrackSeekVoltage (@004ba838) + ChargeTimeScale (@004b0d50): the charge
  integrates from the generator through the heat-stretched time scale, and
  the I2R loss heats the GENERATOR -- verified GeneratorA at 477.9K charging
  its PPC (BT411 predicted ~+480K) while the avionics generator idles at 77.
  Hot generators charge slower: the heat/firepower feedback loop is CLOSED.
- Emitter::ComputeOutputVoltage override (now virtual, as in the binary
  vtable): dial = level/seekV[gear], 0.01 snap, over-1 clamp.
- Sub-stepped Loading (1/60s slices -- the BT411 weapon-brick fix) + the
  documented-divergence overcharge rescue. VERIFIED: the PPC loads at level
  ~7920, inside the binary's exact [7920,8080] snap window.
- FireWeapon energy algebra (@004bace8): damage/heat = energy shares x
  chargeRatio^2. VERIFIED dmg=11.78 / heat=1.079e8 at ratio 0.9906. The
  heatCost x 1e7 partial is retired -- heat now comes from the energy.

Zero Fail; jams/shutdowns regressions stay live under spam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-24 09:14:56 -05:00
co-authored by Claude Fable 5
parent 6451a0a38d
commit b772e716fe
7 changed files with 372 additions and 43 deletions
+251 -37
View File
@@ -19,6 +19,8 @@
# include <mech.hpp>
#endif
#include <math.h>
Derivation
Emitter::ClassDerivations(
MechWeapon::ClassDerivations,
@@ -71,13 +73,105 @@ Emitter::Emitter(
Check_Pointer(subsystem_resource);
chargeLevel = 0.0f;
seekRate = 0.0f;
damagePortion = 0.0f;
heatPortion = 0.0f;
firingActive = 0;
graphicLength = subsystem_resource->graphicLength;
dischargeTime = subsystem_resource->dischargeTime;
dischargeTimer = 0.0f;
dischargeTimer = dischargeTime;
//
// Install the beam-weapon fire state machine (a replicant copy is driven by
// console updates / ServiceDischarge instead, still staged).
// Bring-up-safe pre-init: the electrical block below overwrites these with
// the calibrated values when the voltage source is live.
//
energyCoefficient = 1.0f;
seekVoltageIndex = 0;
seekVoltageRecommendedIndex = 0;
minSeekVoltageIndex = 0;
maxSeekVoltageIndex = 0;
{
for (int sv = 0; sv < 5; ++sv)
{
seekVoltage[sv] = 1.0f;
}
}
//
// energyTotal = (damage + heat) x 1e7 -- the discharge energy in the
// 1e7-native heat units.
//
energyTotal = (damageData.damageAmount + heatCostToFire) * 1.0e7f;
//
// The SeekVoltage calibration (binary ctor @004bb120): the authored curve
// values are FRACTIONS of the powering generator's rated voltage (-1
// sentinel ends the list); the energy coefficient pins E = 0.5*V^2*EC to
// energyTotal exactly at the recommended gear; and voltageScale calibrates
// the exponential charge level(t) = Vrated*(1 - exp(-t/(EC*voltageScale)))
// to reach the recommended seek voltage in the authored RechargeRate
// seconds on a COLD generator (0.0001 == 1/Vrated). Generator heat then
// stretches the scale through ChargeTimeScale -- the heat/firepower
// feedback.
//
{
Generator *src = (Generator *)ResolveVoltageSource();
if (src != NULL)
{
int i;
for (i = 0; i < 5; ++i)
{
seekVoltage[i] = 0.0f;
}
for (i = 0; i < 5; ++i)
{
if (subsystem_resource->seekVoltage[i] == -1.0f)
{
maxSeekVoltageIndex = i - 1;
break;
}
seekVoltage[i] = subsystem_resource->seekVoltage[i]
* src->RatedVoltageOf();
}
seekVoltageRecommendedIndex =
subsystem_resource->seekVoltageRecommendedIndex;
seekVoltageIndex = seekVoltageRecommendedIndex;
minSeekVoltageIndex = 0;
Scalar v = seekVoltage[seekVoltageRecommendedIndex];
energyCoefficient = energyTotal / (v * v * 0.5f);
voltageScale = (rechargeRate
/ -(Scalar)log((double)(1.0f - 0.0001f * v)))
/ energyCoefficient;
if (getenv("BT_POWER_LOG"))
{
DEBUG_STREAM << "[charge] '" << GetName()
<< "' seekV={" << seekVoltage[0] << "," << seekVoltage[1]
<< "," << seekVoltage[2] << "," << seekVoltage[3]
<< "," << seekVoltage[4] << "} rec=" << seekVoltageRecommendedIndex
<< " max=" << maxSeekVoltageIndex
<< " EC=" << energyCoefficient
<< " vScale=" << voltageScale
<< " discharge=" << dischargeTime
<< " E=" << energyTotal << endl << flush;
}
}
}
//
// damageFraction = the damage share of the discharge energy.
//
damageFraction = damageData.damageAmount /
(damageData.damageAmount + heatCostToFire);
//
// Install the beam-weapon fire state machine; spawn LOADING (the charge
// integrates up from zero). (A replicant copy is driven by console
// updates / ServiceDischarge instead -- that path joins the network wave.)
//
weaponAlarm.SetLevel(LoadingState);
if (owner->GetInstance() != Entity::ReplicantInstance)
{
SetPerformance(&Emitter::EmitterSimulation);
@@ -86,6 +180,80 @@ Emitter::Emitter(
Check_Fpu();
}
//
//#############################################################################
// TrackSeekVoltage -- integrate the charge from the powering generator
// (binary @004ba838): derive the seek rate from the voltage gap over the
// (heat-stretched) charge time-scale, step the level, and feed the charging
// I^2R loss back into the GENERATOR's heat -- recharging weapons is what
// heats generators, which conduct to their condensers and slow further
// charging through ChargeTimeScale. The heat/firepower feedback loop.
//#############################################################################
//
void
Emitter::TrackSeekVoltage(Scalar time_slice)
{
Check(this);
Generator *src = (Generator *)ResolveVoltageSource();
if (src == NULL)
{
return;
}
Scalar dtScale = ChargeTimeScale();
seekRate = (src->MeasuredVoltage() - chargeLevel) / dtScale;
chargeLevel = (seekRate / energyCoefficient) * time_slice + chargeLevel;
if (HeatModelActive())
{
src->AddPendingHeat(seekRate * seekRate * dtScale * time_slice);
}
}
//
//#############################################################################
// ComputeOutputVoltage -- the Emitter override (binary @004ba738): the
// recharge dial = the charge level as a fraction of the selected seek
// voltage, snapped to 1.0 within 0.01 and clamped to [0,1] (the byte-verified
// over-1 clamp zeroes the dial).
//#############################################################################
//
void
Emitter::ComputeOutputVoltage()
{
Check(this);
Scalar magnitude = (chargeLevel < 0.0f) ? -chargeLevel : chargeLevel;
if (magnitude > 1.0e-4f)
{
rechargeLevel = chargeLevel / seekVoltage[seekVoltageIndex];
}
else
{
rechargeLevel = 0.0f;
}
Scalar snap = rechargeLevel - 1.0f;
if (snap < 0.0f)
{
snap = -snap;
}
if (snap <= 0.01f)
{
rechargeLevel = 1.0f;
}
if (rechargeLevel < 0.0f)
{
rechargeLevel = 0.0f;
}
else if (rechargeLevel > 1.0f)
{
rechargeLevel = 0.0f;
}
}
Emitter::~Emitter()
{
}
@@ -121,32 +289,49 @@ void
{
Check(this);
dischargeTimer = dischargeTime;
chargeLevel = 0.0f;
recoil = rechargeRate; // full recoil -> dial 0, decays in Loading
ComputeOutputVoltage();
//
// Re-arm the beam-on countdown, then THE AUTHENTIC ENERGY ALGEBRA (binary
// @004bace8): the shot's damage and heat are the two shares of the stored
// charge energy, scaled by the SQUARE of the charge ratio (the level as a
// fraction of the recommended seek voltage) -- an under-charged or
// down-geared shot delivers proportionally less of both. At full charge on
// the recommended gear: damage = the authored DamageAmount verbatim, heat =
// heatCostToFire x 1e7 (the 1e7-native chain).
//
dischargeTimer = dischargeTime;
Scalar vRec = seekVoltage[seekVoltageRecommendedIndex];
Scalar chargeRatio = (vRec > 0.0f) ? (chargeLevel / vRec) : 1.0f;
damagePortion = (damageFraction * energyTotal * 1.0e-7f)
* chargeRatio * chargeRatio;
heatPortion = (1.0f - damageFraction) * energyTotal
* chargeRatio * chargeRatio;
//
// Dump the firing heat into our own thermal accumulator, under the
// heat-model experience gate (novice / standard fire generates no heat --
// authentic); the HeatSink step absorbs it next frame and conducts it
// toward the linked Condenser bank. The heat chain is 1e7-unit-native
// (the BT411 calibration audit): the authored EMITTER heatCostToFire is
// the closed form's full-charge value / 1e7 (PPC: 11 -> 1.1e8 units ->
// +632 K on its own 174000-mass sink). (PARTIAL: the
// (1-dF)*E*chargeRatio^2 charge-scaling joins the electrical-charge wave.)
// The damage record carries this shot's portion (the submission at the
// owner's target joins the targeting/damage wave).
//
damageData.damageAmount = damagePortion;
if (HeatModelActive())
{
AddPendingHeat(heatCostToFire * 10000000.0f);
AddPendingHeat(heatPortion);
}
//
// Spend the charge.
//
ComputeOutputVoltage();
chargeLevel = 0.0f;
firingActive = 1;
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[fire] '" << GetName()
<< "' FIRED (discharge=" << dischargeTime
<< "s recharge=" << rechargeRate
<< "s heat+=" << heatCostToFire
<< "' FIRED (dmg=" << damagePortion
<< " heat=" << heatPortion
<< " ratio=" << chargeRatio
<< " T=" << CurrentTemperatureOf() << ")" << endl << flush;
}
}
@@ -162,6 +347,7 @@ void
{
Check(this);
firingActive = 0;
weaponAlarm.SetLevel(LoadingState);
}
@@ -267,27 +453,55 @@ void
weaponAlarm.SetLevel(LoadingState);
}
//
// Recharge only while the electrical supply is Ready (the authentic
// charge integration gates on the voltage state): decay the recoil
// over the authored RechargeRate seconds; the dial rises 0 -> 1.
// THE CHARGE INTEGRATION, sub-stepped at the pod's locked 60 fps: the
// binary's Loading tick assumes fixed frames -- the Loaded transition
// fires while the dial crosses the +-0.01 snap window around the
// selected seek voltage, a window a variable-dt spike can jump clean
// over (the level then overshoots, the over-1 clamp zeroes the dial,
// and the weapon bricks in Loading -- the BT411 weapon-brick fix).
// Charging gates on the electrical Ready state.
//
if (GetVoltageState() == Ready)
{
recoil -= time_slice;
if (recoil <= 0.0f)
const Scalar kPodFrame = 1.0f / 60.0f;
Scalar remaining = time_slice;
int guard = 600;
while (remaining > 0.0f && guard-- > 0
&& GetWeaponState() == LoadingState)
{
recoil = 0.0f;
}
}
ComputeOutputVoltage();
if (recoil == 0.0f)
{
weaponAlarm.SetLevel(LoadedState);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[fire] '" << GetName()
<< "' LOADED (T=" << CurrentTemperatureOf() << ")"
<< endl << flush;
Scalar slice = (remaining < kPodFrame) ? remaining : kPodFrame;
remaining -= slice;
if (GetVoltageState() == Ready)
{
TrackSeekVoltage(slice);
}
ComputeOutputVoltage();
if (rechargeLevel == 1.0f)
{
weaponAlarm.SetLevel(LoadedState);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[fire] '" << GetName()
<< "' LOADED (level=" << chargeLevel
<< " T=" << CurrentTemperatureOf() << ")"
<< endl << flush;
}
break;
}
//
// Overcharge rescue (a DOCUMENTED DIVERGENCE, BT411 issue #21:
// the binary deadlocks here -- a mid-charge gear change can
// land the level above the new gear's snap window, the over-1
// clamp zeroes the dial forever, and the weapon bricks. The
// arcade's own math says full == the gear's seek voltage, so
// an overcharged weapon IS loaded).
//
if (chargeLevel > seekVoltage[seekVoltageIndex]
&& seekVoltage[seekVoltageIndex] > 0.0f)
{
rechargeLevel = 1.0f;
weaponAlarm.SetLevel(LoadedState);
break;
}
}
}
break;
+33 -4
View File
@@ -26,11 +26,19 @@
//####################### Emitter Model Resource ########################
//###########################################################################
//
// WIRE-VERIFIED layout: graphicLength, dischargeTime, then the SeekVoltage
// curve -- five FRACTIONS of the generator's rated voltage (-1 sentinel
// ends the list) -- and the recommended (default) gear index. (The earlier
// two-field guess read seekVoltage[3] = 0.99 as "dischargeTime 0.99 s".)
//
struct Emitter__SubsystemResource:
public MechWeapon::SubsystemResource
{
Scalar seekVoltage[5];
Scalar graphicLength;
Scalar dischargeTime;
Scalar seekVoltage[5];
int seekVoltageRecommendedIndex;
};
//###########################################################################
@@ -104,7 +112,10 @@
FireWeapon();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Per-frame simulation (the beam-weapon fire state machine).
// Per-frame simulation (the beam-weapon fire state machine) and the
// electrical charge model: the Loading state integrates currentLevel
// toward the powering generator's voltage (TrackSeekVoltage); Loaded snaps
// when the recharge dial reaches 1.0 (level ~= the selected seek voltage).
//
public:
typedef void
@@ -120,14 +131,32 @@
EmitterSimulation(Scalar time_slice);
void
ResetFiringState();
void
TrackSeekVoltage(Scalar time_slice);
virtual void
ComputeOutputVoltage(); // the charge-voltage form (level/seekV)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Local Data
// Local Data. chargeLevel is the ChargeLevel attribute alias of the raw
// charge voltage (currentLevel in the binary's naming).
//
protected:
Scalar chargeLevel;
Scalar chargeLevel; // the raw charge voltage (binary currentLevel @0x414)
Scalar dischargeTime;
Scalar dischargeTimer;
Scalar graphicLength;
Scalar seekRate;
Scalar energyCoefficient;
Scalar energyTotal;
Scalar damageFraction;
Scalar damagePortion;
Scalar heatPortion;
int firingActive;
int seekVoltageIndex;
int seekVoltageRecommendedIndex;
int minSeekVoltageIndex;
int maxSeekVoltageIndex;
Scalar seekVoltage[5];
};
#endif
+2
View File
@@ -253,6 +253,8 @@
GetHeatState() { Check(this); return heatAlarm.GetLevel(); }
Scalar
CurrentTemperatureOf() { Check(this); return currentTemperature; }
Scalar
StartingTemperatureOf() { Check(this); return startingTemperature; }
void
AddPendingHeat(Scalar heat)
{ Check(this); pendingHeat += heat; }
+41
View File
@@ -223,3 +223,44 @@ past ambient -- actively radiating the mech's heat away.
(0.05 x 6 x 20 = 6 -- the authentic tank), coolant refilled to it.
Zero Fail; the full expert economy regression stays green.
## The electrical charge model (Phase 5.3.15, 2026-07-24)
The emitter charge cycle is REAL (all bodies authentic from the BT411 RE):
- **Resource fix (wire-verified)**: the Emitter block is graphicLength,
dischargeTime, seekVoltage[5] (FRACTIONS of the generator's rated voltage,
-1 sentinel), seekVoltageRecommendedIndex. The earlier two-field guess had
read seekVoltage[3] = 0.99 as "dischargeTime 0.99 s" -- the TRUE PPC
discharge is 0.2 s, and the calibration reads seekV = {6000, 7000, 8000,
9900} x rated 10000, rec gear 2, exactly the documented curve.
- **Ctor calibration** (@004bb120): energyTotal = (damage + heat) x 1e7 (PPC
2.3e8); EC pins E = 0.5 V^2 EC to energyTotal at the recommended gear;
voltageScale calibrates the exponential charge to reach seekV[rec] in the
authored RechargeRate seconds on a COLD generator; damageFraction = the
damage share.
- **TrackSeekVoltage** (@004ba838): seekRate = (genV - level)/ChargeTimeScale;
level += rate/EC x dt; and the charging I^2R loss lands in the GENERATOR's
pendingHeat -- recharging weapons is what heats generators. VERIFIED:
GeneratorA at 477.9 K charging its PPC (BT411 predicted ~+480K), GeneratorD
(avionics only) idle at 77.
- **ChargeTimeScale** (@004b0d50, PoweredSubsystem): the base voltageScale
stretched by (1 + thermalResistivity x generator temp rise) -- hot
generators charge slower. The feedback loop is CLOSED.
- **Emitter::ComputeOutputVoltage** override (@004ba738, now virtual as in the
binary vtable): dial = level/seekV[gear], snap to 1.0 within 0.01, over-1
clamp zeroes.
- **Sub-stepped Loading** (the BT411 weapon-brick fix): the charge integrates
in 1/60 s slices so the +-0.01 Loaded snap window can't be jumped by a dt
spike; plus the DOCUMENTED-DIVERGENCE overcharge rescue (the binary
deadlocks a weapon whose level lands above a re-geared snap window).
VERIFIED: the PPC loads at level ~7920 -- inside the exact [7920, 8080]
window from the binary's disasm.
- **FireWeapon energy algebra** (@004bace8): damage/heat = the two
energy shares x chargeRatio^2 (level/seekV[rec]). VERIFIED: dmg=11.78,
heat=1.079e8 at ratio 0.9906 -- ~98% of authored at the snap edge. The
heatCost x 1e7 partial is retired; the heat now comes from the ENERGY.
Deferred: the ToggleSeekVoltage gear button (needs the energy-weapon message
table ids 4..0xb with pads), ServiceDischarge (replicant beam path), the beam
build + damage submission (targeting/render waves).
+3 -2
View File
@@ -189,8 +189,9 @@
GetWeaponState() { Check(this); return weaponAlarm.GetLevel(); }
Logical
CheckFireEdge();
void
ComputeOutputVoltage();
virtual void
ComputeOutputVoltage(); // vtable slot 17; the Emitter overrides
// with its charge-voltage form
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Local Data
+33
View File
@@ -179,6 +179,39 @@ int
return 0;
}
//
//#############################################################################
// ChargeTimeScale -- the heat/firepower feedback (binary @004b0d50):
// rise = max(0, sourceTemperature - sourceStartingTemperature)
// scale = max(voltageScale,
// (thermalResistivityCoefficient * rise + 1) * voltageScale)
// A hot generator stretches the exponential charge constant, so recharging
// slows exactly when the electrical plant is cooking.
//#############################################################################
//
Scalar
PoweredSubsystem::ChargeTimeScale()
{
Check(this);
Generator *source = (Generator *)voltageSource.Resolve();
if (source == NULL)
{
return voltageScale;
}
Scalar rise = source->CurrentTemperatureOf()
- source->StartingTemperatureOf();
if (rise < 0.0f)
{
rise = 0.0f;
}
Scalar stretched =
(thermalResistivityCoefficient * rise + 1.0f) * voltageScale;
return (stretched > voltageScale) ? stretched : voltageScale;
}
//
//#############################################################################
// PoweredSubsystemSimulation -- the per-frame electrical step (binary
+9
View File
@@ -93,6 +93,15 @@
unsigned
GetVoltageState() { Check(this); return electricalStateAlarm.GetLevel(); }
//
// The charge time-scale (binary @004b0d50) -- the heat/firepower
// feedback: the base voltageScale (the ctor-calibrated exponential
// charge constant) stretched by the powering generator's temperature
// rise above its start point.
//
Scalar
ChargeTimeScale();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Per-frame simulation (heat step + the electrical state machine).
//