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:
co-authored by
Claude Opus 5
parent
62513b2f8a
commit
f3bdb3b85a
@@ -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("<I", data, 0x3C)[0]
|
||||
coff = e_lfanew + 4
|
||||
num_sec = struct.unpack_from("<H", data, coff + 2)[0]
|
||||
opt_size = struct.unpack_from("<H", data, coff + 16)[0]
|
||||
opt = coff + 20
|
||||
image_base = struct.unpack_from("<I", data, opt + 28)[0]
|
||||
sec_tbl = opt + opt_size
|
||||
secs = []
|
||||
for i in range(num_sec):
|
||||
off = sec_tbl + i * 40
|
||||
vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", data, off + 8)
|
||||
secs.append((va, vsize, rawptr, rawsize))
|
||||
|
||||
|
||||
def va_to_off(va):
|
||||
rva = va - image_base
|
||||
for sva, vsize, rawptr, rawsize in secs:
|
||||
if sva <= rva < sva + max(vsize, rawsize):
|
||||
return rawptr + (rva - sva)
|
||||
return None
|
||||
|
||||
|
||||
md = Cs(CS_ARCH_X86, CS_MODE_32)
|
||||
|
||||
for name, va, length in (
|
||||
("Searchlight::ToggleLamp @004b838c (EXPORT GAP -- ground truth)", 0x004b838c, 0x2c),
|
||||
("ThermalSight::ToggleLamp @004b860c (exported sibling)", 0x004b860c, 0x3c),
|
||||
):
|
||||
off = va_to_off(va)
|
||||
print("=" * 78)
|
||||
print(name)
|
||||
print("=" * 78)
|
||||
for ins in md.disasm(data[off:off + length], va):
|
||||
print(" %08x %-22s %s %s" % (ins.address, ins.bytes.hex(), ins.mnemonic, ins.op_str))
|
||||
print()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user