BT410 Phase 5.3.12: experience gates + coolant system -- novice/expert modes REAL

The player-experience system now gates the entire heat/jam economy, verified in
BOTH directions with a one-token egg edit (TESTNOV.EGG, experience=novice):
NOVICE = 212 fire cycles all pinned at T=77, zero jams, zero shutdowns (the
heat model authentically absent); EXPERT = the full economy (duty cycles, 119
shutdowns + 2 authentic jams under forced fire). Zero Fail in both.

- BTPLAYER: the flag block renamed to its TRUE semantics (simLive @0x25c,
  heatModelOn @0x260 -- the FUN_004ad7d4 master switch, advancedDamageOn pair,
  levelFlag26c/270, experienceLevel); the ctor rows were already
  binary-accurate (nov 0000 / std 1011 / vet 1111 / exp 1101); accessors +
  [exp] sentinel.
- HeatSink::HeatModelActive() + ProjectileWeapon::LiveFireEnabled(): owner
  mech -> Entity::GetPlayerLink() -> the BTPlayer flags, NULL-permissive.
  Gates: both HeatSinkSimulation phases, every weapon fire-heat dump, and
  CheckForJam is now the AUTHENTIC form (LiveFireEnabled + heatLoad<=0
  early-outs + the minJamChance floor; the interim heat-degraded gate retired).

- THE LOAD-BEARING FIX: Mech ctor SetValidFlag(). Every 1995 entity ctor tail
  marks itself valid; ours didn't -- Entity::Dispatch routes messages to an
  INVALID entity into the deferred event queue, so the PlayerLink bind (and
  every directly-dispatched mech message) silently never landed and the gates
  read a NULL player forever.

- THE COOLANT SYSTEM (authentic bodies, byte-verified constants: HeatLoadScale
  0.002 -> heatLoad now in [0,1]; equalize eps 1e-4; draw floor/ON
  0.0025/0.003): UpdateCoolant (damage-scaled draw -- an undamaged mech leaks
  nothing), BalanceCoolant (full clamp chain from ConductHeat), DrawCoolant
  base=0 with the RESERVOIR override as THE SOURCE; Reservoir reconstructed
  (capacity overlays thermalCapacity, CoolantSimulation + the full
  InjectCoolant flush distribution, BT_FORCE_FLUSH dev hook); Condenser
  RefrigerationSimulation (massScale = (1-damage)*refrigerationFactor >= 1 --
  the heat pump that chills the bank; valveState inits 1; digit-suffix number
  fix). Deferred: AggregateHeatSink family, cockpit-button handlers
  (MoveValve/ToggleCooling/InjectCoolant), TrackSeekVoltage charge model.

