From f3bdb3b85abdf105d312a0355a39b355bcc5611c Mon Sep 17 00:00:00 2001 From: arcattack Date: Sat, 25 Jul 2026 10:46:59 -0500 Subject: [PATCH] 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::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) --- context/decomp-reference.md | 21 +++- context/experience-levels.md | 13 ++- context/open-questions.md | 38 ++++---- context/pod-hardware.md | 10 +- context/rendering.md | 29 +++--- context/subsystems.md | 28 +++++- docs/GLASS_COCKPIT.md | 7 +- docs/INPUT_PATH_AUDIT.md | 6 +- game/reconstructed/btplayer.cpp | 4 +- game/reconstructed/hud.cpp | 13 ++- game/reconstructed/searchlight.cpp | 142 ++++++++++++++++++++++++++-- game/reconstructed/searchlight.hpp | 75 ++++++++++++--- game/reconstructed/thermalsight.cpp | 81 +++++++++++++++- game/reconstructed/thermalsight.hpp | 54 +++++++++++ scratchpad/dis838c.py | 50 ++++++++++ scratchpad/lamptest.py | 86 +++++++++++++++++ scratchpad/thermtest.py | 86 +++++++++++++++++ 17 files changed, 667 insertions(+), 76 deletions(-) create mode 100644 scratchpad/dis838c.py create mode 100644 scratchpad/lamptest.py create mode 100644 scratchpad/thermtest.py diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 86dcef9..a8bf1d6 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -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)**: diff --git a/context/experience-levels.md b/context/experience-levels.md index c5bcf97..b109a15 100644 --- a/context/experience-levels.md +++ b/context/experience-levels.md @@ -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 diff --git a/context/open-questions.md b/context/open-questions.md index cfa0773..0ddf4c1 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -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` diff --git a/context/pod-hardware.md b/context/pod-hardware.md index fcbb951..2747f24 100644 --- a/context/pod-hardware.md +++ b/context/pod-hardware.md @@ -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. diff --git a/context/rendering.md b/context/rendering.md index 7ebd645..9e0408b 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -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 diff --git a/context/subsystems.md b/context/subsystems.md index dfb5ae9..c362947 100644 --- a/context/subsystems.md +++ b/context/subsystems.md @@ -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). diff --git a/docs/GLASS_COCKPIT.md b/docs/GLASS_COCKPIT.md index 3d89f3d..a3a2b42 100644 --- a/docs/GLASS_COCKPIT.md +++ b/docs/GLASS_COCKPIT.md @@ -188,9 +188,10 @@ So: **0x2C = the COOLANT FLUSH button** (Reservoir InjectCoolant, WORKS), **0x2F 0x2A/0x29 = per-condenser VALVE buttons** (MoveValve, WORK), **0x1A-0x1D = Generator A-D ON/OFF** (`ToggleGeneratorOnOff` id 4, table @0050fb90 fn @004b1ed0 — DEAD, unreconstructed). Also new: 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 `Searchlight:: -MessageHandlers` is default-constructed EMPTY, so it never fires: the quick win). Weapon +**BalanceCoolant** (0x16 @0049f728), 0x12 → ThermalSight **ToggleLamp** (id 3, table +@0x51120C fn @004b860c), 0x14 → Searchlight + Searchlight2 **ToggleLamp** (id 3, table @0x51117C +fn **@004b838c**) — ✅ both WIRED 2026-07-25 (#61); the two fn addresses were swapped here before +that (see `context/decomp-reference.md`). Weapon Eng-page extras: msg 0x3 = **ToggleCooling** (HeatableSubsystem @0050e41c fn @004ad6f8, unwired), msg 0xb = **ToggleSeekVoltage** (energy @004ba478) / **EjectAmmo** (ammo weapons @004bb9b8), Myomers msg 9 = ToggleSeekVoltage (@004b8a48; body exists, unwired). Unhandled messages are diff --git a/docs/INPUT_PATH_AUDIT.md b/docs/INPUT_PATH_AUDIT.md index e09e66d..2e2ef74 100644 --- a/docs/INPUT_PATH_AUDIT.md +++ b/docs/INPUT_PATH_AUDIT.md @@ -79,8 +79,8 @@ Ranked by impact. **Reverse thrust `0x3F` (key LALT / pad B) is FIXED** and conf | 8 | **Any clickable button latches if the mouse-up is lost** (worst on `0x3F` reverse, `0x40` trigger) | A momentary click always releases | Neither WndProc handles `WM_CAPTURECHANGED` or `WM_KILLFOCUS` — grep over `engine/` + `game/` returns **zero** hits. Alt-tab or a system dialog between press and release leaves `gCkPressed` set and `SetScreenButton(a,0)` never runs | **M** — trap: `btl4main.cpp:137` calls `ReleaseCapture()` itself, which re-enters `WM_CAPTURECHANGED`; guard on `gCkPressed != -1` (it is cleared before `ReleaseCapture`) | Click-hold `0x3F`, alt-tab, release outside the window: `BT_PAD_LOG=1` shows no release line and `rev` stays 1 | | 9 | **Every HOTAS / flight stick / rudder** (whole `joydev/joyaxis/joybutton/joyhat` grammar) | Drive the pod channels from a DirectInput device | `content/bindings.txt` is 89 lines and has **zero** joy rows; the joy poll is gated on `joyAxisBindingCount>0 \|\| …` (`L4PADRIO.cpp:775-777`) so `BTJoyInit/BTJoyPoll` are never called. `PadBindingProfile::Load` writes the default only when the file is **absent** (`L4PADBINDINGS.cpp:708-713`), and the file is gitignored (`.gitignore:23`) — so the zip ships **this machine's stale file** | **S** now (ship without it / refresh it) · **M** later (version marker + additive merge) | `content/jstest.log`: `bindings loaded: 40 keys, 10 pad buttons, 5 pad axes, 0 joy axes…`; no `[joy]` line exists in any log in `content/` | | 10 | **`joyconfig.bat` re-runs the wizard after it finishes** | Run once, then play | `BT_JOYCONFIG` is never cleared (`game/btl4main.cpp:287-291`), and the front end relaunches the exe inheriting the environment (`btl4console.cpp:138-174` clears only `BT_FE_EGG/PODS/SECS/LOOP`) → the mission process re-enters the wizard and blocks on `_getch()` behind the 3D window | **S** (1 line) | Run `joyconfig.bat`, finish it, watch for a second `=== BattleTech joystick setup ===` banner | -| 11 | **`0x14`** Searchlight (+Searchlight2) | Turn the light on for night maps | `Receiver::MessageHandlerSet Searchlight::MessageHandlers;` default-constructed (`searchlight.cpp:50-51`, used `:72`) — the `ToggleLamp` body at `:158` is unreachable. **And wiring it changes nothing visible**: the 1995 binary writes `commandedOn@0x1DC` while the simulation reads `requestedOn@0x1E0` (`open-questions.md:183-200`), faithfully preserved at `searchlight.cpp:189` | **S** to wire (no visible payoff) · **L** to make the light work (needs a fidelity decision) | After wiring, `ToggleLamp` logs twice per press and `lightState` still stays 0 — that is the correct, faithful outcome. **`docs/GLASS_COCKPIT.md:192-194` calling this "the quick win" is misleading** | -| 12 | **`0x12`** Thermal sight | IR view | Two layers dead: `ThermalSight::MessageHandlers` default-constructed (`thermalsight.cpp:52-53`) **and** no body — `ToggleGlobalThermalVision() {}` is an explicit no-op (`thermalsight.hpp:153`), plus the thermal render path is unported | **L** | Not testable until both land | +| 11 | **`0x14`** Searchlight (+Searchlight2) | Turn the light on for night maps | ✅ **FIXED 2026-07-25 (#61).** `Searchlight::MessageHandlers` was default-constructed, so the `ToggleLamp` body was unreachable; it now chains `PowerWatcher::GetMessageHandlers()` with id 3. **My "wiring it changes nothing visible" verdict in this row was WRONG** — it rested on the swapped attribution this same report flags in §Corrections: @004b860c (which writes `commandedOn@0x1DC`) is **ThermalSight's** handler, not Searchlight's. Searchlight's is **@004b838c** → toggles `requestedOn@0x1E0`, the field its own Performance reads. There was never a 1995 latent bug | **S — done** | **Verified live**: `BT_BTNTEST=0x14,400,900` → `[light] requested ON` then `[light] reported lightState 0 -> 1`. The lamp lights. (`scratchpad/lamptest.py`) | +| 12 | **`0x12`** Thermal sight | IR view | ✅ **handler + body FIXED 2026-07-25 (#61).** `ThermalSight::MessageHandlers` was default-constructed AND it had no `ToggleLamp` at all — the exported body @004b860c had been transcribed into `searchlight.cpp` by mistake. Both now correct: id 3 → `requestedOn@0x1DC`, read by `ThermalSightSimulation` @004b8648. ⚠ Still **no visible IR effect**: `ToggleGlobalThermalVision()` is a marked no-op and the thermal render path (pvision palette flip) is unported, and `IsLocallyViewed()` returns False | **S done** · **L** for the renderer | **Verified live**: `BT_BTNTEST=0x12,400,900` → `[light] thermal sight requested ON` then `reported thermalActive 0 -> 1`. State machine + alarm authentic; screen unchanged (`scratchpad/thermtest.py`) | | 13 | **Numpad cluster with NumLock OFF** | Aim the torso | `L4PADRIO.cpp:583-584` reads `VK_NUMPAD8..9` verbatim; with NumLock off Windows delivers `VK_UP`/`VK_DOWN`/`VK_LEFT`/`VK_RIGHT`, which `bindings.txt:26,27,30,31` map to **throttle and pedals** — "aim up" accelerates the mech. `NUMPAD5/7/9` → `VK_CLEAR/HOME/PRIOR`, unbound | **S** (startup warning) · **M** (extended-key flag) | NumLock off, hold NUMPAD8 with `BT_MPPR_TRACE=1`: `thr=` rises | | 14 | **Duplicate rows on one axis add instead of aliasing** (`W`+`UP`+`LSHIFT` = 3× lever rate; `A`+`LEFT`+`NUMPAD7` = 3× pedal) | One lever, one rate | `slewDelta[channel] += rate*dt` per **binding** with no per-channel fold (`L4PADRIO.cpp:604-620`). End state is identical (clamped `:956-961`, spring uses max `:157-168`) — only the ramp rate multiplies, so LSHIFT+W doubles throttle rate | **S** but same file as #4 — **bundle and defer** | `BT_GAIT_TRACE=1`, hold `W` alone vs `W`+`UP`, compare lever delta/second | @@ -187,4 +187,4 @@ The whole class exists because **three failure modes produce zero output**. Each 5. **Promote the auditors' `BTL4.RES` decoder to `tools/`.** Records are 24 bytes (`controlsGroup, controlsElement, mappingType, subsystemID, modeMask, attributeID|messageID` per `CONTROLS.h:783-803`); type-19 `ControlMappingStream` per mech, validated against `content/ctrlmap.log` at 121/121 records with zero mismatches. It makes "is this button mapped on this mech, on this page?" a two-second static query instead of a per-mech log hunt — and it already produced the authoritative answer, identical on all 18 mechs: **MAPPED** = `0x00-0x15, 0x18, 0x1A-0x1D, 0x20-0x2F, 0x3F, 0x40-0x44, 0x47`; **NEVER MAPPED** = `0x16, 0x17, 0x19, 0x1E, 0x1F, 0x30-0x3E, 0x45, 0x46`. ### KB sweep owed in the same commits (per the CLAUDE.md correction mandate) -`context/pod-hardware.md:121-135` (false AddOrErase premise — also in `mechmppr.cpp:894-919`, `docs/GLASS_COCKPIT.md` §2a); `context/pod-hardware.md:50-61` (`0x28` BalanceCoolant, weapon `msg 0xb`, Myomers `msg 9` are all LIVE since 2026-07-21/23); the hat labels `0x41-0x44` (the shipped RES says `0x41` lookBehind, **`0x42` TORSO CENTER**, `0x43` lookRight, `0x44` lookLeft — fix `content/CONTROLS.MAP:20-22`, `L4PADBINDINGS.cpp:159-160`, `pod-hardware.md`); `context/open-questions.md:172-182` (census stale in both directions — `0x28`/ToggleCooling/ToggleSeekVoltage/EjectAmmo now live, hotbox never listed); `btl4mppr.cpp:1332` calls `0x200000` "ModePreset1" — the surviving T0 header names it **ModeEject** (`game/original/BT_L4/BTL4MODE.HPP:74`, which is authoritative and not yet cited by any context file); `searchlight.cpp:51`/`hud.cpp:282` table-address citations are +4 and mis-paired (real: Searchlight `@0x51117C → @004b838c`, ThermalSight `@0x51120C → @004b860c`); stale `//ButtonPanic=0x38` at `L4CTRL.cpp:112`. \ No newline at end of file +`context/pod-hardware.md:121-135` (false AddOrErase premise — also in `mechmppr.cpp:894-919`, `docs/GLASS_COCKPIT.md` §2a); `context/pod-hardware.md:50-61` (`0x28` BalanceCoolant, weapon `msg 0xb`, Myomers `msg 9` are all LIVE since 2026-07-21/23); the hat labels `0x41-0x44` (the shipped RES says `0x41` lookBehind, **`0x42` TORSO CENTER**, `0x43` lookRight, `0x44` lookLeft — fix `content/CONTROLS.MAP:20-22`, `L4PADBINDINGS.cpp:159-160`, `pod-hardware.md`); `context/open-questions.md:172-182` (census stale in both directions — `0x28`/ToggleCooling/ToggleSeekVoltage/EjectAmmo now live, hotbox never listed); `btl4mppr.cpp:1332` calls `0x200000` "ModePreset1" — the surviving T0 header names it **ModeEject** (`game/original/BT_L4/BTL4MODE.HPP:74`, which is authoritative and not yet cited by any context file); `searchlight.cpp:51`/`hud.cpp:282` table-address citations are +4 and mis-paired (real: Searchlight `@0x51117C → @004b838c`, ThermalSight `@0x51120C → @004b860c`) — ✅ **SWEPT 2026-07-25 (#61)** in `searchlight.cpp/.hpp`, `thermalsight.cpp/.hpp`, `hud.cpp:282`, `btplayer.cpp:1957`, `context/decomp-reference.md`, `context/pod-hardware.md`, `docs/GLASS_COCKPIT.md`; this swap had also manufactured a false "1995 latent bug" now retracted in `context/subsystems.md`, `context/rendering.md`, `context/open-questions.md`; stale `//ButtonPanic=0x38` at `L4CTRL.cpp:112`. \ No newline at end of file diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index 0c28a1c..a01177d 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -1954,7 +1954,9 @@ int BTPlayerRoleLocksAdvanced(void *owner_mech) // The player+0x25c "sim live" flag [T1]: 0 only for NOVICE experience. Binary // consumers reached through the owner mech's playerLink (mech+0x190): // * CheckForJam @004bbfcc (projweap.cpp LiveFireEnabled) -- no jam rolls; -// * Searchlight::ToggleLamp @004b860c (searchlight.hpp ControlsAllowLights); +// * ThermalSight::ToggleLamp @004b860c (thermalsight.hpp ControlsAllowLights) +// -- and NOT Searchlight, whose own handler @004b838c has no such gate +// (raw disasm, #61: it tests the press only), so novices keep the lamp; // * PoweredSubsystem::HandleMessage @004b0efc short-event path (powersub.cpp). // A NULL player (target dummy / unbound dev mech) reads as LIVE so dev rigs // keep today's behavior [T3]; the binary derefs unguarded. diff --git a/game/reconstructed/hud.cpp b/game/reconstructed/hud.cpp index d078894..9fe3e3f 100644 --- a/game/reconstructed/hud.cpp +++ b/game/reconstructed/hud.cpp @@ -278,9 +278,16 @@ void // HandleMessage -- @004b7780 (slot 9) // // BEST-EFFORT: the vtable installs a HUD-specific HandleMessage at @004b7780, -// but no body was captured in the recovered shards. A handler table -// @00511180 binds the message "ToggleLamp" to FUN_004b838c. Chains to the -// PowerWatcher handler otherwise. +// but no body was captured in the recovered shards. Chains to the PowerWatcher +// handler otherwise. +// +// ATTRIBUTION CORRECTION (#61, 2026-07-25): this comment used to claim "a handler +// table @00511180 binds the message ToggleLamp to FUN_004b838c" as if it were +// HUD's. It is NOT -- @0051117C is **Searchlight's** message entry +// {3, "ToggleLamp" @00511440, @004b838c}; @00511180 is merely its name+handler +// pair (the entry base is 4 bytes lower). HUD's own data ends with its +// Performance triple at @00511170 (@004b7830, HudSimulation). See +// searchlight.cpp and context/decomp-reference.md for the table evidence. // Logical HUD::HandleMessage(int message) diff --git a/game/reconstructed/searchlight.cpp b/game/reconstructed/searchlight.cpp index aec572a..17ebd07 100644 --- a/game/reconstructed/searchlight.cpp +++ b/game/reconstructed/searchlight.cpp @@ -47,8 +47,11 @@ Derivation "Searchlight" ); +// DEAD (Gitea #61): default-constructed -- entryCount 0, no parent -- so it was +// a total blackhole. Kept only because the binary symbol exists; DefaultData now +// points at GetMessageHandlers() below. Do NOT bind anything to this. Receiver::MessageHandlerSet - Searchlight::MessageHandlers; // includes { "ToggleLamp", &Searchlight::ToggleLamp } @00511210 + Searchlight::MessageHandlers; // gauge data-binding wave: publish "LightOn" -> lightState@0x1D8 (the button-5 // searchlight lamp; the empty default-constructed index resolved NOTHING). @@ -69,7 +72,7 @@ Searchlight::AttributeIndexSet Searchlight::SharedData Searchlight::DefaultData( // @00511198 &Searchlight::ClassDerivations, - Searchlight::MessageHandlers, + Searchlight::GetMessageHandlers(), // was the EMPTY MessageHandlers (blackhole) -- #61 Searchlight::AttributeIndex, Searchlight::StateCount ); @@ -148,20 +151,122 @@ Logical } //########################################################################### -// ToggleLamp -- @004b860c ("ToggleLamp" message handler) +// Message handling -- table @0051117c // -// Bound by the shared-data message table @00511210. On a positive command -// (message arg @0xC > 0) and when the host mech's controls allow it -// (mech +0xD0 -> +0x190 -> +0x25C != 0), flips commandedOn (@0x1DC). +// Gitea #61. Searchlight's handler set was default-constructed, so its +// ToggleLamp was reachable code bound to nothing: every id-3 press was dropped +// in silence. Proved live by the unhandled-message trace: +// [btntest] PRESS 0x14 at poll 400 +// [msg] UNHANDLED: Searchlight has no handler for message id 3 +// +// ATTRIBUTION CORRECTION (this file previously cited @004b860c for Searchlight's +// ToggleLamp -- that is ThermalSight's; see thermalsight.cpp). The two TUs emit +// PARALLEL shared-data blocks with identical stride (msg entry, +0x5C attribute +// entries, +0x84 performance triple): +// +// Searchlight msg @0051117c {3,"ToggleLamp"@00511440} -> @004b838c +// attrs LightOn->0x1D8, LightState->0x1E4 ; perf @004b841c +// ThermalSight msg @0051120c {3,"ToggleLamp"@0051146a} -> @004b860c +// attrs LightOn->0x1D8, LightState->0x1E0 ; perf @004b8648 +// +// The name pointers disambiguate them: "ToggleLamp" is NOT pooled across the two +// TUs (two copies exist, @00511440 and @0051146a), and each is emitted +// immediately before its own class-name string -- "Searchlight" @0051144b and +// "ThermalSight" @00511475. Our own thermalsight.cpp independently cites +// @004b8648 / PTR @00511290, which corroborates the split. +// [T1: reference/decomp/section_dump.txt lines 69661-69711] +// +// The parent link is PowerWatcher's, which -- PowerWatcher, HeatWatcher and +// MechSubsystem all declaring no set of their own -- name-resolves to Receiver's +// empty root set. That keeps id 3 unambiguous here (HeatSink's ToggleCooling is +// on a DIFFERENT chain), which the live trace confirmed before this fix landed. +// +Receiver::MessageHandlerSet& + Searchlight::GetMessageHandlers() +{ + static const Receiver::HandlerEntry entries[]= + { + MESSAGE_ENTRY(Searchlight, ToggleLamp) // id 3 @004b838c + }; + static Receiver::MessageHandlerSet messageHandlers( + ELEMENTS(entries), entries, + PowerWatcher::GetMessageHandlers() + ); + return messageHandlers; +} + +// +// The typed forwarder the table actually binds. Receiver::Handler is +// void(const Message*); ToggleLamp is the decomp's Logical(Message&) shape, so +// this adapts rather than casting. The static_assert proves our named +// dataContents member sits where the binary read the arg (message+0xC). +// +static_assert( + offsetof(ReceiverDataMessageOf, dataContents) == 0xC, + "ReceiverDataMessageOf::dataContents must sit at 0xC -- @004b860c reads message+0xC" +); + +void + Searchlight::ToggleLampMessageHandler(ReceiverDataMessageOf *message) +{ + Check(this); + Check_Pointer(message); + ToggleLamp(*message); +} + +//########################################################################### +// ToggleLamp -- @004b838c ("ToggleLamp" message handler) +// +// Bound by the shared-data message table @0051117c via the forwarder above. +// +// @004b838c is a Ghidra EXPORT GAP (#60) -- functions_index.tsv jumps straight to +// 4b83b8 -- so this was RAW-DISASSEMBLED from content/BTL4OPT.EXE, the same +// technique that recovered EjectAmmo @004bb9b8. Rig: scratchpad/dis838c.py. [T1] +// +// 004b838c push ebp / mov ebp,esp +// 004b838f mov eax,[ebp+8] ; this +// 004b8392 mov edx,[ebp+0xc] ; message +// 004b8395 mov ecx,[edx+0xc] ; message arg +// 004b8398 test ecx,ecx +// 004b839a jle 004b83b0 ; release / non-positive -> skip the toggle +// 004b839c mov edx,[eax+0x1e0] ; requestedOn +// 004b83a2 test edx,edx +// 004b83a4 sete cl +// 004b83a7 and ecx,1 +// 004b83aa mov [eax+0x1e0],ecx ; requestedOn = (requestedOn == 0) +// 004b83b0 or word ptr [eax+0x18],1 ; <-- UNCONDITIONAL (the jle lands HERE) +// 004b83b5 pop ebp / ret +// +// TWO corrections this disassembly forced on the first reconstruction: +// +// 1. **There is NO ControlsAllowLights gate.** Searchlight checks only the press +// (`msg+0xC > 0`); it never touches mech+0xD0 -> +0x190 -> player+0x25C. That +// novice lock is THERMALSIGHT-ONLY (@004b860c has it, this does not) -- so a +// NOVICE pilot CAN work the searchlight but not the thermal sight. +// 2. The `or word ptr [eax+0x18],1` sits AT the jle target, so the graphics-dirty +// bit is raised on EVERY dispatch including the release. Per #59 that 16-bit +// write to +0x18 is `updateModel` == ForceUpdate(), NOT `simulationFlags`. +// +// It toggles +0x1E0 (requestedOn) -- the field Searchlight's own Performance +// @004b841c reads -- NOT +0x1DC. (+0x1DC is ctor-seeded from the subsystem +// resource +0x28 and measured 21 live, i.e. not a boolean; nothing reads it.) // Logical Searchlight::ToggleLamp(Message &message) { - if ((0 < MessageArg(message)) && // *(message + 0xC) - ControlsAllowLights()) // *(*(owner +0xD0)+0x190)+0x25C + if (0 < MessageArg(message)) // *(message + 0xC) -- press only { - commandedOn = (commandedOn == 0); // toggle @0x1DC + requestedOn = (requestedOn == 0); // toggle @0x1E0 (read by @004b841c) + + if (getenv("BT_FIRE_LOG") || getenv("BT_HEAT_LOG")) + DEBUG_STREAM << "[light] " << GetName() << " searchlight requested " + << (requestedOn ? "ON" : "OFF") + << " (reported lightState " << lightState + << ", voltage level " << WatchedVoltageLevel() + << ", heat level " << HeatStateLevel() << ")" << std::endl; } + + SetGraphicsDirty(); // or word ptr [this+0x18],1 -- UNCONDITIONAL @004b83b0 return True; } @@ -200,9 +305,28 @@ void stateAlarm.SetLevel(lightState); // FUN_0041bbd8(this+0x1E4, lightState) + if (getenv("BT_FIRE_LOG") || getenv("BT_HEAT_LOG")) + { + static int announced = 0; // one-shot: proves the Performance is INSTALLED + if (!announced) + { + announced = 1; + DEBUG_STREAM << "[light] SearchlightSimulation RUNNING (commandedOn " + << commandedOn << ", requestedOn " << requestedOn + << ", lightState " << lightState << ")" << std::endl; + } + } + if (previous != lightState) + { SetGraphicsDirty(); // this[0x18] |= 1 + if (getenv("BT_FIRE_LOG") || getenv("BT_HEAT_LOG")) + DEBUG_STREAM << "[light] reported lightState " << previous << " -> " << lightState + << " (commandedOn " << commandedOn << ", requestedOn " << requestedOn << ")" + << std::endl; + } + Check_Fpu(); } diff --git a/game/reconstructed/searchlight.hpp b/game/reconstructed/searchlight.hpp index bc81f3c..e87e77e 100644 --- a/game/reconstructed/searchlight.hpp +++ b/game/reconstructed/searchlight.hpp @@ -121,8 +121,11 @@ // protected: int lightState; // @0x1D8 reported on/off (gated by power + heat each frame); "LightState" - int commandedOn; // @0x1DC resource +0x28; toggled by the "ToggleLamp" message (@004b860c) - int requestedOn; // @0x1E0 init 0; on/off input consumed by SearchlightSimulation + int commandedOn; // @0x1DC ctor-seeded from the subsystem resource +0x28 (@004b84dc). + // NOT the toggle target and NOT read by @004b841c; it measured + // 21 live, so it is not a boolean -- role still unidentified. + int requestedOn; // @0x1E0 init 0; THE on/off input consumed by SearchlightSimulation + // (@004b841c reads +0x1E0) and toggled by ToggleLamp @004b838c BtAlarm stateAlarm; // @0x1E4 2-level light indicator (size 0x54) //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -148,12 +151,17 @@ // SetGraphicsDirty -> engine Simulation::ForceUpdate() (this+0x18) // SetInstanceFlag -> engine Simulation::simulationFlags |= bits (this+0x28) // GetSegmentFlags() removed (the ctor gate now reads owner->simulationFlags). - // ControlsAllowLights() [T1]: the ToggleLamp @004b860c gate is the - // owning BTPlayer's +0x25c "sim live" EXPERIENCE flag (mech+0x190 - // playerLink -> player+0x25c; the old "controls-mapper lightsEnabled" - // reading was wrong) -- 0 only for NOVICE experience. Issue #2: - // routed through the complete-type bridge in btplayer.cpp - // (databinding rule: never raw-read player offsets). + // ControlsAllowLights(): ⚠ NOT USED BY Searchlight::ToggleLamp. Raw + // disassembly of @004b838c (#61 -- it is a Ghidra export gap, see + // searchlight.cpp) shows Searchlight gates on the PRESS ALONE and never + // reads mech+0xD0 -> +0x190 -> player+0x25C. The +0x25C novice lock + // belongs to the SIBLING ThermalSight (@004b860c), whose handler had been + // mis-transcribed into this file -- which is where this accessor came + // from. Kept because the bridge itself is correct and callers elsewhere + // document the flag; see thermalsight.hpp for the real consumer. + // [T1: player+0x25c is the "sim live" experience flag, 0 only for NOVICE; + // routed through the complete-type bridge in btplayer.cpp per the + // databinding rule.] Logical HostShutDown() const { return simulationState == 1; /* Destroyed */ } int WatchedVoltageLevel() const { return watchdogAlarm.GetLevel(); } // @0x198 int HeatStateLevel() const { return heatAlarm.GetLevel(); } // @0x140 @@ -164,8 +172,15 @@ } void SetGraphicsDirty() { ForceUpdate(); } // this[0x18] |= 1 void SetInstanceFlag(int bits) { simulationFlags |= bits; } // this[10] |= bits - // The "ToggleLamp" command arg is carried at message+0xC (decomp). - static int MessageArg(Message &m) { return *(const int *)((const char *)&m + 0xC); } + // The "ToggleLamp" command arg is carried at message+0xC (decomp). That + // is exactly ReceiverDataMessageOf::dataContents -- the first member + // added to Receiver::Message -- so read the NAMED member instead of the + // raw offset (databinding rule). searchlight.cpp static_asserts the + // 0xC equivalence so the binary citation above stays honest. + static int MessageArg(Message &m) + { + return Cast_Object(const ReceiverDataMessageOf*, &m)->dataContents; + } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Simulation Support @@ -189,7 +204,45 @@ // public: Logical - ToggleLamp(Message &message); // @004b860c (bound to "ToggleLamp" @00511210) + ToggleLamp(Message &message); // @004b838c (bound to "ToggleLamp" @0051117c) + + // + // Gitea #61 (input-path audit systemic cause #1). The bare + // `MessageHandlers` above is DEFAULT-CONSTRUCTED -- entryCount 0, no + // parent chain -- so Receiver::MessageHandlerSet::Find() returned + // NullHandler for EVERY id and Receiver::Receive dropped every message + // the searchlight was ever sent, in silence. Proved live by the new + // unhandled-message trace: + // [btntest] PRESS 0x14 at poll 400 + // [msg] UNHANDLED: Searchlight has no handler for message id 3 + // The binary's table @0051117c holds exactly ONE entry -- + // { 3, "ToggleLamp" @00511440, @004b838c } -- with this class's attribute + // index ("LightOn" -> 0x1D9, "LightState" -> 0x1E5) at @005111d8 and its + // Performance triple (@004b841c) at @00511200. [T1: section_dump.txt] + // + // NOT @004b860c / table @0051120c -- that pair is ThermalSight's (this + // file mis-cited it for a long time). The two TUs emit parallel blocks + // and each keeps its OWN un-pooled "ToggleLamp" string next to its own + // class name: @00511440+"Searchlight"@0051144b here, + // @0051146a+"ThermalSight"@00511475 there. + // + // ToggleLamp's own signature is Logical(Message&) while + // Receiver::Handler is void(const Message*), so the table binds a + // correctly-typed forwarder rather than a cast (a cast would be UB that + // merely happens to work on x86 __thiscall). + // + // No id collision with HeatSink's id 3 (ToggleCooling): PowerWatcher + // declares no set of its own, so `PowerWatcher::GetMessageHandlers()` + // name-resolves up through HeatWatcher and MechSubsystem to Receiver's + // empty ROOT set -- HeatSink is not on this chain. Confirmed live: the + // id-3 press produced the UNHANDLED line and NO coolant toggle. + // + enum { ToggleLampMessageID = 3 }; // table @0051120c, fn @004b860c + + static Receiver::MessageHandlerSet& GetMessageHandlers(); + + void + ToggleLampMessageHandler(ReceiverDataMessageOf *message); //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Subsystem virtual overrides (vtable @005114d4) diff --git a/game/reconstructed/thermalsight.cpp b/game/reconstructed/thermalsight.cpp index a54e7db..a5c160a 100644 --- a/game/reconstructed/thermalsight.cpp +++ b/game/reconstructed/thermalsight.cpp @@ -49,6 +49,9 @@ Derivation "ThermalSight" ); +// DEAD (Gitea #61): default-constructed -- entryCount 0, no parent -- so it was a +// total blackhole. Kept only because the binary symbol exists; DefaultData now +// points at GetMessageHandlers() below. Do NOT bind anything to this. Receiver::MessageHandlerSet ThermalSight::MessageHandlers; @@ -72,7 +75,7 @@ ThermalSight::AttributeIndexSet& ThermalSight::SharedData ThermalSight::DefaultData( // @00511228 &ThermalSight::ClassDerivations, - ThermalSight::MessageHandlers, + ThermalSight::GetMessageHandlers(), // was the EMPTY MessageHandlers (blackhole) -- #61 ThermalSight::GetAttributeIndex(), ThermalSight::StateCount ); @@ -143,6 +146,78 @@ Logical return True; // BEST-EFFORT (family convention) } +//########################################################################### +// Message handling -- table @0051120c +// +// See thermalsight.hpp for why @004b860c is THIS class's handler (it had been +// transcribed into searchlight.cpp). The parent link resolves to Receiver's +// empty root set, so id 3 is unambiguous here. +// +Receiver::MessageHandlerSet& + ThermalSight::GetMessageHandlers() +{ + static const Receiver::HandlerEntry entries[]= + { + MESSAGE_ENTRY(ThermalSight, ToggleLamp) // id 3 @004b860c + }; + static Receiver::MessageHandlerSet messageHandlers( + ELEMENTS(entries), entries, + PowerWatcher::GetMessageHandlers() + ); + return messageHandlers; +} + +// +// The typed forwarder the table binds. Receiver::Handler is +// void(const Message*); the decomp's shape is Logical(Message&). The +// static_assert proves our named dataContents member sits where the binary read +// the arg (message+0xC). +// +static_assert( + offsetof(ReceiverDataMessageOf, dataContents) == 0xC, + "ReceiverDataMessageOf::dataContents must sit at 0xC -- @004b860c reads message+0xC" +); + +void + ThermalSight::ToggleLampMessageHandler(ReceiverDataMessageOf *message) +{ + Check(this); + Check_Pointer(message); + ToggleLamp(*message); +} + +//########################################################################### +// ToggleLamp -- @004b860c ("ToggleLamp" message handler) +// +// Transcribed instruction-for-instruction [T1]: +// +// if ((0 < *(int *)(param_2 + 0xc)) && +// (*(int *)(*(int *)(*(int *)(param_1 + 0xd0) + 400) + 0x25c) != 0)) { +// *(uint *)(param_1 + 0x1dc) = (uint)(*(int *)(param_1 + 0x1dc) == 0); +// } +// +// 400 == 0x190, the owning-BTPlayer link on the Mech; +0x25C is the player's +// "sim live" experience flag (0 only for NOVICE). +0x1DC is requestedOn -- the +// exact field ThermalSightSimulation (@004b8648) reads each frame. +// +Logical + ThermalSight::ToggleLamp(Message &message) +{ + if ((0 < MessageArg(message)) && // *(message + 0xC) + ControlsAllowLights()) // *(*(owner +0xD0)+0x190)+0x25C + { + requestedOn = (requestedOn == 0); // toggle @0x1DC (read by @004b8648) + + if (getenv("BT_FIRE_LOG") || getenv("BT_HEAT_LOG")) + DEBUG_STREAM << "[light] " << GetName() << " thermal sight requested " + << (requestedOn ? "ON" : "OFF") + << " (reported thermalActive " << thermalActive + << ", voltage level " << WatchedVoltageLevel() + << ", heat level " << HeatStateLevel() << ")" << std::endl; + } + return True; +} + //########################################################################### // ThermalSightSimulation -- @004b8648 (Performance) // @@ -181,6 +256,10 @@ void stateAlarm.SetLevel(thermalActive); // FUN_0041bbd8(this+0x1E0, thermalActive) + if ((previous != thermalActive) && (getenv("BT_FIRE_LOG") || getenv("BT_HEAT_LOG"))) + DEBUG_STREAM << "[light] reported thermalActive " << previous << " -> " << thermalActive + << " (requestedOn " << requestedOn << ")" << std::endl; + if ((previous != thermalActive) && IsLocallyViewed()) // was LocalViewport()->viewingThisMech { ToggleGlobalThermalVision(); // FUN_0045fe44 -- bring-up no-op (pvision unported) diff --git a/game/reconstructed/thermalsight.hpp b/game/reconstructed/thermalsight.hpp index 52aecd6..6d63b42 100644 --- a/game/reconstructed/thermalsight.hpp +++ b/game/reconstructed/thermalsight.hpp @@ -152,6 +152,60 @@ Logical IsLocallyViewed() const { return False; } // bring-up no-op (see above) void ToggleGlobalThermalVision() {} // bring-up no-op (pvision unported) + // ControlsAllowLights() [T1]: the ToggleLamp @004b860c gate is the owning + // BTPlayer's +0x25c "sim live" EXPERIENCE flag (mech+0xD0 -> +0x190 + // playerLink -> player+0x25C), 0 only for NOVICE. Routed through the + // complete-type bridge in btplayer.cpp (databinding rule: never raw-read + // player offsets). Mirrors searchlight.hpp. + Logical ControlsAllowLights() const + { + extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (player+0x25c) + return BTPlayerExperienceSimLive(owner) ? True : False; + } + // The "ToggleLamp" command arg is carried at message+0xC (decomp) -- that + // is ReceiverDataMessageOf::dataContents, the first member added to + // Receiver::Message. Read the NAMED member (databinding rule); + // thermalsight.cpp static_asserts the 0xC equivalence. + static int MessageArg(Message &m) + { + return Cast_Object(const ReceiverDataMessageOf*, &m)->dataContents; + } + + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // Message handling -- table @0051120c + // + // Gitea #61 (input-path audit systemic cause #1). `MessageHandlers` above is + // DEFAULT-CONSTRUCTED -- entryCount 0, no parent chain -- so Find() returned + // NullHandler for every id and Receiver::Receive dropped every message the + // thermal sight was sent, in silence. + // + // The binary's table @0051120c holds exactly ONE entry -- + // { 3, "ToggleLamp" @0051146a, @004b860c }. That handler was previously + // transcribed into searchlight.cpp by mistake: @004b860c toggles +0x1DC, which + // is THIS class's requestedOn (the field its own Performance @004b8648 reads) + // and is a resource-seeded non-boolean in Searchlight. The un-pooled + // "ToggleLamp" string @0051146a sits immediately before "ThermalSight" + // @00511475, which is what pins the entry to this TU. + // [T1: reference/decomp/section_dump.txt lines 69670-69711] + // + // Signature note: Receiver::Handler is void(const Message*) while the decomp's + // shape is Logical(Message&), so the table binds a typed forwarder, not a cast. + // Parent link: PowerWatcher declares no set of its own, so + // PowerWatcher::GetMessageHandlers() name-resolves through HeatWatcher and + // MechSubsystem to Receiver's empty ROOT set -- HeatSink's id 3 (ToggleCooling) + // is NOT on this chain, so id 3 is unambiguous here. + // + public: + enum { ToggleLampMessageID = 3 }; // table @0051120c, fn @004b860c + + static Receiver::MessageHandlerSet& GetMessageHandlers(); + + Logical + ToggleLamp(Message &message); // @004b860c + + void + ToggleLampMessageHandler(ReceiverDataMessageOf *message); + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Simulation Support // diff --git a/scratchpad/dis838c.py b/scratchpad/dis838c.py new file mode 100644 index 0000000..68debf3 --- /dev/null +++ b/scratchpad/dis838c.py @@ -0,0 +1,50 @@ +"""Raw-disassemble Searchlight::ToggleLamp @004b838c -- a Ghidra export gap (#60). + +The exported functions_index.tsv jumps straight to 4b83b8, so this body was never +in the pseudocode. It is the handler bound by Searchlight's message table +@0051117c {3, "ToggleLamp"@00511440, @004b838c}. Same technique that recovered +EjectAmmo @004bb9b8 (see context/decomp-reference.md). + +Also dumps ThermalSight's @004b860c for a side-by-side, since that one IS exported +and is the structural sibling the reconstruction was inferred from. +""" +import struct +from capstone import * + +PATH = r"C:\git\bt411\content\BTL4OPT.EXE" +data = open(PATH, "rb").read() +e_lfanew = struct.unpack_from(" 1" in l] +ok = light and not unh_sl and lit +print("\nVERDICT:", "PASS -- ToggleLamp handled AND the lamp actually lit (lightState 0 -> 1)" if ok + else ("still UNHANDLED -- the chain did not take" if unh_sl + else ("handler ran but lightState never reached 1 -- the toggle does not reach the lamp" + if light else "no [light] line -- handler bound but the gate refused"))) +sys.exit(0 if ok else 2) diff --git a/scratchpad/thermtest.py b/scratchpad/thermtest.py new file mode 100644 index 0000000..a05aef6 --- /dev/null +++ b/scratchpad/thermtest.py @@ -0,0 +1,86 @@ +"""Verify the ThermalSight handler-set fix (Gitea #61, input-path audit cause #1). + +ThermalSight's MessageHandlerSet was DEFAULT-CONSTRUCTED (entryCount 0, no parent +chain), so Receiver::Receive dropped its "ToggleLamp" message (id 3) in silence. +Before the fix the identical rig produced: + + [btntest] PRESS 0x12 at poll 400 + [msg] UNHANDLED: ThermalSight has no handler for message id 3 -- silently dropped + +The binary's table @0051120c holds exactly one entry { 3, "ToggleLamp", @004b860c }. + +PASS = the UNHANDLED line is GONE **and** a [light] line shows commandedOn flipping. +Uses the real click seam (BT_BTNTEST, L4PADRIO.cpp:393), not a synthetic call. + +Kills only the PID it spawns. +""" +import os +import re +import subprocess +import sys +import time + +REPO = r"C:\git\bt411" +LOG = os.path.join(REPO, "scratchpad_therm.log") +if os.path.exists(LOG): + os.remove(LOG) + +env = dict(os.environ) +env.update({ + "BT_START_INSIDE": "1", + "BT_DEV_GAUGES": "1", + "BT_FIRE_LOG": "1", # enables the [light] proof line + "BT_HEAT_LOG": "1", + # press 0x12 (the thermal sight button) at poll 400, release at 900, twice over + # the run so we see the toggle go ON then back OFF. + "BT_BTNTEST": "0x12,400,900", + "BT_LOG": LOG, +}) +proc = subprocess.Popen( + [os.path.join(REPO, "build", "Release", "btl4.exe"), "-egg", "LAST.EGG"], + cwd=os.path.join(REPO, "content"), env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +print("pid", proc.pid) + +try: + deadline = time.time() + 240 + seen_release = False + while time.time() < deadline: + if os.path.exists(LOG): + t = open(LOG, errors="replace").read() + if "RELEASE 0x12" in t: + seen_release = True + break + time.sleep(2) + time.sleep(8) +finally: + proc.terminate() + time.sleep(1) + +t = open(LOG, errors="replace").read() if os.path.exists(LOG) else "" +btn = re.findall(r"^\[btntest\].*$", t, re.M) +light = re.findall(r"^\[light\].*$", t, re.M) +unh_sl = re.findall(r"^\[msg\] UNHANDLED.*ThermalSight.*$", t, re.M) +unh_any = re.findall(r"^\[msg\] UNHANDLED.*$", t, re.M) + +print("\n=============== RESULT ===============") +print("reached the button press:", seen_release) +for l in btn[:6]: + print(" ", l[:130]) +print("\n[light] toggle lines:", len(light)) +for l in light[:6]: + print(" ", l[:150]) +print("\n[msg] UNHANDLED mentioning ThermalSight:", len(unh_sl)) +for l in unh_sl[:4]: + print(" ", l[:140]) +print("\nall other UNHANDLED pairs still open:", len(unh_any) - len(unh_sl)) +for l in [x for x in unh_any if "ThermalSight" not in x][:10]: + print(" ", l[:140]) + +lit = [l for l in light if "reported thermalActive 0 -> 1" in l] +ok = light and not unh_sl and lit +print("\nVERDICT:", "PASS -- ToggleLamp handled AND the lamp actually lit (lightState 0 -> 1)" if ok + else ("still UNHANDLED -- the chain did not take" if unh_sl + else ("handler ran but lightState never reached 1 -- the toggle does not reach the lamp" + if light else "no [light] line -- handler bound but the gate refused"))) +sys.exit(0 if ok else 2)