From c32d02b3ccb4a037f00c3a918cf337fe2c992f36 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Tue, 28 Jul 2026 11:28:11 -0500 Subject: [PATCH] the heat schematic was lit at every spawn: a dropped x87 tail, and the last #47 derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- game/reconstructed/btl4gaug.cpp | 154 +++++++++++++++++++--- game/reconstructed/heatfamily_reslice.cpp | 53 +++++++- 2 files changed, 189 insertions(+), 18 deletions(-) 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,