the heat schematic was lit at every spawn: a dropped x87 tail, and the last #47 derivation

Playtest night 5: "heat damage @ launch is lit up like a Christmas tree",
confirmed by two players at ANY spawn -- so not the respawn-reset bug it was
first filed as.  Two stacked causes, both fixed here.

1. THE RATIO WAS NEVER RECONSTRUCTED.  HeatConnection::Transfer @004c3720 sets
   the colour index a ColorMapper pushes into the palette slot.  The port wrote

       *currentColorIndex = HeatRound(heat->currentTemperature);

   straight into an index whose range is 0..99.  A stone-cold mech sits at ~77
   and the generators idle near 260, so every one of the 24 cmHeat mappers in
   GAUGE/L4GAUGE.CFG (GeneratorA-D, Condenser1-6, HUD, Avionics, Gyroscope,
   Torso, GAUSS, the lasers, SRM6, Myomers) saturated at the hot end from the
   first frame and stayed there.

   The real computation was INVISIBLE in the decomp.  Ghidra renders the tail as
   `uVar1 = FUN_004dcd94();` -- an arg-less __ftol, exactly the carve artifact
   reconstruction-gotchas §19 documents: the x87 expression that left the value
   in ST0 is dropped from the export.  The previous author reconstructed the
   only thing visible and flagged the scaling as unreconciled in a comment.
   Raw disasm @0x4c379a-0x4c37c1 recovers it:

       fld [num] ; fdiv [den]        ; ratio
       fcomp 1.0f ; jbe -> ratio=1   ; clamp
       fmul 99.0f ; call __ftol      ; -> 0..99

   i.e. "how close to failure am I", not a raw temperature.  At spawn that is
   77/2000 -> 4, and the schematic reads cold.

   The two operands come from one of two branches, chosen by a class flag at
   +0x14 that the port did not model at all (ctor disasm @0x4c3682-0x4c36c2):
     HeatableSubsystem (0x50e3ec) -> own temp@0x114 / own failureTemperature@0x11C
     HeatWatcher       (0x50e604) -> WATCHED subsystem's temp@0x114
                                     / the WATCHER's failureTemperature@0x124
     neither                      -> source NULLed, Transfer writes 100
   Note the watcher asymmetry: temperature from the watched subsystem, reference
   from the watcher itself.  Bridged as BTHeatWatcherSample so the gauge TU need
   not include the heat family's headers.

2. THE WATCHER BRANCH COULD NEVER BE SELECTED.  With (1) fixed, HUD, Gyroscope
   and Torso still pinned at 99 reading temp=1.6369e-35 failAt=0 -- uninitialised
   bytes.  HeatWatcher's C++ base had been re-based to MechSubsystem, but its
   DERIVATION chain still said HeatableSubsystem, so every watcher answered "yes,
   I am heat-bearing", took branch one, and read its own watchedLink@0x114 as a
   temperature.  This is the last surviving instance of the #47 bug -- in the
   file that fix's own comment cites as already correct.  The two families are
   disjoint in the binary and the branch test depends on it.

VERIFIED LIVE (solo, F6 to the Heat schematic, BT_HEATGAUGE_LOG=1): mode mask
reached 0x510421 (ModeSecondaryHeat) and the gauge tracks per subsystem rather
than saturating -- GeneratorA 259.5->13, Condenser3 202.0->10, SRM6 182.8->9,
lasers 90-97->4-5, and AmmoBinSRM6_1 (a watcher) resolves its link and reads
182.7->9 through the branch that previously read 1.6369e-35.  Nothing pinned at
99.  Operator confirms the panel is blue, not red.

BT_HEATGAUGE_LOG kept as a permanent env-gated diagnostic: bind-time
classification plus per-sample temp/failAt/ratio/index.  A fresh spawn reading
99 is this regression returning.

