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
+133 -9
View File
@@ -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<int>, dataContents) == 0xC,
"ReceiverDataMessageOf<int>::dataContents must sit at 0xC -- @004b860c reads message+0xC"
);
void
Searchlight::ToggleLampMessageHandler(ReceiverDataMessageOf<int> *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();
}