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> # include <mech.hpp>
#endif #endif
#include <math.h>
Derivation Derivation
Emitter::ClassDerivations( Emitter::ClassDerivations(
MechWeapon::ClassDerivations, MechWeapon::ClassDerivations,
@@ -71,13 +73,105 @@ Emitter::Emitter(
Check_Pointer(subsystem_resource); Check_Pointer(subsystem_resource);
chargeLevel = 0.0f; chargeLevel = 0.0f;
seekRate = 0.0f;
damagePortion = 0.0f;
heatPortion = 0.0f;
firingActive = 0;
graphicLength = subsystem_resource->graphicLength;
dischargeTime = subsystem_resource->dischargeTime; dischargeTime = subsystem_resource->dischargeTime;
dischargeTimer = 0.0f; dischargeTimer = dischargeTime;
// //
// Install the beam-weapon fire state machine (a replicant copy is driven by // Bring-up-safe pre-init: the electrical block below overwrites these with
// console updates / ServiceDischarge instead, still staged). // 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) if (owner->GetInstance() != Entity::ReplicantInstance)
{ {
SetPerformance(&Emitter::EmitterSimulation); SetPerformance(&Emitter::EmitterSimulation);
@@ -86,6 +180,80 @@ Emitter::Emitter(
Check_Fpu(); 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() Emitter::~Emitter()
{ {
} }
@@ -121,32 +289,49 @@ void
{ {
Check(this); Check(this);
dischargeTimer = dischargeTime; //
chargeLevel = 0.0f; // Re-arm the beam-on countdown, then THE AUTHENTIC ENERGY ALGEBRA (binary
recoil = rechargeRate; // full recoil -> dial 0, decays in Loading // @004bace8): the shot's damage and heat are the two shares of the stored
ComputeOutputVoltage(); // 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 // The damage record carries this shot's portion (the submission at the
// heat-model experience gate (novice / standard fire generates no heat -- // owner's target joins the targeting/damage wave).
// 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.)
// //
damageData.damageAmount = damagePortion;
if (HeatModelActive()) if (HeatModelActive())
{ {
AddPendingHeat(heatCostToFire * 10000000.0f); AddPendingHeat(heatPortion);
} }
//
// Spend the charge.
//
ComputeOutputVoltage();
chargeLevel = 0.0f;
firingActive = 1;
if (getenv("BT_MECH_LOG")) if (getenv("BT_MECH_LOG"))
{ {
DEBUG_STREAM << "[fire] '" << GetName() DEBUG_STREAM << "[fire] '" << GetName()
<< "' FIRED (discharge=" << dischargeTime << "' FIRED (dmg=" << damagePortion
<< "s recharge=" << rechargeRate << " heat=" << heatPortion
<< "s heat+=" << heatCostToFire << " ratio=" << chargeRatio
<< " T=" << CurrentTemperatureOf() << ")" << endl << flush; << " T=" << CurrentTemperatureOf() << ")" << endl << flush;
} }
} }
@@ -162,6 +347,7 @@ void
{ {
Check(this); Check(this);
firingActive = 0;
weaponAlarm.SetLevel(LoadingState); weaponAlarm.SetLevel(LoadingState);
} }
@@ -267,27 +453,55 @@ void
weaponAlarm.SetLevel(LoadingState); weaponAlarm.SetLevel(LoadingState);
} }
// //
// Recharge only while the electrical supply is Ready (the authentic // THE CHARGE INTEGRATION, sub-stepped at the pod's locked 60 fps: the
// charge integration gates on the voltage state): decay the recoil // binary's Loading tick assumes fixed frames -- the Loaded transition
// over the authored RechargeRate seconds; the dial rises 0 -> 1. // 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; const Scalar kPodFrame = 1.0f / 60.0f;
if (recoil <= 0.0f) Scalar remaining = time_slice;
int guard = 600;
while (remaining > 0.0f && guard-- > 0
&& GetWeaponState() == LoadingState)
{ {
recoil = 0.0f; Scalar slice = (remaining < kPodFrame) ? remaining : kPodFrame;
} remaining -= slice;
} if (GetVoltageState() == Ready)
ComputeOutputVoltage(); {
if (recoil == 0.0f) TrackSeekVoltage(slice);
{ }
weaponAlarm.SetLevel(LoadedState); ComputeOutputVoltage();
if (getenv("BT_MECH_LOG")) if (rechargeLevel == 1.0f)
{ {
DEBUG_STREAM << "[fire] '" << GetName() weaponAlarm.SetLevel(LoadedState);
<< "' LOADED (T=" << CurrentTemperatureOf() << ")" if (getenv("BT_MECH_LOG"))
<< endl << flush; {
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; break;
+33 -4
View File
@@ -26,11 +26,19 @@
//####################### Emitter Model Resource ######################## //####################### 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: struct Emitter__SubsystemResource:
public MechWeapon::SubsystemResource public MechWeapon::SubsystemResource
{ {
Scalar seekVoltage[5]; Scalar graphicLength;
Scalar dischargeTime; Scalar dischargeTime;
Scalar seekVoltage[5];
int seekVoltageRecommendedIndex;
}; };
//########################################################################### //###########################################################################
@@ -104,7 +112,10 @@
FireWeapon(); 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: public:
typedef void typedef void
@@ -120,14 +131,32 @@
EmitterSimulation(Scalar time_slice); EmitterSimulation(Scalar time_slice);
void void
ResetFiringState(); 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: protected:
Scalar chargeLevel; Scalar chargeLevel; // the raw charge voltage (binary currentLevel @0x414)
Scalar dischargeTime; Scalar dischargeTime;
Scalar dischargeTimer; 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 #endif
+2
View File
@@ -253,6 +253,8 @@
GetHeatState() { Check(this); return heatAlarm.GetLevel(); } GetHeatState() { Check(this); return heatAlarm.GetLevel(); }
Scalar Scalar
CurrentTemperatureOf() { Check(this); return currentTemperature; } CurrentTemperatureOf() { Check(this); return currentTemperature; }
Scalar
StartingTemperatureOf() { Check(this); return startingTemperature; }
void void
AddPendingHeat(Scalar heat) AddPendingHeat(Scalar heat)
{ Check(this); pendingHeat += 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. (0.05 x 6 x 20 = 6 -- the authentic tank), coolant refilled to it.
Zero Fail; the full expert economy regression stays green. 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(); } GetWeaponState() { Check(this); return weaponAlarm.GetLevel(); }
Logical Logical
CheckFireEdge(); CheckFireEdge();
void virtual void
ComputeOutputVoltage(); ComputeOutputVoltage(); // vtable slot 17; the Emitter overrides
// with its charge-voltage form
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Local Data // Local Data
+33
View File
@@ -179,6 +179,39 @@ int
return 0; 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 // PoweredSubsystemSimulation -- the per-frame electrical step (binary
+9
View File
@@ -93,6 +93,15 @@
unsigned unsigned
GetVoltageState() { Check(this); return electricalStateAlarm.GetLevel(); } 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). // Per-frame simulation (heat step + the electrical state machine).
// //