Not covered: ColorMapperCritical (cmCrit / ModeSecondaryCritical) already
computes a proper damageLevel*100 ratio and was read, not measured -- if the
Critical page specifically still misbehaves that is a separate lead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joe DiPrima
2026-07-28 11:28:11 -05:00
co-authored by Claude Opus 5
parent 1777d5a62e
commit c32d02b3cc
2 changed files with 189 additions and 18 deletions
+137 -17
View File
@@ -114,35 +114,114 @@ static const Scalar StatesCriticalLevel = 0.0025f; // _DAT_0050e3d8
class HeatConnection : public GaugeConnection
{
public:
//
// @004c3664. The ctor CLASSIFIES the source (the +0x14 flag Transfer reads)
// and NULLs a source that is neither heat class, so Transfer's first test
// then writes the fail-safe 100.
//
HeatConnection(int *destination, Subsystem *source)
: GaugeConnection(0),
currentColorIndex(destination),
heatSubsystem(source)
{}
heatSubsystem(source),
sourceIsHeatable(0),
currentColorIndex(destination)
{
extern int BTIsHeatWatcher(Subsystem *sub); // heatfamily_reslice.cpp
if (heatSubsystem != NULL)
{
if (heatSubsystem->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
sourceIsHeatable = 1; // @0x4c369d: [this+0x14] = 1
else if (BTIsHeatWatcher(heatSubsystem))
sourceIsHeatable = 0; // @0x4c36bf: [this+0x14] = 0
else
heatSubsystem = NULL; // @0x4c36c4: xor esi,esi
}
if (getenv("BT_HEATGAUGE_LOG"))
{
DEBUG_STREAM << "[heatgauge] bind "
<< (heatSubsystem ? heatSubsystem->GetName() : "(none)")
<< (heatSubsystem == NULL ? " REJECTED (neither heat class)"
: (sourceIsHeatable ? " heatable" : " watcher"));
if (heatSubsystem != NULL && sourceIsHeatable)
{
// The two operands of the authentic ratio, so a spawn reading can
// be checked WITHOUT driving the MFD to the Heat secondary page
// (Update() only runs while that mode is displayed).
HeatableSubsystem *h = (HeatableSubsystem *)heatSubsystem;
Scalar r = (h->failureTemperature > 0.0f)
? (h->currentTemperature / h->failureTemperature) : 1.0f;
if (r > 1.0f) r = 1.0f;
DEBUG_STREAM << " temp=" << (float)h->currentTemperature
<< " failAt=" << (float)h->failureTemperature
<< " -> index=" << (int)(r * 99.0f + 0.5f); // HeatRound is declared below
}
else if (heatSubsystem != NULL)
{
extern int BTHeatWatcherSample(Subsystem *, Scalar *, Scalar *);
Scalar c = 0.0f, f = 0.0f;
if (BTHeatWatcherSample(heatSubsystem, &c, &f))
{
Scalar r = (f > 0.0f) ? (c / f) : 1.0f;
if (r > 1.0f) r = 1.0f;
DEBUG_STREAM << " watched temp=" << (float)c
<< " failAt=" << (float)f
<< " -> index=" << (int)(r * 99.0f + 0.5f);
}
else
{
// Expected AT BIND: the factory binds watchedLink in its
// post-roster connect pass, which runs after the gauges are
// built. Only a persistent failure AT UPDATE would matter.
DEBUG_STREAM << " link not resolved yet (bind-time)";
}
}
DEBUG_STREAM << std::endl << std::flush;
}
}
protected:
//
// The per-frame data feed -- @004c3720 (the recovered "Transfer"). The
// gauge framework calls this once per frame (Plug/GaugeConnection vtbl
// virtual Update()). It samples the live heat-subsystem temperature and
// writes the colour index that ColorMapper::Execute then pushes into the
// The per-frame data feed -- @004c3720. The gauge framework calls this once
// per frame; it writes the colour index ColorMapper::Execute pushes into the
// hardware palette slot, tinting the cockpit heat art.
//
// no subsystem -> 100 (fail safe: full tint)
// subsystem destroyed -> 100 (recovered "+0x40 == 1" path)
// else -> Round(currentTemperature @0x114)
// subsystem destroyed -> 100 ("+0x40 == 1" path)
// else -> Min(temperature / failureTemperature, 1) * 99
//
// NOTE (flagged): the recovered code rounds the value resolved at +0x114
// (currentTemperature) directly into the 0..255 colour index that
// ColorMapper::Execute then clamps to [0,255]. The exact temperature->
// percentage scaling (HEAT.TCP seeds currentTemperature at 300.0) is a
// tuning detail that a human should reconcile against the original
// l4gauge.cfg palette ramp; the live data path itself is correct.
// FIXED 2026-07-28 -- "the critical heat panel is lit up like a Christmas
// tree at launch" (playtest night 5, reported at ANY spawn, not just after a
// respawn, so not a reset bug). This function used to write
// Round(currentTemperature) straight into the index. HEAT.TCP seeds
// currentTemperature at 300.0 and the index range is 0..99, so a stone-cold
// mech pinned EVERY heat lamp at maximum from the first frame, permanently.
//
// The real computation was invisible in the decomp: it ends in
// `uVar1 = FUN_004dcd94();` -- an ARG-LESS __ftol, which is exactly the
// carve artifact reconstruction-gotchas §19 warns about (Ghidra drops the
// x87 expression that left the value in ST0). Raw disasm @0x4c379a-0x4c37c1
// recovers it:
//
// fld [numerator] ; fdiv [denominator] ; ratio
// fcomp 1.0f ; jbe -> else ratio = 1.0f ; CLAMP to 1
// fmul 99.0f ; call __ftol ; -> 0..99
//
// The two operands come from one of two branches, chosen by the class flag
// the ctor sets at +0x14 (disasm @0x4c3682-0x4c36c2):
//
// HeatableSubsystem (0x50e3ec) -> own currentTemperature@0x114
// / own failureTemperature@0x11C
// HeatWatcher (0x50e604) -> WATCHED subsystem's temperature@0x114
// / the WATCHER's failureTemperature@0x124
// neither -> source NULLed; Update writes 100
//
// Both are "how close to failure am I", 0..1 -- so a spawn at ambient 300
// against a ~2000 failure line now reads ~15, not a maxed lamp.
//
void Update(); // override
int *currentColorIndex; // destination@0x18
Subsystem *heatSubsystem; // source@0x10
int sourceIsHeatable; // @0x14 1 = HeatableSubsystem, 0 = HeatWatcher
int *currentColorIndex; // destination@0x18
};
//
@@ -175,7 +254,48 @@ void
return;
}
*currentColorIndex = HeatRound(heat->currentTemperature); // +0x114 live feed
//
// The ratio -- see the note on the declaration for the recovered x87 tail.
//
Scalar current = 0.0f;
Scalar reference = 0.0f;
if (sourceIsHeatable)
{
current = heat->currentTemperature; // @0x114
reference = heat->failureTemperature; // @0x11C
}
else
{
extern int BTHeatWatcherSample(Subsystem *, Scalar *, Scalar *);
if (!BTHeatWatcherSample(heatSubsystem, &current, &reference))
{
*currentColorIndex = 100; // link not resolved yet -- fail safe
return;
}
}
// The binary divides unguarded: a zero reference yields +inf, which its own
// `> 1.0` test then clamps to 1.0 (full tint). Reproduce that result without
// relying on x87 inf/NaN semantics, which differ under SSE2 codegen.
Scalar ratio = (reference > 0.0f) ? (current / reference) : 1.0f;
if (ratio > 1.0f)
ratio = 1.0f; // @0x4c37b1: ratio = 1.0f
*currentColorIndex = HeatRound(ratio * 99.0f); // @0x4c37bb: * 99, then __ftol
// BT_HEATGAUGE_LOG=1 -- first few samples per run. A cold mech at ambient
// should read a LOW index (300 / ~2000 -> ~15); a pinned 99 on a fresh spawn
// is the Christmas-tree regression coming back.
if (getenv("BT_HEATGAUGE_LOG"))
{
static int s_logged = 0;
if (s_logged++ < 12)
DEBUG_STREAM << "[heatgauge] " << heatSubsystem->GetName()
<< (sourceIsHeatable ? " (heatable)" : " (watcher)")
<< " temp=" << (float)current
<< " failAt=" << (float)reference
<< " ratio=" << (float)ratio
<< " -> index=" << *currentColorIndex << std::endl << std::flush;
}
}
+52 -1
View File
@@ -339,7 +339,23 @@ HeatWatcher::SharedData
Derivation*
HeatWatcher::GetClassDerivations()
{
static Derivation classDerivations(HeatableSubsystem::GetClassDerivations(), "HeatWatcher");
// 2026-07-28 CORRECTION -- the last surviving instance of the #47 bug, in
// the file that fix's own comment cites as already correct. The C++ base
// was re-based to MechSubsystem (heatfamily_reslice.hpp: "was
// HeatableSubsystem (approximation); real base is MechSubsystem"), but
// this derivation chain was left pointing at HeatableSubsystem, so the
// runtime type test disagreed with the actual hierarchy.
//
// The two branches are DISJOINT in the binary -- heat leaf
// (PoweredSubsystem : HeatSink : HeatableSubsystem) vs watcher
// (Torso/Gyroscope/HUD/Searchlight/ThermalSight : PowerWatcher :
// HeatWatcher) -- and HeatConnection::Transfer @004c3720 picks which
// OFFSETS to read from exactly this test. With watchers answering
// "yes, I am a HeatableSubsystem", the heat gauges read a watcher's
// watchedLink@0x114 as a temperature and its interior as the failure
// setpoint: measured temp=1.6369e-35 failAt=0 for HUD, Gyroscope and
// Torso, which pinned those three lamps at maximum tint forever.
static Derivation classDerivations(&MechSubsystem::ClassDerivations, "HeatWatcher");
return &classDerivations;
}
@@ -487,6 +503,41 @@ int
return sub != 0 && sub->IsDerivedFrom(*HeatWatcher::GetClassDerivations());
}
//
// HeatConnection::Transfer's WATCHER branch (@004c3720, the `[this+0x14]==0`
// path) -- bridged because the gauge TU cannot include this family's headers.
//
// The binary does, verbatim (raw disasm @0x4c3774-0x4c3797; Ghidra dropped the
// whole x87 tail into a bare `FUN_004dcd94()` -- the __ftol carve artifact of
// reconstruction-gotchas §19):
//
// esi = FUN_00417ab4(sub + 0x114) // watchedLink.Resolve()
// numerator = *(float *)(esi + 0x114) // the WATCHED subsystem's temperature
// denominator = *(float *)(sub + 0x124) // the WATCHER's own failureTemperature
//
// Note the asymmetry, which is authentic and easy to get wrong: the temperature
// comes from the watched subsystem, the reference from the watcher itself.
// Returns 0 if the link has not resolved (no target bound yet).
//
int
BTHeatWatcherSample(Subsystem *sub, Scalar *current, Scalar *reference)
{
if (sub == 0 || !BTIsHeatWatcher(sub))
{
return 0;
}
HeatWatcher *watcher = (HeatWatcher *)sub;
HeatableSubsystem *watched =
(HeatableSubsystem *)watcher->watchedLink.Resolve(); // FUN_00417ab4(this+0x114)
if (watched == 0)
{
return 0;
}
*current = watched->currentTemperature; // watched @0x114
*reference = watcher->failureTemperature; // watcher @0x124
return 1;
}
//
// @4aec54 (468 bytes) -- parse the HeatWatcher resource. Stamps classID 0x0BBF
// / size 0xF0; defaults the watched index to -1; requires DegradationTemperature,