Files
BT411/scratchpad/lamptest.py
T
arcattackandClaude Opus 5 f3bdb3b85a 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>
2026-07-25 10:46:59 -05:00

87 lines
3.0 KiB
Python

"""Verify the Searchlight handler-set fix (Gitea #61, input-path audit cause #1).
Searchlight'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 0x14 at poll 400
[msg] UNHANDLED: Searchlight 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_lamp.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 0x14 (the searchlight button) at poll 400, release at 900, twice over
# the run so we see the toggle go ON then back OFF.
"BT_BTNTEST": "0x14,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 0x14" 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.*Searchlight.*$", 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 Searchlight:", 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 "Searchlight" not in x][:10]:
print(" ", l[:140])
lit = [l for l in light if "reported lightState 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)