diff --git a/game/reconstructed/btl4gaug.cpp b/game/reconstructed/btl4gaug.cpp index d53370b..66efcdb 100644 --- a/game/reconstructed/btl4gaug.cpp +++ b/game/reconstructed/btl4gaug.cpp @@ -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; + } } diff --git a/game/reconstructed/heatfamily_reslice.cpp b/game/reconstructed/heatfamily_reslice.cpp index 0886eab..6866709 100644 --- a/game/reconstructed/heatfamily_reslice.cpp +++ b/game/reconstructed/heatfamily_reslice.cpp @@ -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,