Research driven by a 4-agent workflow dossier over the BT411 RE (verbatim
bodies for every function above + the egg experience plumbing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-22 22:15:07 -05:00
co-authored by Claude Fable 5
parent b1cbbe1c9b
commit e1e5a9a6db
14 changed files with 934 additions and 106 deletions
+39 -23
View File
@@ -155,41 +155,51 @@ BTPlayer::BTPlayer(
else
{
//
// Master: derive the game-mode flags from the mission experience
// level (novice / standard / veteran / expert) and advanced-damage
// technician flag. See BTPLAYER.NOTES.md for the flag mapping.
// Master: derive the game-mode flags from the mission experience level
// (the egg's per-pilot "experience" token). The binary rows for
// (simLive, heatModelOn, levelFlag26c, levelFlag270):
// nov 0,0,0,0 / std 1,0,1,1 / vet 1,1,1,1 / exp 1,1,0,1.
// Standard mode is LIVE but has no heat model -- authentic.
//
Check(bt_mission);
switch (bt_mission->ExperienceLevel())
experienceLevel = bt_mission->ExperienceLevel();
switch (experienceLevel)
{
case BTMission::NoviceMode:
showDamageReceived = 0;
showKills = 0;
roleReturnDelay2 = 0;
showScore = 0;
simLive = 0;
heatModelOn = 0;
levelFlag26c = 0;
levelFlag270 = 0;
break;
case BTMission::StandardMode:
showDamageReceived = 1;
showKills = 0;
roleReturnDelay2 = 1;
showScore = 1;
simLive = 1;
heatModelOn = 0;
levelFlag26c = 1;
levelFlag270 = 1;
break;
case BTMission::VeteranMode:
showDamageReceived = 1;
showKills = 1;
roleReturnDelay2 = 1;
showScore = 1;
simLive = 1;
heatModelOn = 1;
levelFlag26c = 1;
levelFlag270 = 1;
break;
case BTMission::ExpertMode:
showDamageReceived = 1;
showKills = 1;
roleReturnDelay2 = 0;
showScore = 1;
simLive = 1;
heatModelOn = 1;
levelFlag26c = 0;
levelFlag270 = 1;
break;
}
showDamageInflicted = bt_mission->AdvancedDamageOn();
roleReturnDelay = bt_mission->AdvancedDamageOn();
roleClassIndex = bt_mission->ExperienceLevel();
advancedDamageOn = bt_mission->AdvancedDamageOn();
advancedDamageOn2 = advancedDamageOn;
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[exp] master player experience=" << experienceLevel
<< " simLive=" << (int)simLive
<< " heatModelOn=" << (int)heatModelOn
<< " advDamage=" << (int)advancedDamageOn << endl << flush;
}
}
killCount = 0;
@@ -403,6 +413,12 @@ void
playerVehicle->Dispatch(&player_link_message);
playerVehicle->DispatchToReplicants(&player_link_message);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[link] InitializePlayerLink -> vehicle playerLink="
<< (void *)playerVehicle->GetPlayerLink() << endl << flush;
}
Check_Fpu();
}
+34 -12
View File
@@ -173,24 +173,46 @@
suppressConsole;
//
// Game-mode flags derived in the constructor from the mission's
// experience level (novice / standard / veteran / expert) and
// advanced-damage technician flag. See BTPLAYER.NOTES.md.
// EXPERIENCE-LEVEL game-mode flags, derived in the constructor from the
// mission's experience level (novice / standard / veteran / expert) and
// advanced-damage technician flag. TRUE SEMANTICS (the early "display
// toggles" reading of this block was wrong -- see BTPLAYER.NOTES.md):
// binary rows for (simLive, heatModelOn, levelFlag26c, levelFlag270) =
// nov 0,0,0,0 / std 1,0,1,1 / vet 1,1,1,1 / exp 1,1,0,1.
// simLive -- the novice lockout: jams, searchlight, powersub
// short events (CheckForJam's LiveFireEnabled gate);
// heatModelOn -- THE heat-model master switch (HeatModelActive):
// weapon-fire heat, the heat sim, coolant, movement
// heat. Standard mode has NO heat consequences --
// authentic, not a gap;
// advancedDamageOn/2 -- the egg's technician flag (an int pair in the
// binary; NOT part of the level switch);
// experienceLevel -- the raw 0..3 (the ==0 novice-lockout predicate).
//
Logical
showDamageReceived;
simLive;
Logical
showKills;
heatModelOn;
Logical
showDamageInflicted;
advancedDamageOn;
Logical
showScore;
Scalar
roleReturnDelay;
Scalar
roleReturnDelay2;
advancedDamageOn2;
Logical
levelFlag26c;
Logical
levelFlag270;
int
roleClassIndex;
experienceLevel;
public:
Logical
IsSimLive() const { Check(this); return simLive; }
Logical
IsHeatModelOn() const { Check(this); return heatModelOn; }
Logical
IsAdvancedDamageOn() const { Check(this); return advancedDamageOn; }
protected:
int
killCount;
@@ -147,3 +147,17 @@ marker), zero Fail. The mech BODY is still `DoNothingOnce` (its real
3. Score / ScoreInflicted / ScoreUpdate handlers (scoring wave); the console
SCORE-delta flush inside PlayerSimulation.
4. DropZoneReply respawn tail (`Mech::Reset` in-place heal/move).
## Experience flags renamed to TRUE semantics (2026-07-22, Phase 5.3.12)
The `show*` / `roleReturn*` block was the old "per-role display toggles"
misread (the memory note flagged it). Renamed + retyped to the binary truth:
`simLive` @0x25c (novice lockout: jams/lights/powersub short events),
`heatModelOn` @0x260 (THE heat-model master switch, FUN_004ad7d4),
`advancedDamageOn`/`advancedDamageOn2` (the egg technician flag pair),
`levelFlag26c` (0/1/1/0, consumer unlocated), `levelFlag270` (0/1/1/1),
`experienceLevel` (raw 0..3). The ctor rows were already binary-accurate.
Public accessors IsSimLive()/IsHeatModelOn()/IsAdvancedDamageOn(); a gated
`[exp]` sentinel prints the derived flags per master player. Consumers:
HeatSink::HeatModelActive() (HEAT.CPP), ProjectileWeapon::LiveFireEnabled()
(PROJWEAP.CPP) -- both walk mech->GetPlayerLink(), NULL-permissive.
+12 -8
View File
@@ -127,15 +127,19 @@ void
ComputeOutputVoltage();
//
// Dump the firing heat into our own thermal accumulator; 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 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 with the
// electrical-charge wave.)
// 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.)
//
AddPendingHeat(heatCostToFire * 10000000.0f);
if (HeatModelActive())
{
AddPendingHeat(heatCostToFire * 10000000.0f);
}
if (getenv("BT_MECH_LOG"))
{
+270 -26
View File
@@ -19,8 +19,26 @@
# include <mech.hpp>
#endif
#if !defined(BTPLAYER_HPP)
# include <btplayer.hpp>
#endif
#include <math.h>
//
//#############################################################################
// Tuning constants (byte-verified against the shipped image in the BT411 RE:
// .data / literal-pool reads).
//#############################################################################
//
static const Scalar HeatLoadScale = 0.002f; // heat-load normalising scale -> band [0,1]
static const Scalar HeatLoadMinimum = 0.0f;
static const Scalar HeatLoadMaximum = 1.0f;
static const Scalar HeatEqualizeEpsilon = 1.0e-4f; // BalanceCoolant no-op band
static const Scalar CoolantDrawGate = 1.0e-4f; // DrawCoolant |amount| gate
static const Scalar CoolantDrawFloor = 0.0025f; // draw zero-floor + ACTIVE-off hysteresis
static const Scalar CoolantActiveOn = 0.003f; // coolantActive ON threshold
//
//#############################################################################
// Shared data support -- reuses the base Subsystem sets (no boot-critical
@@ -286,17 +304,39 @@ Logical
//
//#############################################################################
// HeatSinkSimulation -- the per-frame thermal step (binary @004ad924).
// Absorb the pending heat into the thermal mass, recompute the temperature and
// the smoothed heat-load reading, conduct into the linked sink, then drive the
// degradation / failure alarm from the authored thresholds.
// HeatModelActive -- the heat-model master switch (binary FUN_004ad7d4):
// owner mech -> Entity::playerLink -> BTPlayer::heatModelOn, ON only for
// veteran / expert experience. Standard mode has NO heat consequences --
// authentic, not a gap. A NULL player link (unlinked dev mech / target dummy)
// reads ON, matching the permissive dev-rig behavior; the binary derefs
// unguarded (a linked player always exists in pod missions).
//#############################################################################
//
// PARTIAL: the heat model runs unconditionally -- the authentic gate is the
// player experience level (HeatModelActive: novice mode disables the heat
// model / jams; joins with the player-link accessor wave). UpdateCoolant
// (coolant depletion / venting) is deferred with the coolant wave; the
// coolant level stays at capacity, which holds the conduction term at its
// full-coolant value.
Logical
HeatSink::HeatModelActive()
{
Check(this);
if (owner == NULL)
{
return True;
}
BTPlayer *player = Cast_Object(BTPlayer *, owner->GetPlayerLink());
if (player == NULL)
{
return True;
}
return player->IsHeatModelOn();
}
//
//#############################################################################
// HeatSinkSimulation -- the per-frame thermal step (binary @004ad924).
// Under the heat-model experience gate: absorb the pending heat into the
// thermal mass, recompute the temperature and the smoothed heat-load reading,
// conduct into the linked sink, and run the coolant draw. The degradation /
// failure alarm drive is OUTSIDE the gate (authentic -- with the model off the
// temperature never moves, so the alarm just holds Normal).
//#############################################################################
//
void
@@ -304,11 +344,19 @@ void
{
Check(this);
heatEnergy += pendingHeat;
currentTemperature = heatEnergy / thermalMass;
UpdateHeatLoad();
pendingHeat = 0.0f;
ConductHeat(time_slice);
if (HeatModelActive())
{
heatEnergy += pendingHeat;
currentTemperature = heatEnergy / thermalMass;
UpdateHeatLoad();
pendingHeat = 0.0f;
ConductHeat(time_slice);
}
if (HeatModelActive())
{
UpdateCoolant(time_slice);
}
//
// Drive the degradation / failure alarm.
@@ -326,15 +374,32 @@ void
heatAlarm.SetLevel(NormalHeat);
}
//
// BT_HEAT_LOG: a 5-second per-sink census (temperature / coolant / load).
//
if (getenv("BT_HEAT_LOG"))
{
static Scalar censusAccum = 0.0f;
censusAccum += time_slice;
if (censusAccum >= 5.0f)
{
censusAccum = 0.0f;
DEBUG_STREAM << "[heat-t] " << GetName()
<< " T=" << currentTemperature
<< " cool=" << coolantLevel << "/" << thermalCapacity
<< " load=" << heatLoad << endl << flush;
}
}
Check_Fpu();
}
//
//#############################################################################
// UpdateHeatLoad -- recompute the radiated heat and feed it through the
// 15-sample running-average filter to produce the smoothed heatLoad reading
// (binary @004ad7f0; the HeatLoadScale / min / max shaping constants join with
// the gauge-calibration wave).
// UpdateHeatLoad -- recompute the radiated heat and feed the normalised sample
// through the 15-sample running-average filter to produce the smoothed
// heatLoad reading in the [0,1] band (binary @004ad7f0; the shaping constants
// are byte-verified from the image).
//#############################################################################
//
void
@@ -343,14 +408,25 @@ void
Check(this);
radiatedHeat = currentTemperature * coolantLevel;
heatFilter.Add(radiatedHeat);
Scalar sample = HeatLoadScale * radiatedHeat;
if (sample < HeatLoadMinimum)
{
sample = HeatLoadMinimum;
}
else if (sample > HeatLoadMaximum)
{
sample = HeatLoadMaximum;
}
heatFilter.Add(sample);
heatLoad = heatFilter.CalculateAverage();
}
//
//#############################################################################
// ConductHeat -- conduct heat into the linked sink (binary @004ad8ac). The
// coolant rebalance (BalanceCoolant) is deferred with the coolant wave.
// ConductHeat -- conduct heat into the linked sink, then rebalance coolant so
// the hotter side sheds load (binary @004ad8ac).
//#############################################################################
//
void
@@ -364,6 +440,116 @@ void
Scalar flow = ComputeHeatFlow(other, time_slice);
other->pendingHeat += flow;
pendingHeat -= flow;
BalanceCoolant(time_slice);
}
}
//
//#############################################################################
// BalanceCoolant -- move coolant between this sink and its linked sink so that
// the hotter side sheds load (binary @004ada94). Clamped on both ends so
// neither sink goes below empty or above its capacity.
//#############################################################################
//
void
HeatSink::BalanceCoolant(Scalar time_slice)
{
Check(this);
HeatSink *other = (HeatSink *)linkedSinks.Resolve();
if (other == NULL)
{
return;
}
Scalar spread = radiatedHeat - other->radiatedHeat;
if (spread < 0.0f)
{
spread = -spread;
}
if (spread <= HeatEqualizeEpsilon)
{
return;
}
Scalar delta = other->radiatedHeat / currentTemperature - coolantLevel;
//
// Clamp delta to +/- (thermalCapacity * dt).
//
Scalar limit = thermalCapacity * time_slice;
if (delta < -limit)
{
delta = -limit;
}
else if (delta > limit)
{
delta = limit;
}
//
// Clamp so this sink stays within [0, thermalCapacity].
//
Scalar hi = thermalCapacity - coolantLevel;
Scalar lo = -coolantLevel;
if (delta < lo) delta = lo;
else if (delta > hi) delta = hi;
//
// Clamp so the other sink stays within its own [0, thermalCapacity].
//
Scalar otherLo = -(other->thermalCapacity - other->coolantLevel);
if (delta < otherLo) delta = otherLo;
else if (delta > other->coolantLevel) delta = other->coolantLevel;
delta = coolantFlowScale * delta;
coolantLevel += delta;
other->coolantLevel -= delta;
}
//
//#############################################################################
// UpdateCoolant -- consume coolant proportional to the current load, request a
// top-up from the central cooling system, and update the draw state machine
// (binary @004adbf8). The draw scales with THIS subsystem's own structural
// damage: an undamaged subsystem leaks nothing (the coolant bars stay full on
// a pristine mech); the draw rises only as the sink itself takes battle
// damage.
//#############################################################################
//
void
HeatSink::UpdateCoolant(Scalar time_slice)
{
Check(this);
coolantDraw = GetSubsystemDamageLevel() * heatLoad;
if (coolantDraw < CoolantDrawFloor)
{
coolantDraw = 0.0f;
}
Scalar amount = coolantDraw * time_slice;
if (coolantLevel < amount)
{
amount = coolantLevel;
}
coolantLevel -= amount;
if (amount > CoolantDrawGate || amount < -CoolantDrawGate)
{
coolantLevel += DrawCoolant(amount);
}
//
// The draw state machine (hysteresis: ON above 0.003, OFF below 0.0025).
//
if (coolantActive == 0 && coolantDraw > CoolantActiveOn)
{
coolantActive = 1;
}
else if (coolantActive == 1 && coolantDraw < CoolantDrawFloor)
{
coolantActive = 0;
}
}
@@ -403,10 +589,17 @@ Scalar
return equilibrium * tau * response;
}
//
//#############################################################################
// DrawCoolant -- ask the central cooling system for coolant and return how
// much was actually supplied. Base sinks supply nothing on their own; the
// Reservoir (the coolant store) overrides this as the source.
//#############################################################################
//
Scalar
HeatSink::DrawCoolant(Scalar)
{
Fail("HeatSink::DrawCoolant -- heat.cpp not yet reconstructed");
Check(this);
return 0.0f;
}
@@ -519,14 +712,41 @@ Condenser::Condenser(
Check(owner);
Check_Pointer(subsystem_resource);
valveState = 0;
//
// The authentic ctor (binary @4ae568): the valve opens at 1, the
// refrigeration output rides the inherited massScale slot, and the
// condenser's own coolantFlowScale streams 0 (the valve recompute --
// BTRecomputeCondenserValves, the cockpit MoveValve wave -- assigns each
// condenser its share of the total valve opening). INTERIM: flow scale is
// left at the inherited 1.0 until the valve-recompute wave lands -- a zero
// flow scale would zero the condenser->bank conduction exponent and strand
// the heat in the condensers with no way to reach the central sink.
//
valveState = 1;
refrigerationFactor = subsystem_resource->refrigerationFactor;
massScale = refrigerationFactor;
//
// Condenser number from the segment-name suffix ('A' -> 1 ...).
// Condenser number from the segment-name DIGIT suffix ("Condenser1" -> 1;
// the letter form -0x40 is the GENERATOR convention).
//
const char *name = GetName();
condenserNumber = name[strlen(name) - 1] - 0x40;
condenserNumber = name[strlen(name) - 1] - '0';
if (getenv("BT_POWER_LOG"))
{
DEBUG_STREAM << "[cond] '" << GetName()
<< "' refrigFactor=" << refrigerationFactor
<< " #" << condenserNumber << endl << flush;
}
//
// Install the per-frame refrigeration Performance.
//
if (owner->GetInstance() != Entity::ReplicantInstance)
{
SetPerformance(&Condenser::RefrigerationSimulation);
}
Check_Fpu();
}
@@ -546,3 +766,27 @@ Logical
{
return IsDerivedFrom(ClassDerivations);
}
//
//#############################################################################
// RefrigerationSimulation -- the condenser's per-frame step (binary @4ae4d8):
// recompute the refrigeration output as (1 - own structural damage) *
// refrigerationFactor, clamped >= 1, into the inherited massScale slot -- an
// undamaged condenser behaves refrigerationFactor-times "hotter" in the
// conduction exchange, actively pumping heat toward the central bank and
// chilling itself below ambient -- then run the base HeatSink step.
//#############################################################################
//
void
Condenser::RefrigerationSimulation(Scalar time_slice)
{
Check(this);
massScale = (1.0f - GetSubsystemDamageLevel()) * refrigerationFactor;
if (massScale < 1.0f)
{
massScale = 1.0f;
}
HeatSink::HeatSinkSimulation(time_slice);
}
+33
View File
@@ -200,6 +200,7 @@
public HeatableSubsystem
{
friend class Condenser;
friend class Reservoir;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Shared Data Support
//
@@ -251,6 +252,14 @@
activePerformance = (Simulation::Performance)performance;
}
//
// The heat-model master switch (binary FUN_004ad7d4): the owning
// BTPlayer's heatModelOn experience flag, reached through the mech's
// playerLink. ON only for veteran / expert; a NULL player reads ON.
//
Logical
HeatModelActive();
void
HeatSinkSimulation(Scalar time_slice);
void
@@ -259,6 +268,10 @@
ConductHeat(Scalar time_slice);
Scalar
ComputeHeatFlow(HeatSink *other, Scalar time_slice);
void
UpdateCoolant(Scalar time_slice);
void
BalanceCoolant(Scalar time_slice);
virtual Scalar
DrawCoolant(Scalar requested);
@@ -340,6 +353,26 @@
);
~Condenser();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Per-frame refrigeration (binary @4ae4d8): the condenser actively pumps
// heat toward the central bank -- massScale is recomputed each frame as
// (1 - own damage) * refrigerationFactor, clamped >= 1, so an undamaged
// condenser behaves refrigerationFactor-times "hotter" in the conduction
// exchange and chills itself below ambient (which is what cools the
// weapons equalizing against it). Damage degrades it toward a plain sink.
//
public:
typedef void
(Condenser::*Performance)(Scalar time_slice);
void
SetPerformance(Performance performance)
{
Check(this);
activePerformance = (Simulation::Performance)performance;
}
void
RefrigerationSimulation(Scalar time_slice);
protected:
int valveState;
int condenserNumber;
+66 -6
View File
@@ -100,11 +100,71 @@ The weapon-side consequences are wired and VERIFIED under sustained fire:
thermal duty cycle (fire at ~1930° → spike 2562° → shutdown → cool → refire);
the lasers oscillate around ~2200°.
## The experience gates + the coolant wave (Phase 5.3.12, 2026-07-22)
**The player-experience gates are LIVE** (the workflow dossier recovered every
body from the BT411 RE):
- `HeatSink::HeatModelActive()` (binary FUN_004ad7d4): owner mech →
`Entity::GetPlayerLink()` → `BTPlayer::heatModelOn` — ON only for veteran /
expert. Gates BOTH phases of HeatSinkSimulation (absorb/conduct AND
UpdateCoolant) and every weapon fire-heat dump. Standard mode has NO heat
consequences — authentic. NULL player link reads ON (dev-permissive).
- `ProjectileWeapon::LiveFireEnabled()`: playerLink → `simLive` — 0 only for
novice, whose ballistics never jam. `CheckForJam` is now the AUTHENTIC form:
LiveFireEnabled early-out + the `heatLoad <= 0` early-out (with the model off
heatLoad stays 0, so standard mode never jams either) + the minJamChance
floor (a veteran+ launcher carries its authored ~5% cold jam chance — the
interim heat-degraded gate is retired).
- BTPlayer's flag block renamed to its TRUE semantics (simLive @0x25c,
heatModelOn @0x260, advancedDamageOn/2, levelFlag26c/270, experienceLevel) —
the ctor rows were already binary-accurate; only the names/types were the
old "display toggles" misread.
**THE LOAD-BEARING FIX found on the way: `Mech` ctor `SetValidFlag()`.**
Every 1995 entity ctor tail marks itself valid; ours didn't — and
`Entity::Dispatch` routes messages to an INVALID entity into the deferred
event queue, so the PlayerLink bind (and every directly-dispatched mech
message) silently never landed. The experience gates read a NULL player
forever (permissive ON) until this one authentic line was restored.
**The coolant system** (all bodies authentic from the dossier, constants
byte-verified: HeatLoadScale 0.002 → heatLoad now lives in the [0,1] band,
HeatEqualizeEpsilon 1e-4, draw floor 0.0025 / ON 0.003, draw gate 1e-4):
- `UpdateCoolant` (@004adbf8): coolantDraw = own structural damage × heatLoad
(an UNDAMAGED mech leaks nothing — the bars stay full), drain, top-up via
the virtual `DrawCoolant`, and the active-flag hysteresis.
- `BalanceCoolant` (@004ada94): full clamp chain, called from ConductHeat.
- `DrawCoolant` base = 0; **Reservoir overrides it as THE SOURCE** (@4af3b0).
- **Reservoir reconstructed** (@4af408): capacity overlays thermalCapacity
(streamed 20), squirtMass 0.5, `CoolantSimulation` + `InjectCoolant`
(@4aefa4, the full flush distribution: condensers → weapons → heatables →
master with intentional duplicate weighting, squirts only leave the tank,
the chill only cools). `BT_FORCE_FLUSH=1` dev hook (the cockpit
InjectCoolant button joins the message wave). Deferred: the
AggregateHeatSink master-bank attach + the 0.05×count capacity rescale.
- **Condenser refrigeration** (@4ae4d8): valveState inits 1, massScale =
(1 own damage) × refrigerationFactor (streamed 3.0) clamped ≥ 1 each
frame — the condenser pumps heat as if 3× hotter, chilling itself below
ambient; that chill is what cools the weapons. INTERIM: coolantFlowScale
stays 1.0 (authentic streams 0 + the MoveValve/BTRecomputeCondenserValves
share assignment — the cockpit-button wave; zeroing it now would strand
condenser heat).
**VERIFIED headlessly, both directions of the experience lever** (TESTNOV.EGG
= TEST.EGG with `experience=novice`):
- NOVICE: 212 fire cycles ALL at T=77, zero jams, zero shutdowns, coolant
static — the heat model authentically absent.
- EXPERT: playerLink resolves, the full economy runs (PPC duty cycle now
sawing ~650-1200° — the refrigeration pump drains harder than before — 119
shutdowns + 2 authentic jams under forced sustained fire). Zero Fail in
both.
## Still deferred
UpdateCoolant / BalanceCoolant (coolant depletion + venting), the
HeatModelOff / LiveFireEnabled experience gates (novice mode), the electrical
charge model (TrackSeekVoltage / voltage sag / I²R), the mech-disabled halves
of the weapon gates (damage wave), the central sink's forward-linked drain,
Condenser MoveValve handling, jam recovery via ResetToInitialState (mission
reset path).
The electrical charge model (TrackSeekVoltage / voltage sag / I²R), the
mech-disabled halves of the weapon gates (damage wave), the AggregateHeatSink
family (the central bank's count/radiator loop + reservoir attach + capacity
rescale + its forward-linked drain), the MoveValve / ToggleCooling /
InjectCoolant cockpit-button message handlers + BTRecomputeCondenserValves,
Myomers movement heat, jam recovery via ResetToInitialState (mission reset).
+10
View File
@@ -462,6 +462,16 @@ Mech::Mech(
//
SetPerformance(&Mech::Simulate);
//
// The entity is complete -- mark it VALID, like every 1995 entity ctor tail
// (CamShip / DoorFrame / DropZone / ...). LOAD-BEARING: Entity::Dispatch
// routes messages to an INVALID entity into the deferred event queue, so
// without this the mech never receives a directly-dispatched message --
// the PlayerLink bind (and with it every player-experience gate) silently
// never lands.
//
SetValidFlag();
Check_Fpu();
}
+10
View File
@@ -355,3 +355,13 @@ invalidates every bucket; newest BT/BT_L4 header mtime additionally invalidates
the game buckets) so header edits force the dependent recompiles. CODE/ is
immutable and needs no stamp. When in doubt: purge `build410/obj/*` for a
clean baseline.
## PHASE 5.3.12: the missing SetValidFlag (2026-07-22)
The Mech ctor tail now calls `SetValidFlag()` -- every 1995 entity ctor does
(CamShip/DoorFrame/DropZone/...), and ours didn't. LOAD-BEARING:
`Entity::Dispatch` routes messages to an INVALID entity into the deferred
event queue, so the mech never received a directly-dispatched message -- the
PlayerLink bind silently never landed and every player-experience gate read a
NULL player (dev-permissive heat-always-on) until this line was restored.
Found while verifying the novice-mode heat lockout end-to-end.
+7 -3
View File
@@ -104,9 +104,13 @@ void
//
// Ballistic heatCostToFire is stored 1e7-unit-NATIVE in the stream (SRM6
// reads 5.06e7 = +641K on its own sink -- the same design magnitude as the
// PPC's +632K); dump it raw. (The EMITTER's authored value is small and
// its 1e7 comes from the energy algebra -- see EMITTER.CPP.)
AddPendingHeat(heatCostToFire);
// PPC's +632K); dump it raw, under the heat-model experience gate (novice /
// standard fire generates no heat -- authentic). (The EMITTER's authored
// value is small and its 1e7 comes from the energy algebra.)
if (HeatModelActive())
{
AddPendingHeat(heatCostToFire);
}
if (getenv("BT_MECH_LOG"))
{
+49 -15
View File
@@ -27,6 +27,10 @@
# include <random.hpp>
#endif
#if !defined(BTPLAYER_HPP)
# include <btplayer.hpp>
#endif
Derivation
ProjectileWeapon::ClassDerivations(
MechWeapon::ClassDerivations,
@@ -150,9 +154,14 @@ void
// Ballistic heatCostToFire is stored 1e7-unit-NATIVE in the stream (SRM6
// reads 5.06e7 = +641K on its own sink -- the same design magnitude as the
// PPC's +632K); dump it raw. (The EMITTER's authored value is small and
// its 1e7 comes from the energy algebra -- see EMITTER.CPP.)
AddPendingHeat(heatCostToFire);
// PPC's +632K); dump it raw, under the heat-model experience gate (novice /
// standard fire generates no heat -- authentic). (The EMITTER's authored
// value is small and its 1e7 comes from the energy algebra -- see
// EMITTER.CPP.)
if (HeatModelActive())
{
AddPendingHeat(heatCostToFire);
}
if (getenv("BT_MECH_LOG"))
{
@@ -165,18 +174,39 @@ void
//
//#############################################################################
// CheckForJam -- the heat-scaled jam roll (binary @4bbfcc):
// p = 0.41 * currentTemperature / failureTemperature,
// clamped to [minJamChance, 1.0], rolled against a uniform random.
// A jammed launcher clears only on ResetToInitialState (mission reset).
// LiveFireEnabled -- the "sim live" novice lockout: owner mech ->
// Entity::playerLink -> BTPlayer::simLive, 0 only for NOVICE experience. A
// NULL player link reads LIVE (the unlinked dev mech / target dummy).
//#############################################################################
//
// TWO interim gates stand in for deferred systems (the BT411 port hit exactly
// this): the authentic leading gate is LiveFireEnabled() -- the player
// experience switch (novice mode = no jams), which needs the player-link
// accessor wave -- and the binary's own `heatLoad <= 0` early-out is trivially
// true once the heat model runs. Until LiveFireEnabled lands, gate the roll
// on the sink actually being heat-degraded, so cold weapons fire reliably and
// jams appear exactly when the heat economy says the weapon is cooking.
Logical
ProjectileWeapon::LiveFireEnabled()
{
Check(this);
if (owner == NULL)
{
return True;
}
BTPlayer *player = Cast_Object(BTPlayer *, owner->GetPlayerLink());
if (player == NULL)
{
return True;
}
return player->IsSimLive();
}
//
//#############################################################################
// CheckForJam -- the heat-scaled jam roll (binary @4bbfcc), AUTHENTIC form:
// novice never jams (LiveFireEnabled); a cold heat model never jams (heatLoad
// stays 0 with the model off -- UpdateHeatLoad only runs under the experience
// gate); otherwise
// p = 0.41 * currentTemperature / failureTemperature,
// clamped to [minJamChance, 1.0], rolled against the uniform random. Note the
// floor: even a cool launcher at veteran+ carries the authored minimum jam
// chance per shot (SRM6: 5%) -- authentic pod behavior. A jammed launcher
// clears only on ResetToInitialState (mission reset).
//#############################################################################
//
Logical
@@ -184,7 +214,11 @@ Logical
{
Check(this);
if (GetHeatState() < DegradationHeat) // interim LiveFireEnabled stand-in
if (!LiveFireEnabled())
{
return False;
}
if (heatLoad <= 0.0f)
{
return False;
}
+7
View File
@@ -80,6 +80,13 @@
Logical
CheckForJam();
//
// The "sim live" novice lockout (the owning BTPlayer's simLive
// experience flag): novice ballistics never jam.
//
Logical
LiveFireEnabled();
public:
typedef ProjectileWeapon__SubsystemResource SubsystemResource;
+293 -8
View File
@@ -1,16 +1,301 @@
//===========================================================================//
// File: reservr.cpp //
// Project: BattleTech Brick: Mech subsystems //
// Contents: Reservoir -- the coolant store //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. //
// All Rights reserved worldwide //
// This unpublished sourcecode is PROPRIETARY and CONFIDENTIAL //
//===========================================================================//
#include <bt.hpp>
#pragma hdrstop
#if !defined(RESERVR_HPP)
# include <reservr.hpp>
#endif
#if !defined(MECH_HPP)
# include <mech.hpp>
#endif
Derivation Reservoir::ClassDerivations(HeatSink::ClassDerivations, "Reservoir");
Reservoir::SharedData Reservoir::DefaultData(Reservoir::ClassDerivations, Subsystem::MessageHandlers, Subsystem::AttributeIndex, Subsystem::StateCount);
Reservoir::Reservoir(Mech *owner, int subsystem_ID, SubsystemResource *r, SharedData &shared_data)
: HeatSink(owner, subsystem_ID, r, shared_data)
{ Check(owner); Check_Pointer(r); Check_Fpu(); }
Reservoir::~Reservoir() {}
Logical Reservoir::TestClass(Mech &) { return True; }
Logical Reservoir::TestInstance() const { return IsDerivedFrom(ClassDerivations); }
#if !defined(MECHWEAP_HPP)
# include <mechweap.hpp>
#endif
#include <math.h>
Derivation
Reservoir::ClassDerivations(
HeatSink::ClassDerivations,
"Reservoir"
);
Reservoir::SharedData
Reservoir::DefaultData(
Reservoir::ClassDerivations,
Subsystem::MessageHandlers,
Subsystem::AttributeIndex,
Subsystem::StateCount
);
//
//#############################################################################
// The coolant store (binary ctor @4af408): CoolantCapacity overlays the
// inherited HeatSink thermalCapacity slot; the charge starts full; the flush
// flow scale is zero (a reservoir never conducts like an ordinary sink). A
// master reservoir registers the CoolantSimulation performance.
//
// Deferred with the AggregateHeatSink wave: the authentic master path also
// attaches the reservoir into the central bank's radiator loop and rescales
// the capacity by 0.05 x the bank's heat-sink count (the central sink here is
// still a plain HeatSink, which has no count) -- until then the streamed
// capacity is used as-is (a larger tank than authentic; noted).
//#############################################################################
//
Reservoir::Reservoir(
Mech *owner,
int subsystem_ID,
SubsystemResource *r,
SharedData &shared_data
):
HeatSink(owner, subsystem_ID, r, shared_data)
{
Check(owner);
Check_Pointer(r);
reservoirAlarm.Initialize(2);
squirtEfficiency = 0.5f;
thermalCapacity = r->coolantCapacity;
coolantSquirtMass = r->coolantSquirtMass;
coolantLevel = thermalCapacity;
coolantFlowScale = 0.0f;
injectAccumulator = 0.0f;
reservoirAlarm.SetLevel(0);
if (getenv("BT_POWER_LOG"))
{
DEBUG_STREAM << "[resv] '" << GetName()
<< "' capacity=" << thermalCapacity
<< " squirtMass=" << coolantSquirtMass << endl << flush;
}
if (owner->GetInstance() != Entity::ReplicantInstance)
{
SetPerformance(&Reservoir::CoolantSimulation);
}
Check_Fpu();
}
Reservoir::~Reservoir()
{
}
Logical
Reservoir::TestClass(Mech &)
{
return True;
}
Logical
Reservoir::TestInstance() const
{
return IsDerivedFrom(ClassDerivations);
}
//
//#############################################################################
// DrawCoolant -- the SOURCE (binary @4af3b0): hand out up to coolantLevel of
// the requested amount and deduct it from the charge.
//#############################################################################
//
Scalar
Reservoir::DrawCoolant(Scalar requested)
{
Check(this);
Scalar supplied = 0.0f;
if (requested >= 0.0f)
{
supplied = requested;
if (coolantLevel < requested)
{
supplied = coolantLevel;
}
}
coolantLevel -= supplied;
return supplied;
}
//
//#############################################################################
// CoolantSimulation -- the registered Performance (binary @4aef78): while
// injection is active, accumulate elapsed time and run the coolant
// distribution. The InjectCoolant COCKPIT BUTTON (the id-4 message handler
// that raises the alarm) joins with the cockpit-button message wave; the DEV
// hook BT_FORCE_FLUSH=1 raises it here so the flush machinery is exercisable
// headlessly (one press ~4 s after the sim starts ticking).
//#############################################################################
//
void
Reservoir::CoolantSimulation(Scalar time_slice)
{
Check(this);
{
static int forceFlush = -1;
if (forceFlush < 0)
{
forceFlush = (getenv("BT_FORCE_FLUSH") != NULL) ? 1 : 0;
}
if (forceFlush == 1)
{
static Scalar armAccum = 0.0f;
armAccum += time_slice;
if (armAccum >= 4.0f && reservoirAlarm.GetLevel() != 1
&& coolantLevel > 0.0f)
{
forceFlush = 2;
reservoirAlarm.SetLevel(1);
injectAccumulator = 0.0f;
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[resv] FLUSH ON (charge="
<< coolantLevel << ")" << endl << flush;
}
}
}
}
if (reservoirAlarm.GetLevel() == 1)
{
injectAccumulator += time_slice;
InjectCoolant(time_slice);
}
}
//
//#############################################################################
// InjectCoolant -- the coolant-flush distribution (binary @4aefa4). Walk the
// roster gathering the flush targets in the authentic pass order -- the
// condensers, then the weapons, then every heat sink, then the linked master
// (the duplicate visits are intentional weighting) -- and squirt
// (coolantSquirtMass x the target's coolantFlowScale x dt) into each,
// crediting a negative pending-heat chill for the moved mass. Squirts only
// ever leave the tank; the chill only ever cools.
//#############################################################################
//
void
Reservoir::InjectCoolant(Scalar time_slice)
{
Check(this);
if (fabs(coolantLevel) <= 1.0e-4f) // the tank is empty
{
return;
}
enum { kMaxWork = 96 };
HeatSink *work[kMaxWork];
int workCount = 0;
Entity *own = (Entity *)owner;
int count = own->GetSubsystemCount();
int i;
for (i = 2; i < count && workCount < kMaxWork; ++i)
{
Subsystem *s = own->GetSubsystem(i);
if (s != NULL && s->IsDerivedFrom(Condenser::ClassDerivations))
{
work[workCount++] = (HeatSink *)s;
}
}
for (i = 2; i < count && workCount < kMaxWork; ++i)
{
Subsystem *s = own->GetSubsystem(i);
if (s != NULL && s->IsDerivedFrom(MechWeapon::ClassDerivations))
{
work[workCount++] = (HeatSink *)s;
}
}
for (i = 2; i < count && workCount < kMaxWork; ++i)
{
Subsystem *s = own->GetSubsystem(i);
if (s != NULL && s->IsDerivedFrom(HeatSink::ClassDerivations))
{
work[workCount++] = (HeatSink *)s;
}
}
{
HeatSink *link = (HeatSink *)linkedSinks.Resolve();
if (link != NULL && workCount < kMaxWork)
{
work[workCount++] = link;
}
}
for (int w = 0; w < workCount; ++w)
{
if (fabs(coolantLevel) <= 1.0e-4f) // ran dry mid-pass
{
return;
}
HeatSink *sink = work[w];
if (sink->coolantFlowScale == 0.0f)
{
continue;
}
//
// squirt = -(squirtMass x flowScale x dt), clamped to [-coolantLevel, 0].
//
Scalar move = -coolantSquirtMass
* sink->coolantFlowScale
* time_slice;
Scalar lo = -coolantLevel;
if (move < lo) move = lo;
if (move > 0.0f) move = 0.0f;
//
// The heat delta riding the moved mass (computed BEFORE the level
// updates, as in the binary).
//
Scalar den = (fabs(sink->coolantLevel) > 1.0e-4f)
? sink->coolantLevel
: sink->thermalCapacity;
Scalar moved = (move < 0.0f) ? -move : move;
Scalar fracSink = moved / den;
Scalar fracRes = moved / coolantLevel;
Scalar heatDelta =
sink->heatEnergy * fracSink
- heatEnergy * fracRes;
coolantLevel += move; // the reservoir drains (move <= 0)
sink->coolantLevel -= move; // the sink gains
if (sink->coolantLevel >= 0.0f)
{
if (sink->coolantLevel > sink->thermalCapacity)
{
sink->coolantLevel = sink->thermalCapacity;
}
}
else
{
sink->coolantLevel = 0.0f;
}
Scalar cap = sink->thermalMass * startingTemperature;
if (heatDelta > cap)
{
heatDelta = cap;
}
Scalar chill = -heatDelta;
if (chill > 0.0f)
{
chill = 0.0f; // the flush only ever COOLS
}
sink->pendingHeat += chill;
}
}
+90 -5
View File
@@ -1,19 +1,104 @@
//===========================================================================//
// File: reservr.hpp //
// Project: BattleTech Brick: Mech subsystems //
// Contents: Reservoir -- the coolant store //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. //
// All Rights reserved worldwide //
// This unpublished sourcecode is PROPRIETARY and CONFIDENTIAL //
//===========================================================================//
#if !defined(RESERVR_HPP)
# define RESERVR_HPP
# if !defined(HEAT_HPP)
# include <heat.hpp>
# endif
//##################### Forward Class Declarations #######################
class Mech;
struct Reservoir__SubsystemResource : public HeatSink::SubsystemResource {};
class Reservoir : public HeatSink
//###########################################################################
//#################### Reservoir Model Resource #########################
//###########################################################################
//
// CoolantCapacity overlays the inherited HeatSink thermalCapacity slot at
// construction; CoolantSquirtMass is the per-second flush rate base.
//
struct Reservoir__SubsystemResource:
public HeatSink::SubsystemResource
{
Scalar coolantCapacity;
Scalar coolantSquirtMass;
};
//###########################################################################
//############################# Reservoir ###############################
//###########################################################################
//
// The coolant store: a HeatSink that holds the mech's coolant charge and
// "squirts" it into the other sinks on demand -- it is the DrawCoolant SOURCE
// (every sink's UpdateCoolant top-up resolves here through the virtual), and
// the manual coolant FLUSH (the InjectCoolant cockpit button) distributes
// charge into the condensers / weapons / heatables, crediting each a negative
// pending-heat chill.
//
class Reservoir:
public HeatSink
{
public:
static Derivation ClassDerivations;
static SharedData DefaultData;
static Logical TestClass(Mech &);
Logical TestInstance() const;
static Logical
TestClass(Mech &);
Logical
TestInstance() const;
public:
typedef Reservoir__SubsystemResource SubsystemResource;
Reservoir(Mech *owner, int subsystem_ID, SubsystemResource *r, SharedData &shared_data = DefaultData);
Reservoir(
Mech *owner,
int subsystem_ID,
SubsystemResource *r,
SharedData &shared_data = DefaultData
);
~Reservoir();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The coolant-store interface.
//
public:
//
// The DrawCoolant SOURCE: hand out up to coolantLevel of the requested
// amount and deduct it from the charge.
//
virtual Scalar
DrawCoolant(Scalar requested);
typedef void
(Reservoir::*Performance)(Scalar time_slice);
void
SetPerformance(Performance performance)
{
Check(this);
activePerformance = (Simulation::Performance)performance;
}
void
CoolantSimulation(Scalar time_slice);
void
InjectCoolant(Scalar time_slice);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Local Data. The inject flag is the reservoir alarm's level (1 = flushing).
//
protected:
AlarmIndicator reservoirAlarm;
Scalar coolantSquirtMass;
Scalar injectAccumulator;
Scalar squirtEfficiency;
};
#endif