Heat: authentic 1e7-unit heat + the ambient radiator land (task #9)

- Emitters: heatPortion closed form = heatCostToFire x 1e7 x (charge/seekV)^2
  (PPC 1.1e8 -> +632K on its own sink); the missing projectile/missile heat
  adds (raw -- resources author pre-scaled 1e7 units).
- The bank's AMBIENT RADIATOR @4ae73c reconstructed (the system's ONLY heat
  exit; conductance x 0.1 x HeatSinkCount -- _DAT_004ae974 float80 = 0.1) +
  the link-attach guard corrected (skip = the 0xBBE bank, not Condenser; the
  inversion blocked condenser->bank links and closed the system).
- Constant corrections (all byte-verified float80s): coolant epsilons
  0.0025/0.003/1e-4 (was a single 1e-4 serving three sites);
  CoolantCapacityScale 0.05 (was 1.738).
- Verified: heat flows + exits; max-rate autofire overheats weapons into the
  authentic heatLoad range-cutout (thermal spam unsustainable by design).
- KNOWN REMAINING: linked-sink routing scrambled (heat pools in Condenser1;
  authored map says PPC->C4/C6) -- the heat-stream offset audit, filed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-11 16:21:37 -05:00
co-authored by Claude Fable 5
parent fd055281a8
commit 4ed2bbc293
8 changed files with 224 additions and 17 deletions
+21
View File
@@ -165,6 +165,27 @@ authentic path scoped.
LODReuseHysteresis corrected 0.82→0.33 (a double); the MP beam-kill live-verify awaits a
clear-sightline spawn (mechanism identical to solo; force-dmg cross-pod cycles verified).
- **HEAT (task #9, 2026-07-11): authentic 1e7-unit heat LIVE; conduction ROUTING defect
remains [T2/T3].** Landed + byte-anchored: emitters emit `heatCostToFire x 1e7 x
(charge/seekV)^2` (closed form; PPC = 1.1e8 -> +632 K on its 174000-mass sink); the missing
projectile/missile heat adds (raw pre-scaled resources); the bank's AMBIENT RADIATOR @4ae73c
(the system's only heat exit: relax toward 300 K, conductance x 0.1 x HeatSinkCount
[_DAT_004ae974 float80 = 0.1 -- the audit's "1.7375" was a misread]); the link-attach guard
corrected (skip = the 0xBBE bank, NOT Condenser -- the inversion had closed the system);
three coolant epsilon corrections (0.0025/0.003/1e-4 for the old single 1e-4) +
CoolantCapacityScale = 0.05 float80 (both prior readings wrong). Verified: heat flows,
exits (~2/3 of a 1e10 spam run drained), weapons overheat under max-rate autofire exactly
as the authored-constant simulation predicts (thermal spam is unsustainable BY DESIGN;
heatLoad -> effectiveRange 0 = the authentic overheat cutout). **REMAINING DEFECT: the
linked-sink ROUTING is scrambled** -- both PPCs' heat pools in Condenser1 (observed 6800 K)
while their AUTHORED condensers (PPC_1->C4, PPC_2->C6) sit at 72 K; heat.hpp:186 already
notes the heat-stream parse reading neighbouring fields for thermalMass/heatSinkIndex --
audit the type-0x11/heat-family stream offsets + the "+2-biased" index resolve. Also
[T4]: who reads FailureHeat to BLOCK firing; the HeatWatcher/AmmoBin cook-off watchedLink;
the mech-level heatAlarm@0x450 producer. KB sweep pending: the "Heat MFD authentically
NEAR-STATIC" claim (gauges-hud.md) was computed at the degenerate scale -- at authentic
scale the MFD is dynamic.
## Locomotion / combat polish (non-gating)
- Authentic per-mech TURN-RATE constant (currently a bring-up constant rate).
- Body-callback gimp handlers (states 16-19, targets 0x70b2/0x7161 undecoded → fall back to stand);
+13 -5
View File
@@ -201,11 +201,19 @@ void
damagePortion = (damageFraction * energyTotal * kDamageScale)
* chargeRatio * chargeRatio; // 0x44c
}
// Heat stays on the bring-up scale until the heat-calibration audit [T3]:
// the authentic heatPortion is heatCostToFire x 1e7 energy units (the
// missile path already feeds that scale raw); switching the emitters too
// is the heat audit's call, not the damage economy's.
heatPortion = energy - damageFraction * energy; // 0x450
// THE AUTHENTIC HEAT (task #9; the calibration audit verified the whole
// HeatSink chain is 1e7-unit-native and formula-exact): the closed form
// mirroring the damage fix -- heatPortion = (1 - dF) x energyTotal x
// (charge/seekV)^2 = heatCostToFire x 1e7 at full charge (PPC = 1.1e8 ->
// +632 K on its own 174000-mass sink, conducting to the 1.39e6 bank;
// authored thresholds 77 start / 1000 degradation / 2000 failure).
{
Scalar vRecH = seekVoltage[seekVoltageRecommendedIndex];
Scalar chargeRatioH = (vRecH > 0.0f) ? (currentLevel / vRecH) : 1.0f;
heatPortion = (1.0f - damageFraction) * energyTotal
* chargeRatioH * chargeRatioH; // 0x450
}
(void)energy;
// dump the heat portion into our INHERITED thermal accumulator if heatable/online.
// E5: pendingHeat (HeatSink @0x1C8) is the field the heat sim absorbs each frame
+23 -9
View File
@@ -62,13 +62,21 @@
// _DAT_004ad880 : 00 00 80 3f == 1.0f
// _DAT_004ada90 : 00 00 80 3f == 1.0f (the "1 - exp(...)" unit term)
// _DAT_004adbf4 : 17 b7 d1 38 == 1.0e-4f (heat-equalise threshold)
// _DAT_004adcfc : 17 b7 d1 38 == 1.0e-4f (coolant-draw threshold)
// _DAT_004adcfc : 17 b7 d1 38 == 1.0e-4f (DrawCoolant |amount| gate)
// _DAT_0050e3d8 : 0a d7 23 3b == 0.0025f (draw-zero floor + OFF hysteresis)
// _DAT_0050e3d4 : a6 9b 44 3b == 0.003f (coolantActive ON threshold)
//
static const Scalar HeatLoadScale = 0.002f; // _DAT_004ad870 (80-bit extended)
static const Scalar HeatLoadMinimum = 0.0f; // _DAT_004ad87c
static const Scalar HeatLoadMaximum = 1.0f; // _DAT_004ad880
static const Scalar HeatEqualizeEpsilon = 1.0e-4f; // _DAT_004adbf4
static const Scalar CoolantDrawEpsilon = 1.0e-4f; // _DAT_004adcfc / 0050e3d8 / 0050e3d4
// task #9 correction: the old single CoolantDrawEpsilon (1e-4) served THREE
// distinct byte-verified constants -- the draw floor / OFF hysteresis are
// 0.0025 and the ON threshold 0.003 (the 1e-4 reading made the leak floor +
// status-bit hysteresis fire ~25-30x too eagerly).
static const Scalar CoolantDrawGate = 1.0e-4f; // _DAT_004adcfc (DrawCoolant |amount|)
static const Scalar CoolantDrawFloor = 0.0025f; // _DAT_0050e3d8 (zero floor + OFF)
static const Scalar CoolantActiveOn = 0.003f; // _DAT_0050e3d4 (ON threshold)
//###########################################################################
@@ -466,9 +474,15 @@ HeatSink::HeatSink(
//
// Resolve and attach the linked heat sink referenced by heatSinkIndex.
// (Skipped for "Condenser"-class segments -- see @0041a1a4 guard.)
// task #9 GUARD CORRECTION: the binary's @0041a1a4 test is against GUID
// 0x50e590 = the AGGREGATE bank (0xBBE "HeatSinkBank"), NOT Condenser --
// only the BANK skips the link-attach (its authored SinkIdx=5 is dead;
// it radiates to ambient instead). The old Condenser guard BLOCKED the
// condenser->bank conduction links, which (with the ambient radiator
// deferred) closed the system: heat could never leave -- the observed
// monotonic runaway once emitters went authentic.
//
if (!IsDerivedFrom(*Condenser::GetClassDerivations())) // FUN_0041a1a4(*this[3], 0x50e590)
if (!IsDerivedFrom("HeatSinkBank")) // FUN_0041a1a4(*this[3], 0x50e590)
{
// ⚠ ROOT-CAUSE FIX (the BGF-load heap corruption): heatSinkIndex indexes the
// owner's SUBSYSTEM ROSTER, NOT the skeleton segment table. Raw @004adda0
@@ -596,7 +610,7 @@ void
&& (Entity *)owner == application->GetViewpointEntity() && pendingHeat > 0.0f)
{
s_heatLog = 0.0f;
DEBUG_STREAM << "[heat] sink temp=" << currentTemperature
DEBUG_STREAM << "[heat] " << GetName() << " temp=" << currentTemperature
<< " heatEnergy=" << heatEnergy << " absorbed=" << pendingHeat
<< " heatLoad=" << heatLoad << "\n" << std::flush;
}
@@ -781,7 +795,7 @@ void
::DamageZone *ownZone = this->Subsystem::damageZone; // @0xE0 (word 0x38)
Scalar zoneDamage = (ownZone != 0) ? ownZone->damageLevel : 0.0f; // +0x158
coolantDraw = zoneDamage * heatLoad; // *(this[0x38]+0x158) * this[0x48]
if (coolantDraw < CoolantDrawEpsilon)
if (coolantDraw < CoolantDrawFloor) // _DAT_0050e3d8 = 0.0025
{
coolantDraw = 0.0f;
}
@@ -793,7 +807,7 @@ void
}
coolantLevel -= amount;
if (fabsf(amount) > CoolantDrawEpsilon)
if (fabsf(amount) > CoolantDrawGate) // _DAT_004adcfc = 1e-4
{
coolantLevel += DrawCoolant(amount); // virtual: (**(*this+0x38))(this, amount)
}
@@ -801,11 +815,11 @@ void
//
// Draw state machine (this+0x138).
//
if (coolantActive == 0 && coolantDraw > CoolantDrawEpsilon)
if (coolantActive == 0 && coolantDraw > CoolantActiveOn) // _DAT_0050e3d4 = 0.003
{
coolantActive = 1;
}
else if (coolantActive == 1 && coolantDraw < CoolantDrawEpsilon)
else if (coolantActive == 1 && coolantDraw < CoolantDrawFloor) // _DAT_0050e3d8 = 0.0025
{
coolantActive = 0;
}
+60 -2
View File
@@ -45,7 +45,9 @@
// Recovered .data constants (best-effort; exact double bit-patterns folded to
// decimal where known). TODO: confirm against section_dump.txt.
//
static const Scalar CoolantCapacityScale = 1.738f; // _DAT_004af518 (~ master-sink capacity scale)
static const Scalar CoolantCapacityScale = 0.05f; // _DAT_004af518 (float80, byte-verified
// task #9; both prior readings -- 1.738
// and the audit's 1.675 -- were misreads)
// FUN_0049f788 -- defined near the foot of this file; forward-declared for MoveValve.
void BTRecomputeCondenserValves(Entity *owner);
@@ -720,10 +722,66 @@ AggregateHeatSink::AggregateHeatSink(
Check_Pointer(subsystem_resource);
heatSinkCount = subsystem_resource->heatSinkCount; // this[0x74] @0x1D0 = res +0xFC
ambientTemperature = 300.0f; // this[0x75] @0x1D4 (_DAT_004ae89c) FROZEN
ambientTemperature = 300.0f; // this[0x75] @0x1D4 (_DAT_004ae89c)
// task #9 (the ambient radiator lands): the binary ctor @4ae8d0 scales the
// bank's conductance by 0.1 x HeatSinkCount (_DAT_004ae974 float80 = 0.1
// byte-verified; madcat count 14 -> x1.4) and installs the RADIATOR
// Performance @4ae73c on masters -- the system's ONLY heat exit.
thermalConductance = 0.1f * (Scalar)heatSinkCount * thermalConductance;
if ((owner->simulationFlags & 0xC) == 0
&& (owner->simulationFlags & 0x100) != 0)
{
SetPerformance((HeatSink::Performance)
&AggregateHeatSink::RadiatorSimulation); // PTR @0050e5e8 = @4ae73c
}
Check_Fpu();
}
//
// @4ae73c -- the bank's authentic Performance (task #9; was DEFERRED): its own
// absorb/temperature/load step, then the AMBIENT RADIATOR -- relax toward the
// 300 K setpoint (target = 300 - (300 - ambientTemperature) x 3.0; = 300 with
// the frozen setpoint) with rate k = conductance x (1 - ownZoneDamage) x
// (coolant/capacity) x flowScale / mass -- the only place heat LEAVES the
// mech. Tail: top the bank's coolant up from the linked reservoir
// (DrawCoolant, vtable +0x38) whenever below capacity. The old deferral note
// ("raw self+0xE0 -> [+0x158] does not map") was wrong -- that read is the
// engine-base DamageZone's damageLevel, the same named-member pattern
// UpdateCoolant already uses. [T1 constants: 300 / 3.0 / 1.0 / 0.0 / 1e-4]
//
void
AggregateHeatSink::RadiatorSimulation(Scalar time_slice)
{
if (HeatModelActive()) // FUN_004ad7d4
{
heatEnergy += pendingHeat; // [0x56] += [0x72]
currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55]
UpdateHeatLoad(); // FUN_004ad7f0
pendingHeat = 0.0f;
Scalar target = 300.0f
- (300.0f - ambientTemperature) * 3.0f; // _DAT_004ae89c/_DAT_004ae8a0
::DamageZone *ownZone = this->Subsystem::damageZone; // @0xE0 (word 0x38)
Scalar zoneDamage = (ownZone != 0) ? (Scalar)ownZone->damageLevel : 0.0f;
double ex = (double)(-(time_slice * thermalConductance
* (1.0f - zoneDamage) // _DAT_004ae8a4 - dmg
* (coolantLevel / thermalCapacity) * coolantFlowScale)
/ thermalMass);
Scalar decay = 1.0f - (Scalar)exp(ex); // FUN_004dca38
pendingHeat += -((currentTemperature * massScale - target)
* thermalMass * decay);
}
// coolant top-up from the reservoir (the binary tail: deficit > 0 and
// past the 1e-4 gate -> DrawCoolant via vtable +0x38).
Scalar deficit = thermalCapacity - coolantLevel; // [0x4a] - [0x4b]
if (deficit > 0.0f && fabsf(deficit) > 1.0e-4f) // _DAT_004ae8a8/_DAT_004ae8ac
{
coolantLevel += DrawCoolant(deficit); // vcall +0x38
}
}
AggregateHeatSink::~AggregateHeatSink()
{
Check(this);
@@ -347,6 +347,10 @@
); // @4ae8d0
~AggregateHeatSink();
// @4ae73c -- the authentic bank Performance (task #9): absorb + the
// AMBIENT RADIATOR (the system's only heat exit) + coolant top-up.
void RadiatorSimulation(Scalar time_slice);
static int CreateStreamedSubsystem( // @4ae9dc (offline content-build only)
NotationFile *model_file,
const char *model_name,
+8
View File
@@ -218,6 +218,14 @@ void MissileLauncher::FireWeapon()
{
Check(this);
// THE FIRING HEAT (task #9): heatCostToFire RAW into the launcher's own
// pendingHeat (pre-scaled 1e7-unit resources: SRM6 = 5e7, LRM15 = 6.5e7)
// -- the binary's add @part_013.c:8744-8747, gated on the heatable check.
if (HeatModelActive()) // FUN_004ad7d4 (heat.hpp; [T3] permissive)
{
pendingHeat += heatCostToFire; // HeatSink @0x1C8, raw
}
// A salvo draws one ammo unit from the linked bin; a dry / not-ready bin
// latches NoAmmo (ConsumeRound) and aborts the launch.
if (!ConsumeRound()) // FUN_004bd4f4 (AmmoBin::FeedAmmo)
+9 -1
View File
@@ -598,11 +598,19 @@ void
{
Check(this);
// THE FIRING HEAT (task #9; was "a separate pre-existing gap"): the
// binary adds heatCostToFire RAW to the weapon's own pendingHeat --
// projectile resources author PRE-scaled 1e7-unit heat (AFC100 = 6.5e7)
// -- gated on the heatable check (part_013.c:8744-8747 idiom).
if (HeatModelActive()) // FUN_004ad7d4 (heat.hpp; [T3] permissive)
{
pendingHeat += heatCostToFire; // HeatSink @0x1C8, raw
}
// Binary @4bc136-4bc19c (task #56): the firing RECOIL kick, BEFORE the ammo
// pull (no early-out precedes it in the binary). Gate: per-shot damage >
// 3.0f (@0x4bc3f4) && the owner mech has a gyro. Direction (0, 0.6, -1.5)
// (0x3f19999a/0xbfc00000), magnitude = damageAmount / 16 (@0x4bc3f8).
// (The binary's preceding heat add is a separate pre-existing gap.)
if (damageData.damageAmount > 3.0f && owner != 0)
{
extern void GyroApplyDamageImpulse(Subsystem *, Scalar, Scalar, Scalar, Scalar);
+86
View File
@@ -0,0 +1,86 @@
import struct
data = open(r'C:/git/bt411/content/BTL4.RES','rb').read()
n = len(data)
def cstr(off, maxlen=32):
s = data[off:off+maxlen].split(b'\0')[0]
try: return s.decode('ascii')
except: return None
def f32(off): return struct.unpack_from('<f', data, off)[0]
def i32(off): return struct.unpack_from('<i', data, off)[0]
# find ALL subsystem records (same heuristic as scan_weapsub.py)
recs = []
i = 0
while i < n - 0x28:
cid = struct.unpack_from('<I', data, i+0x20)[0]
if 0xBB0 <= cid <= 0xBE5:
sz = struct.unpack_from('<I', data, i+0x24)[0]
if 0xE0 <= sz <= 0x300:
name = cstr(i)
if name and 2 <= len(name) <= 31 and all(32 < ord(c) < 127 for c in name):
pad = data[i+len(name):i+0x20]
if pad.count(0) >= len(pad) - 2:
recs.append((i, name, cid, sz))
i += 0x28
continue
i += 1
# group into chains (consecutive: next record at p+sz)
chains = []
cur = []
for k,(p,name,cid,sz) in enumerate(recs):
if cur and cur[-1][0] + cur[-1][3] == p:
cur.append(recs[k])
else:
if cur: chains.append(cur)
cur = [recs[k]]
if cur: chains.append(cur)
mechnames = [b'madcat', b'bhk', b'vulture', b'thor', b'blackhawk', b'blh', b'owens', b'strider', b'sunder', b'cauldron', b'avatar', b'mdc']
low = data.lower()
namepos = []
for m in mechnames:
j = 0
while True:
j = low.find(m, j)
if j < 0: break
namepos.append((j, m.decode()))
j += 1
namepos.sort()
def nearest_label(p):
best = None
for pos, name in namepos:
if pos < p: best = (pos, name)
else: break
return best
HEAT = {0xBBD:'Condenser', 0xBBE:'AggregateHeatSink', 0xBC0:'Reservoir', 0xBBB:'HeatSink?', 0xBBC:'?'}
WEAP = {0xBC8:'Emitter', 0xBD4:'PPC', 0xBCD:'ProjWeapon', 0xBCE:'GaussRifle', 0xBD0:'MissileLauncher'}
for ci, ch in enumerate(chains):
lbl = nearest_label(ch[0][0])
names = [c[1] for c in ch]
print(f'--- chain {ci} @0x{ch[0][0]:x} n={len(ch)} nearest-name={lbl}')
for idx,(p,name,cid,sz) in enumerate(ch):
tag = HEAT.get(cid) or WEAP.get(cid)
if cid in HEAT:
st, dg, ft = f32(p+0xE4), f32(p+0xE8), f32(p+0xEC)
tc, tm = f32(p+0xF0), f32(p+0xF4)
hsi = i32(p+0xF8)
line = (f' [{idx:2d}] {name:20s} cid=0x{cid:X}({tag}) sz=0x{sz:X} '
f'StartT={st:g} DegT={dg:g} FailT={ft:g} Conduct={tc:g} ThermMass={tm:g} HeatSinkIdx={hsi}')
if cid == 0xBBD:
line += f' RefrigFactor={f32(p+0xFC):g}'
elif cid == 0xBBE:
line += f' HeatSinkCount={i32(p+0xFC)}'
elif cid == 0xBC0:
line += f' CoolantCap={f32(p+0xFC):g} SquirtMass={f32(p+0x100):g}'
print(line)
elif cid in WEAP:
heat = f32(p+0x1A4)
print(f' [{idx:2d}] {name:20s} cid=0x{cid:X}({tag}) Heat={heat:g}')
else:
print(f' [{idx:2d}] {name:20s} cid=0x{cid:X} sz=0x{sz:X}')