Searchlight + ThermalSight ToggleLamp WIRED (#61) -- and a swapped table attribution ROOT-CAUSED, retracting a false "1995 latent bug"

Both classes' Receiver::MessageHandlerSet were default-constructed blackholes
(input-path audit systemic cause #1): entryCount 0, no parent chain, Find()
returns NullHandler for every id, Receive drops silently.  The new unhandled-
message trace printed it on the first press:

    [btntest] PRESS 0x14 at poll 400
    [msg] UNHANDLED: Searchlight has no handler for message id 3

Each class now has a GetMessageHandlers() function-local static chained to
PowerWatcher's (which name-resolves through HeatWatcher/MechSubsystem to
Receiver's empty ROOT set, so id 3 does NOT collide with HeatSink's
ToggleCooling), plus a correctly-typed forwarder -- Receiver::Handler is
void(const Message*) while the decomp shape is Logical(Message&), so a cast
would be UB that merely happens to work on x86 __thiscall.  DefaultData
re-pointed off the dead empty sets.  MessageArg now reads the NAMED
ReceiverDataMessageOf<int>::dataContents with a static_assert locking it to the
binary's message+0xC (was a raw offset read -- databinding rule).

ROOT CAUSE of a long-standing misattribution: @004b860c is **ThermalSight's**
ToggleLamp (table @0x51120C), NOT Searchlight's.  The two TUs emit parallel
shared-data blocks with identical stride (msg entry, +0x5C attrs, +0x84
Performance triple); what pins each entry to its TU is that "ToggleLamp" is NOT
pooled across them -- two copies exist, each emitted immediately before its own
class-name string ("Searchlight"@0x51144B, "ThermalSight"@0x511475).
[T1: reference/decomp/section_dump.txt:69661-69711]

Searchlight's own handler is @004b838c, which sits in a Ghidra EXPORT GAP (#60).
RECOVERED by raw disassembly of content/BTL4OPT.EXE (scratchpad/dis838c.py, the
EjectAmmo technique) -- and it corrected two things I had inferred wrong:
  * NO ControlsAllowLights/+0x25C novice gate.  Searchlight tests the press
    alone; that lock is ThermalSight-only.  A NOVICE pilot CAN work the lamp.
  * `or word ptr [this+0x18],1` sits AT the jle target -> the graphics-dirty bit
    (updateModel == ForceUpdate(), per #59) is raised UNCONDITIONALLY.

Consequence: the "ORIGINAL 1995 LATENT BUG -- the searchlight can never light"
claim is RETRACTED.  It compared Searchlight's Performance (@004b841c, reads
requestedOn@0x1E0) against ThermalSight's toggle (0x1DC) -- two different
classes -- and so invented a missing 0x1DC->0x1E0 bridge.  Every sibling toggles
the field its own Performance reads.  The searchlight DID light in the arcade,
the searchlight->fog swap was NOT inert there, and building PullFogRenderable is
FAITHFUL rather than a designer-intent deviation (the 2026-07-13 "left as-is"
decision is void; the sim needs no repair).

Verified live, real click seam (BT_BTNTEST):
  0x14 -> [light] requested ON -> reported lightState 0 -> 1     (lamp lights)
  0x12 -> [light] thermal sight requested ON -> thermalActive 0 -> 1
UNHANDLED lines for both classes gone; 40 LNK2019 unchanged (the pre-existing
CreateStreamedSubsystem + Entity__SharedData::DefaultData families only).

Swept the misattribution out of searchlight.cpp/.hpp, thermalsight.cpp/.hpp,
hud.cpp:282 (it had claimed @00511180/@004b838c as HUD's), btplayer.cpp's +0x25C
consumer list, context/{decomp-reference,subsystems,rendering,open-questions,
pod-hardware,experience-levels}.md, docs/{GLASS_COCKPIT,INPUT_PATH_AUDIT}.md.
checkctx.py CLEAN.

Still open (documented, not fixed): ThermalSight has no visible IR effect
(ToggleGlobalThermalVision is a marked no-op, pvision unported, IsLocallyViewed
returns False); ThermalSight publishes "LightState"->0x1D8 where the binary has
"LightOn"->0x1D8 and "LightState"->0x1E0; Searchlight's commandedOn@0x1DC has no
identified role and measured 21 live, hinting our SubsystemResource +0x28 differs
from the binary's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-25 10:46:59 -05:00
co-authored by Claude Opus 5
parent 62513b2f8a
commit f3bdb3b85a
17 changed files with 667 additions and 76 deletions
+18 -3
View File
@@ -263,9 +263,24 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
template: `Condenser::MoveValveMessageHandler` (id 4, same novice guard). See [[experience-levels]].
- **Generator** @0x50FB90: {4, "ToggleGeneratorOnOff"→@004b1ed0} (streamed buttons 0x1A-0x1D →
GeneratorA-D — unreconstructed).
- **Searchlight** @0x51120C: {3, "ToggleLamp"→@004b860c}; PowerWatcher/ThermalSight-side variant
@0x51117C: {3, "ToggleLamp"→@004b838c}. (Reconstruction has Searchlight::ToggleLamp but the
MessageHandlers set is default-constructed EMPTY — unwired.)
- **Searchlight** @0x51117C: {3, "ToggleLamp"→**@004b838c**}; **ThermalSight** @0x51120C: {3,
"ToggleLamp"→**@004b860c**}. ⚠ **CORRECTED 2026-07-25 (#61)** — these were swapped here (and in
`searchlight.cpp:51`, `hud.cpp:282`), which propagated a false "1995 latent bug" across four
context files; see the WAVE-4 note in [[subsystems]]. The two TUs emit PARALLEL shared-data blocks
with identical stride — msg entry, +0x5C attribute entries, +0x84 Performance triple:
| TU | msg entry | handler | attributes | Performance |
|---|---|---|---|---|
| Searchlight | @0x51117C {3,"ToggleLamp"@0x511440} | @004b838c | LightOn→0x1D8, LightState→0x1E4 | @004b841c (reads **0x1E0**) |
| ThermalSight | @0x51120C {3,"ToggleLamp"@0x51146A} | @004b860c | LightOn→0x1D8, LightState→0x1E0 | @004b8648 (reads **0x1DC**) |
What pins each entry to its TU: **"ToggleLamp" is NOT pooled across the two** (two copies exist)
and each sits immediately before its own class-name string — `"Searchlight"@0x51144B` and
`"ThermalSight"@0x511475`. [T1: `reference/decomp/section_dump.txt:69661-69711`]
**@004b838c is a Ghidra EXPORT GAP (#60 list)** — `functions_index.tsv` jumps straight to 4b83b8.
**RECOVERED 2026-07-25 by raw disasm** of `content/BTL4OPT.EXE` (`scratchpad/dis838c.py`, the
EjectAmmo technique) [T1]: `if (msg+0xC > 0) [this+0x1E0] = ([this+0x1E0]==0);` then
`or word ptr [this+0x18],1` **unconditionally** (== `ForceUpdate()`, per #59). Note it has **NO**
novice/+0x25C gate — that is ThermalSight-only; see [[experience-levels]].
**Both sets WIRED 2026-07-25 (#61)**; both were default-constructed blackholes before.
- **Myomers-class** @0x51158C: {9, "ToggleSeekVoltage"→@004b8a48}; **energy-weapon** variant
@0x511DB8: {0xb, "ToggleSeekVoltage"→@004ba478}; **ammo-weapon** @0x512210: {0xb,
"EjectAmmo"→@004bb9b8}. **Both ToggleSeekVoltage handlers WIRED 2026-07-21 (issue #19)**:
+9 -4
View File
@@ -43,8 +43,12 @@ then unconditionally `+0x264 = +0x268 = mission->advancedDamageOn(+0xf0)` (both
## What each flag gates (binary consumers, all reached via `mech+0x190` → BTPlayer) [T1]
- **+0x25c — "sim live", off only for novice.** Consumers: ballistic jam roll `CheckForJam`
@4bbfcc early-returns NO-JAM when 0 (projweap.cpp calls this `LiveFireEnabled`); Searchlight
`ToggleLamp` @4b860c (searchlight.cpp `ControlsAllowLights`); PoweredSubsystem message handler
@4bbfcc early-returns NO-JAM when 0 (projweap.cpp calls this `LiveFireEnabled`); **ThermalSight**
`ToggleLamp` @4b860c (thermalsight.hpp `ControlsAllowLights`) — ⚠ **CORRECTED 2026-07-25 (#61):
this was listed as *Searchlight's* gate. It is not. Raw disassembly of Searchlight's real handler
@004b838c (a Ghidra export gap) shows it gates on the PRESS ALONE and never reads +0x25C, so a
NOVICE pilot CAN work the searchlight; only the thermal sight is locked** [T1, `scratchpad/dis838c.py`];
PoweredSubsystem message handler
@4b0efc (powersub.cpp's "message-manager short-event flag" — same flag, misattributed); the mech
combat-ineffective evaluation (part_012.c:9364: mech state ∈ {3,4} && novice is an extra OR-term
setting `mech+0x414`). The `#if 0` block in surviving PPC.CPP:63-95 (novice PPCs deal no damage)
@@ -105,8 +109,9 @@ The wiring landed (uncommitted during the coordination pause; issue #2):
consumers route mech→`GetPlayerLink()`→named member; NULL player reads permissive [T3].
- **Gates unified**: `HeatSink::HeatModelActive()` (heat.hpp; also serves mislanch/projweap/
heatfamily call sites) + `Myomers::OwnerAdvancedDamage()` (myomers.hpp) → the +0x260 bridge;
`ProjectileWeapon::LiveFireEnabled()` (projweap.cpp) + `Searchlight::ControlsAllowLights()`
(searchlight.hpp) → the +0x25c bridge; `PoweredSubsystem::HandleMessage` @4b0efc (powersub.cpp)
`ProjectileWeapon::LiveFireEnabled()` (projweap.cpp) + `ThermalSight::ControlsAllowLights()`
(thermalsight.hpp) → the +0x25c bridge (**not** `Searchlight::ControlsAllowLights()`, which #61
showed is unused — its handler has no novice gate; the accessor is retained but dead); `PoweredSubsystem::HandleMessage` @4b0efc (powersub.cpp)
→ not-novice + the +0x25c bridge (the old "message-manager short-event flag" misattribution and
its dead `BT_GetMessageManager` guard are gone).
- **FUN_004ac9c8 family corrected**: `MechSubsystem::IsDamaged` (mechsub.cpp — was a live
+20 -18
View File
@@ -180,24 +180,26 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
@004bb9b8 (msg 0xb ammo weapons), and MechRIOMapper's own Keypress @004d2514 (id 0x19).
Filed as the Gitea "glass panel: unreconstructed button handlers backlog" issue; tables in
[[decomp-reference]] §2026-07-20; census in `docs/GLASS_COCKPIT.md`.
- **Searchlight-driven fog swap — DEFERRED, and it is an ORIGINAL 1995 LATENT BUG (task #63,
2026-07-13) [T1 decomp].** The arcade swaps fog between `fog=` (lights on) and `nosearchlightfog=`
(off) via `PullFogRenderable` watching the Searchlight's `lightState`. It never fires — in the PORT
because `PullFogRenderable` is never constructed, AND in the BINARY ITSELF because the Searchlight
never lights: `ToggleLamp` (@004b860c) toggles `commandedOn` @0x1DC but `SearchlightSimulation`
(@004b841c) reads `requestedOn` @0x1E0, which is zeroed by the ctor and never written — no
0x1DC→0x1E0 bridge exists (self-consistent sibling ThermalSight reads the field it toggles, 0x1DC).
So `lightState` is perpetually 0. The port reproduces this faithfully (searchlight.cpp:189).
**DECISION: left as-is + documented (faithful to the buggy original); port keeps the static
lights-ON `fog=`.** A working swap is a DESIGNER-INTENT deviation, fully scoped if ever wanted:
(1) sim reads `commandedOn` (mirror ThermalSight); (2) construct `PullFogRenderable` at
btl4vid.cpp `MakeMechRenderables` reticle-build/inside pass (== arcade part_014.c:5173, Dynamic,
bound per Searchlight `lightState` via a new `LightStatePtr()` accessor); (3) a toggle input (the
authentic cockpit button-5→`ToggleLamp` dispatch is a controls-family reconstruction; a dev key
stands in). `ControlsAllowLights()` (searchlight.hpp) is WIRED since issue #2 to the
`player+0x25c` not-novice experience flag via the BTPlayerExperienceSimLive bridge
([[experience-levels]]); the consumer (ToggleLamp) still needs the cockpit button dispatch.
See [[rendering]] fog section. Verified inert live: BT_FOG_LOG shows zero `SetFogStyle(2/3)`.
- **Searchlight-driven fog swap — STILL DEFERRED, but the "ORIGINAL 1995 LATENT BUG" premise is
❌ RETRACTED (2026-07-25, #61).** The arcade swaps fog between `fog=` (lights on) and
`nosearchlightfog=` (off) via `PullFogRenderable` watching the Searchlight's `lightState`.
**The old entry claimed the 1995 binary itself could never light the lamp. That was wrong** — it
compared Searchlight's Performance (@004b841c, reads `requestedOn`@0x1E0) against **ThermalSight's**
`ToggleLamp` (@004b860c`0x1DC`), i.e. two different classes, and so invented a missing
"0x1DC→0x1E0 bridge". Searchlight's real handler is **@004b838c** (table @0x51117C) and toggles
`0x1E0`, the field its own Performance reads. [T1 attribution: `section_dump.txt:69661-69711`,
un-pooled `"ToggleLamp"` strings adjacent to their own class names. Body: @004b838c sits in a Ghidra
export gap (#60) but was **recovered by raw disasm** (`scratchpad/dis838c.py`) — it toggles 0x1E0 and
carries no novice gate. All T1.]
Searchlight's handler set is now WIRED and **verified live**: pad `0x14``requestedOn 0→1`
`lightState 0→1`. So the sim needs **no repair** and the previous "DECISION: faithful to the buggy
original" is void — a working fog swap is now plain FAITHFUL reconstruction. Remaining work is a
single item: construct `PullFogRenderable` at btl4vid.cpp `MakeMechRenderables` reticle-build/inside
pass (== arcade part_014.c:5173, Dynamic, bound per Searchlight `lightState` via a new
`LightStatePtr()` accessor). `ControlsAllowLights()` is WIRED since issue #2 to the `player+0x25c`
not-novice experience flag via the BTPlayerExperienceSimLive bridge ([[experience-levels]]).
See [[rendering]] fog section. (The pre-#61 "verified inert live: BT_FOG_LOG zero `SetFogStyle(2/3)`"
observation still holds — reason (1), the un-constructed renderable, remains.)
- **Factory capability-roster loops 2-4 are STILL DEAD (task #57 discovery).** mech.cpp's
post-roster loops add to `heatableSubsystems`(0x51155c)/`weaponRoster`(0x511830)/
`damageableSubsystems`(0x50e4fc) through the local `SubProxy` stub whose `IsDerivedFrom`
+7 -3
View File
@@ -55,9 +55,13 @@ the per-condenser VALVE buttons** (MoveValve, Cond1-6 — work), **0x1A-0x1D = G
ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — DEAD until
reconstructed). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest`
(0x1a @0049fa00 — the manual's CROUCH button)**, **0x28 → Mech `BalanceCoolant` (0x16
@0049f728)**, 0x12 → ThermalSight toggle (id 3), **0x14 → Searchlight + Searchlight2
`ToggleLamp` (id 3 @004b860c; body reconstructed but the handler-set is default-constructed
EMPTY — unwired)** — all currently dead; an unhandled message is SILENTLY ignored
@0049f728)**, **0x12 → ThermalSight `ToggleLamp` (id 3, table @0x51120C fn @004b860c)** and **0x14 →
Searchlight + Searchlight2 `ToggleLamp` (id 3, table @0x51117C fn @004b838c)** — ✅ **BOTH WIRED
2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 →
`lightState 0→1`, 0x12 → `thermalActive 0→1`). ⚠ The fn addresses were **swapped** in this file
before #61 (@004b860c is ThermalSight's) — see [[decomp-reference]] for the table evidence — and
that swap had propagated a false "1995 latent bug" (retracted, [[subsystems]] WAVE 4).
An unhandled message is SILENTLY ignored
(Receiver::Receive finds no handler and does nothing — dead buttons produce NO log).
0x10/0x11 = map ZoomIn/Out, 0x15 = CycleDisplayMode, 0x18 = CycleControlMode (#6). Buttons
with NO streamed mapping authored (authentically inert): 0x16/0x17/0x19/0x1E/0x1F/0x38-0x3E.
+17 -12
View File
@@ -73,18 +73,23 @@ DECLOUDS must stay in the sky pass). Authored TEXTURE **SCROLL** (BMF TEXTURE ta
`SetFogStyle(searchLightOn/OffFogStyle)`). TWO independent reasons it never fires:
(1) **Port:** `PullFogRenderable` is NEVER CONSTRUCTED (grep: only its .h decl + .cpp def; no `new`);
`searchLightOn/Off` is called from nowhere else (BT_FOG_LOG: over 22s only `SetFogStyle(0)`, zero 2/3).
(2) **Original 1995 LATENT BUG [T1, decomp]:** even if wired, the Searchlight never lights —
`ToggleLamp` (@004b860c) toggles `commandedOn` @0x1DC (part_013.c:6087) but `SearchlightSimulation`
(@004b841c) reads `requestedOn` @0x1E0 (part_013.c:5978), which the ctor zeroes and NOTHING ever writes;
no bridge 0x1DC→0x1E0 exists anywhere in the binary. So `lightState`@0x1D8 is perpetually 0. The
self-consistent sibling **ThermalSight** reads the SAME field it toggles (0x1DC) — the tell that
Searchlight's sim reads the wrong slot. The port reproduces this faithfully (searchlight.cpp:189 reads
requestedOn). **DECISION (2026-07-13): left as-is + documented** (faithful to the buggy original); the
port keeps the static lights-ON `fog=` values. To make the swap WORK is a DESIGNER-INTENT fix that
DEVIATES from the shipped binary — repair the sim to read `commandedOn` (mirror ThermalSight) + construct
`PullFogRenderable` at btl4vid.cpp `MakeMechRenderables` (the reticle-build/inside pass, == arcade
part_014.c:5173, ExecutionType Dynamic, bound to each Searchlight `lightState`) + a toggle input. See
[[open-questions]]. Core per-map/time/weather fog is unaffected.
(2) ~~**Original 1995 LATENT BUG**~~ — ❌ **RETRACTED 2026-07-25 (#61). There is no 1995 bug.** The
claim rested on a **swapped table attribution**: @004b860c is **ThermalSight's** `ToggleLamp`
(table @0x51120C), not Searchlight's, so pitting its `0x1DC` write against Searchlight's
`SearchlightSimulation` (@004b841c, reads `requestedOn`@0x1E0) compared two different classes and
manufactured a missing "0x1DC→0x1E0 bridge". Searchlight's own handler is **@004b838c** (table
@0x51117C) and toggles the field its own Performance reads, `0x1E0` — exactly as self-consistently as
ThermalSight does with `0x1DC`. [T1 attribution: un-pooled `"ToggleLamp"` strings sit adjacent to
their own class names, `section_dump.txt:69661-69711`. Body: @004b838c sits in a Ghidra export gap
(#60) but was **recovered by raw disasm** (`scratchpad/dis838c.py`) — it toggles 0x1E0. All T1.]
**The searchlight DID light in the arcade, and the fog swap was NOT inert there.** Searchlight's
handler is now wired (#61) and **verified live**: pad `0x14``requestedOn 0→1``lightState 0→1`.
So the ONLY remaining blocker is (1), the un-constructed `PullFogRenderable` — and building it is
**FAITHFUL, not a designer-intent deviation** (the old "DECISION 2026-07-13: left as-is" is void;
the sim needs NO repair). Remaining work: construct `PullFogRenderable` at btl4vid.cpp
`MakeMechRenderables` (the reticle-build/inside pass, == arcade part_014.c:5173, ExecutionType
Dynamic, bound to each Searchlight `lightState`). See [[open-questions]]. Core per-map/time/weather
fog is unaffected.
- **Projection / stencil (task #55):** the BTDPL.INI `viewangle=60` is the **HORIZONTAL** FOV —
authentic 60×47 frustum at 4:3; fovY derived via `BTFovYFromHorizontal` (L4VIDEO.cpp), applied at
all 5 projection sites (it was mis-applied as vertical). The device depth-stencil is now
+23 -5
View File
@@ -41,11 +41,29 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
- **WAVE 3** — power bus (Generator/PoweredSubsystem) + Emitter/PPC fire-path (end-to-end fire; heat
conducts to the central sink via the linked-sink roster).
- **WAVE 4** — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes).
- **Searchlight has an ORIGINAL latent bug [T1, task #63]:** `SearchlightSimulation` (@004b841c)
reads `requestedOn`@0x1E0 (never written) while `ToggleLamp` (@004b860c) toggles `commandedOn`@0x1DC
— no bridge, so `lightState`@0x1D8 is perpetually 0 (lamp never lights). Present in the 1995 binary
(sibling ThermalSight reads the field it toggles, 0x1DC); the port reproduces it faithfully. Left
as-is by decision; it makes the searchlight→fog swap inert. See [[open-questions]] + [[rendering]] fog.
- **Searchlight + ThermalSight buttons WIRED 2026-07-25 (#61)** — both classes' handler sets were
default-constructed blackholes (systemic cause #1, `docs/INPUT_PATH_AUDIT.md`). Each now chains
`PowerWatcher::GetMessageHandlers()` with its own id-3 `ToggleLamp`. **Verified live**: pad `0x14`
→ `requestedOn 0→1` → `lightState 0→1`; pad `0x12` → `requestedOn 0→1` → `thermalActive 0→1`.
- ❌ **RETRACTED: the "ORIGINAL 1995 latent bug" (old task #63) NEVER EXISTED.** It was an artifact of
a **swapped table attribution**: the claim compared Searchlight's Performance (@004b841c, reads
`requestedOn`@0x1E0) against **ThermalSight's** `ToggleLamp` (@004b860c, toggles `0x1DC`) and
concluded "no 0x1DC→0x1E0 bridge, so the lamp can never light". @004b860c is not Searchlight's.
Searchlight's own handler is **@004b838c** (table @0x51117C) and — like every sibling — toggles the
field its OWN Performance reads, `requestedOn`@0x1E0. Each class is internally self-consistent.
[T1 throughout. Attribution: un-pooled `"ToggleLamp"` strings adjacent to their own class names,
`section_dump.txt:69661-69711`. Body: @004b838c is a Ghidra EXPORT GAP (#60) but was **recovered by
raw disasm** of `content/BTL4OPT.EXE` (`scratchpad/dis838c.py`) — it toggles 0x1E0, raises
`updateModel` (+0x18) unconditionally, and has **NO novice gate** (ThermalSight-only).] Consequence: the searchlight→fog swap was **NOT** inert in the arcade, and
making it work in the port is FAITHFUL, not a designer-intent deviation. See [[open-questions]] +
[[rendering]] fog, both corrected.
- ⚠ Still open on Searchlight: `commandedOn`@0x1DC (ctor-seeded from subsystem resource +0x28,
@004b84dc) has **no identified role** — it is neither the toggle target nor read by @004b841c, and
it measured **21** live (not a boolean), which hints our SubsystemResource +0x28 ≠ the binary's.
- ⚠ ThermalSight's published attributes are mis-named: we publish `"LightState"→thermalActive@0x1D8`,
but the binary's table @0x511268 has **`"LightOn"`(id 3)→0x1D8** and `"LightState"`(id 4)→**0x1E0**
(the alarm). A gauge binding `ThermalSight/LightOn` currently misses. Not yet changed (gauge-binding
change needs its own verification pass).
- **WAVE 5** — Torso (aim twist/elevation; joint-I/O reconstructed; the BLH record disables torso →
faithfully no visible twist).
- **WAVE 6** — Myomers (mover-coupled; structural un-stub is INERT — no live mover/heat coupling).