From b772e716feeede89403092136929ef4df130fef5 Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 24 Jul 2026 09:14:29 -0500 Subject: [PATCH] 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 --- restoration/source410/BT/EMITTER.CPP | 288 +++++++++++++++++++++---- restoration/source410/BT/EMITTER.HPP | 37 +++- restoration/source410/BT/HEAT.HPP | 2 + restoration/source410/BT/HEAT.NOTES.md | 41 ++++ restoration/source410/BT/MECHWEAP.HPP | 5 +- restoration/source410/BT/POWERSUB.CPP | 33 +++ restoration/source410/BT/POWERSUB.HPP | 9 + 7 files changed, 372 insertions(+), 43 deletions(-) diff --git a/restoration/source410/BT/EMITTER.CPP b/restoration/source410/BT/EMITTER.CPP index ada57a12..10a7666d 100644 --- a/restoration/source410/BT/EMITTER.CPP +++ b/restoration/source410/BT/EMITTER.CPP @@ -19,6 +19,8 @@ # include #endif +#include + 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; diff --git a/restoration/source410/BT/EMITTER.HPP b/restoration/source410/BT/EMITTER.HPP index b48bf7cd..6d4b46ff 100644 --- a/restoration/source410/BT/EMITTER.HPP +++ b/restoration/source410/BT/EMITTER.HPP @@ -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 diff --git a/restoration/source410/BT/HEAT.HPP b/restoration/source410/BT/HEAT.HPP index f01e0c7f..48e7bf14 100644 --- a/restoration/source410/BT/HEAT.HPP +++ b/restoration/source410/BT/HEAT.HPP @@ -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; } diff --git a/restoration/source410/BT/HEAT.NOTES.md b/restoration/source410/BT/HEAT.NOTES.md index 74aaf105..7d2480b5 100644 --- a/restoration/source410/BT/HEAT.NOTES.md +++ b/restoration/source410/BT/HEAT.NOTES.md @@ -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). diff --git a/restoration/source410/BT/MECHWEAP.HPP b/restoration/source410/BT/MECHWEAP.HPP index 7a447132..dab40cc2 100644 --- a/restoration/source410/BT/MECHWEAP.HPP +++ b/restoration/source410/BT/MECHWEAP.HPP @@ -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 diff --git a/restoration/source410/BT/POWERSUB.CPP b/restoration/source410/BT/POWERSUB.CPP index db8de654..ad3be554 100644 --- a/restoration/source410/BT/POWERSUB.CPP +++ b/restoration/source410/BT/POWERSUB.CPP @@ -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 diff --git a/restoration/source410/BT/POWERSUB.HPP b/restoration/source410/BT/POWERSUB.HPP index 6443991e..1fb1dd13 100644 --- a/restoration/source410/BT/POWERSUB.HPP +++ b/restoration/source410/BT/POWERSUB.HPP @@ -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). //