diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 417d0a8..688c8b9 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -568,6 +568,21 @@ and every instrument is now live [T2]:** threshold for HUD page visibility" was neither heat nor page visibility) — corrected in place, with the live lock/slide implementation staying in mech4.cpp's targeting step, which had both thresholds right all along. + **THE CARET CAN DIE FOR THE WHOLE SESSION — #147, fixed 2026-08-08 [T2 code, T3 field link]:** + `sShownRange` (mech4's targeting step) is a **function-level static** — one cell per process, + shared by every mech, carried across drops. **NaN is absorbing** in `step = trueRange − + sShownRange; sShownRange += step`, and neither the producer's clamps nor + `BTReticleRenderable::Draw`'s (`range < minRange` / `range > maxRange`) catch it — both + comparisons are **false for NaN**. A poisoned value therefore reaches `AddPoint`/`ConcatMatrix`, + and the caret + its bar become **degenerate geometry that stops rendering while the static tick + marks keep drawing** — the exact reported symptom ("no range finder on this drop", ticks there, + caret gone), sticky until relaunch. Fixed four ways: re-seed on mech change (not on respawn — + that reuses the entity, and the binary doesn't reset the readout either), a producer NaN trap, a + NaN-safe consumer clamp falling back to the 1200 peg, and — the reason no log could settle it — + **`BT_RANGE_LOG` now prints the caret's actual input** (`[range] caret input shown=`). It never + did before: `BT_RANGE_LOG` instrumented the PICK (#4) and `[target]`'s `range=` is a *separate* + locally-recomputed Sqrt in the weapon-range check, so grepping field logs for NaN found nothing + because the poisoned variable was never printed. Causal link to the field report is INFERENCE. **GAP — the 100 m RANGE BIAS is NOT reconstructed [T1 read, unimplemented]:** HudSimulation subtracts `_DAT_004b7ecc` (100.0f) from `RangeToTarget@0x1EC` **every frame while the timed flag @0x22C is set**, accumulating @0x21C by `time_slice` until it reaches @0x1D8, then clearing diff --git a/game/reconstructed/btl4vid.cpp b/game/reconstructed/btl4vid.cpp index acc581e..caa1e56 100644 --- a/game/reconstructed/btl4vid.cpp +++ b/game/reconstructed/btl4vid.cpp @@ -2622,6 +2622,14 @@ void { // the range caret translate, from the live target range Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f; + // #147: NaN-SAFE clamp. `range < minRange` and `range > maxRange` are BOTH + // false for NaN, so the old pair let a poisoned value straight through into + // AddPoint/ConcatMatrix below -- degenerate geometry, and the caret + its + // bar silently STOP DRAWING while the static ticks remain. Test for NaN + // first (x == x is false only for NaN) and fall back to the binary's + // no-target default rather than rendering nothing. + if (!(range == range)) + range = maxRange; // 1200: the authentic no-target peg if (range < minRange) range = minRange; if (range > maxRange) range = maxRange; Scalar frac = (range - minRange) / (maxRange - minRange); diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index b4346c2..cbfae47 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -6286,7 +6286,21 @@ void // clamp(true - shown, +-dt*500) -- so the caret sweeps smoothly as // the boresight crosses near/far ground instead of teleporting. // (Applies to the no-target 1200 default too.) - static float sShownRange = 1200.0f; + // #147: sShownRange is DISPLAY state, but it is a function-level + // static -- one cell for the whole process, shared by every mech and + // carried across drops. Re-seed it whenever the viewpoint mech + // CHANGES (a new drop hands us a new entity) so a fresh drop starts + // at the binary's 1200 default instead of inheriting the last + // mission's slid value. A respawn REUSES the entity (Mech::Reset + // heals in place), so this deliberately does not fire there -- the + // binary does not reset the readout on respawn either. + static float sShownRange = 1200.0f; + static const void *sShownOwner = 0; + if (sShownOwner != (const void *)this) + { + sShownOwner = (const void *)this; + sShownRange = 1200.0f; + } float trueRange = 1200.0f; // no target: the binary default Entity *des = MECH_TARGET_ENTITY(this); if (des != 0 && des != hotTarget) @@ -6343,6 +6357,26 @@ void // the 500 m/s slide toward trueRange (see the banner above) { + // #147 NaN TRAP. NaN is ABSORBING here and the clamps below + // cannot catch it: `step > maxStep` and `step < -maxStep` are + // BOTH false for NaN, so a single poisoned frame makes + // sShownRange NaN and it stays NaN for the life of the process + // (the static is never re-seeded except on a mech change). + // Downstream, BTReticleRenderable::Draw clamps the same way, so + // the NaN reaches AddPoint/ConcatMatrix and the caret + its bar + // render as degenerate geometry -- i.e. they VANISH while every + // static reticle element (the tick marks) still draws. That is + // exactly the reported "no range finder on this drop: ticks + // there, moving caret gone". Re-seed instead of propagating. + if (!(trueRange == trueRange) || !(sShownRange == sShownRange)) + { + if (getenv("BT_RANGE_LOG")) + DEBUG_STREAM << "[range] NaN TRAPPED (true=" << trueRange + << " shown=" << sShownRange << ") -- re-seeded to 1200\n" + << std::flush; + trueRange = 1200.0f; + sShownRange = 1200.0f; + } float maxStep = (float)dt * 500.0f; if (maxStep < 0.0f) maxStep = -maxStep; float step = trueRange - sShownRange; @@ -6350,6 +6384,22 @@ void if (step < -maxStep) step = -maxStep; sShownRange += step; BTSetHudTargetRange((Scalar)sShownRange); + // The caret's ACTUAL input had NO diagnostic anywhere: BT_RANGE_LOG + // instruments the PICK (#4), and [target]'s `range=` is a separate + // locally-recomputed Sqrt in the weapon-range check -- so a field + // log could neither confirm nor refute a dead caret. Fixed. + if (getenv("BT_RANGE_LOG")) + { + static float sRlog = 0.0f; + sRlog += (float)dt; + if (sRlog >= 1.0f) + { + sRlog = 0.0f; + DEBUG_STREAM << "[range] caret input shown=" << sShownRange + << " true=" << trueRange << " lock=" << gBTHudLockState + << "\n" << std::flush; + } + } } // BT_RANGE_LOG (Gitea #4 VERDICT instrumentation -- uncommitted diag): diff --git a/scratchpad/night13/range147.py b/scratchpad/night13/range147.py new file mode 100644 index 0000000..bba7794 --- /dev/null +++ b/scratchpad/night13/range147.py @@ -0,0 +1,88 @@ +"""Night 13: rewrite #147's body (the inline attempt was mangled by the shell). ASCII only.""" +import sys +sys.path.insert(0, r"C:\git\bt411\scratchpad\night7") +import gitea + +BODY = """**Reported by Oracle (2026-08-06 session):** "no range finder on this drop" + a screenshot. +Symptom confirmed by eye: **the ladder tick marks were present, the moving caret was not.** +Intermittent -- one drop, not every drop. He was the only tester who hit it. + +## What it is NOT + +Ruled out from the four field logs: + +* **Not a Steam-host bug.** Oracle *was* hosting (`[lobby] host: lobby up` / `GO with N member(s)` + appears in his log and no other), but the host path is uninvolved: the range **computed** fine on + his node (1806 nonzero `[target] range=` samples) and the reticle **built** fine on every drop + (`[hud] reticle built: 7 weapon pip(s) registered` -- 6 drops, 6 builds). +* **Not the Thor chassis.** He flew a Thor, which is why the report reads as chassis-specific -- but + a second tester flew a Thor the same night *without* hosting and reported nothing, and the range + ladder is drawn by the shared `HudSimulation` / `BTReticleRenderable`, not per-chassis cockpit + content. No Thor asset-missing warnings in any log (only the known wreck-model fallbacks). +* **Not his destroyed HUD.** His HUD subsystem *was* destroyed twice -- the only tester to reach + `condition 0` all night -- but only for ~11 s and ~26 s, repaired by respawn each time. And a + destroyed HUD costs you the fire-control LOCK (own host zone >= 0.75 damage, `_DAT_004b7ec4`), + not the caret. + +## The defect + +`sShownRange` -- the value the caret ultimately binds to -- is a **function-level static** in +mech4.cpp's targeting step. One cell for the whole process, shared by every mech, carried across +drops, never re-seeded. Its update: + +```c +float step = trueRange - sShownRange; +if (step > maxStep) step = maxStep; +if (step < -maxStep) step = -maxStep; +sShownRange += step; +``` + +**NaN is absorbing here, and the clamps cannot catch it** -- `step > maxStep` and `step < -maxStep` +are BOTH false for NaN. So a single poisoned frame makes `sShownRange` NaN and it stays NaN *for +the life of the process*. + +The consumer repeats the same mistake -- `BTReticleRenderable::Draw`: + +```c +Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f; +if (range < minRange) range = minRange; // false for NaN +if (range > maxRange) range = maxRange; // false for NaN +Scalar frac = (range - minRange) / (maxRange - minRange); +``` + +NaN flows into `AddPoint` / `ConcatMatrix`, so the caret and its bar become **degenerate geometry +and stop rendering** -- while every static reticle element, tick marks included, still draws. + +That is exactly the reported symptom: ticks present, caret gone, sticky until relaunch. + +## Why no log could confirm it + +**The caret's actual input had no diagnostic anywhere.** `BT_RANGE_LOG` instruments the *pick* +(#4), and the `range=` field in `[target]` is a separate, locally recomputed +`Sqrt(ddx*ddx + ddy*ddy + ddz*ddz)` inside the weapon-range check -- it is neither `sShownRange` +nor `gBTHudRangeStorage`. Grepping the field logs for NaN returns nothing because **the poisoned +variable was never printed.** Absence of the signal was not evidence of absence. + +## Fixed (unreleased) + +1. **Re-seed on mech change** -- a new drop starts at the binary's 1200 default instead of + inheriting the previous mission's slid value. Deliberately does NOT fire on respawn: that + reuses the entity, and the binary does not reset the readout on respawn either. +2. **NaN trap at the producer** -- re-seed to 1200 rather than propagate. +3. **NaN-safe clamp at the consumer** -- test `x == x` first, and fall back to the authentic + no-target peg (1200) instead of rendering nothing. +4. **`BT_RANGE_LOG` now prints the caret's real input** -- `[range] caret input shown=... true=... + lock=...` at 1 Hz, plus a `[range] NaN TRAPPED` receipt. + +## Status [T3 -- honest] + +The defect and the symptom match exactly, and the fix is correct on its own merits: a +process-lifetime static feeding unguarded float geometry is a bug regardless of who reported what. + +But **the causal link to Oracle's report is INFERENCE, not proof.** The NaN source is unidentified +and the failure has not been reproduced. What would settle it: fly with `BT_RANGE_LOG=1` -- if the +caret dies again, the log now names the frame it happened on. +""" + +gitea.call("/issues/147", method="PATCH", payload={"body": BODY}) +print("rewrote #147 body")