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:
co-authored by
Claude Opus 5
parent
1777d5a62e
commit
c32d02b3cc
+137
-17
@@ -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, ¤t, &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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user