Files
BT411/game/reconstructed/thermalsight.cpp
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

334 lines
12 KiB
C++

//===========================================================================//
// File: thermalsight.cpp //
// Project: BattleTech //
// Contents: ThermalSight (thermal / infra-red vision mode) subsystem //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from the shipped binary (Ghidra pseudo-C, recovered shard
// part_013.c). Cluster @004b8718..@004b881c. Each method cites its @ADDR.
//
// Helper-function name mapping:
// FUN_004b18a4 PowerWatcher base constructor (powersub.cpp)
// FUN_004b198c PowerWatcher::CreateStreamedSubsystem (powersub.cpp)
// FUN_004b181c PowerWatcher base per-frame update (powersub.cpp)
// FUN_004b179c PowerWatcher::HandleMessage (slot 9) (powersub.cpp)
// FUN_004b1804 PowerWatcher::ResetToInitialState (slot10) (powersub.cpp)
// FUN_004b1930 ~PowerWatcher (powersub.cpp)
// FUN_0041b9ec AlarmIndicator(levels)
// FUN_0041baa4 ~AlarmIndicator
// FUN_0041bbd8 AlarmIndicator::SetLevel(n)
// FUN_0041a1a4 IsDerivedFrom(classDerivations)
// FUN_0045fe44 ToggleGlobalThermalVision (flips _DAT_004f1ab8 "pvision",
// prints "pvision ON/OFF", re-applies the view palette)
// FUN_004022d0 operator delete / global free
// PTR_DAT_004fd4b8 the active L4 application/viewport (its +0x48 flag is
// non-zero only when THIS mech is the locally-viewed mech)
//
#include <bt.hpp>
#pragma hdrstop
#if !defined(THERMALSIGHT_HPP)
# include <thermalsight.hpp>
#endif
#if !defined(APP_HPP)
# include <app.hpp>
#endif
#if !defined(TESTBT_HPP)
# include <testbt.hpp>
#endif
//###########################################################################
// Shared Data Support
//
Derivation
ThermalSight::ClassDerivations( // @00511238
PowerWatcher::GetClassDerivations(),
"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;
// task #16: publish the binary attr table @00511270 -- one entry,
// {LightState (id 4) -> thermalActive @0x1D8} (offset encoded 0x1D9 = 0x1D8|1).
// Function-local statics per the static-init-order rule.
ThermalSight::AttributeIndexSet&
ThermalSight::GetAttributeIndex()
{
static const ThermalSight::IndexEntry entries[]=
{
ATTRIBUTE_ENTRY(ThermalSight, LightState, thermalActive) // id 4 @0x1D8
};
static ThermalSight::AttributeIndexSet attributeIndex(
ELEMENTS(entries), entries,
PowerWatcher::GetAttributeIndex()
);
return attributeIndex;
}
ThermalSight::SharedData
ThermalSight::DefaultData( // @00511228
&ThermalSight::ClassDerivations,
ThermalSight::GetMessageHandlers(), // was the EMPTY MessageHandlers (blackhole) -- #61
ThermalSight::GetAttributeIndex(),
ThermalSight::StateCount
);
//###########################################################################
// Construction -- @004b8718
//
// Chains to the PowerWatcher ctor (FUN_004b18a4) with &DAT_00511228, installs
// the ThermalSight vtable (PTR_FUN_00511498), builds the 2-level state
// AlarmIndicator (@0x1E0). When this is a live (non-copy) segment with the
// has-performance flag set it registers ThermalSightSimulation
// (this[7..9] <- PTR_FUN_00511290).
//
ThermalSight::ThermalSight(
Mech *owner,
int subsystem_ID,
SubsystemResource *subsystem_resource,
SharedData &shared_data
):
PowerWatcher(owner, subsystem_ID, subsystem_resource, shared_data)
{
Check(owner);
Check_Pointer(subsystem_resource);
// (WAVE 4 de-shim) the 4 cross-family shim fields are gone; the accessors
// read the inherited base state directly. Only the own fields init here.
thermalActive = 0; // @0x1D8
requestedOn = 0; // @0x1DC
stateAlarm.Initialize(2); // FUN_0041b9ec(this+0x1E0, 2)
// GATE FIX (the functional bug the de-shim unblocks): the binary reads OWNER
// simulationFlags (param_2+0x28, raw part_013.c:6140), NOT the old shadow
// `segmentFlags` (seeded 0 -> gate always FALSE -> Performance never
// installed). No else branch (matches the binary; unlike Searchlight/Torso).
if (((owner->simulationFlags & 0xC) == 0) &&
((owner->simulationFlags & 0x100) != 0))
{
SetPerformance(&ThermalSight::ThermalSightSimulation); // this[7..9] <- PTR_FUN_00511290
}
Check_Fpu();
}
//
// Destruction -- @004b8794 (vtable slot0). Reinstalls the vtable, tears down
// the AlarmIndicator (@0x1E0), chains to ~PowerWatcher (FUN_004b1930), frees
// the block when the deleting-dtor bit is set.
//
ThermalSight::~ThermalSight()
{
Check(this);
stateAlarm.Finalize(); // FUN_0041baa4(this+0x1E0, 2)
Check_Fpu();
}
//###########################################################################
// TestInstance -- @004b881c
//
Logical
ThermalSight::TestInstance() const
{
return IsDerivedFrom(ClassDerivations); // FUN_0041a1a4(**this[3], 0x00511238)
}
Logical
ThermalSight::TestClass(Mech &)
{
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<int>, dataContents) == 0xC,
"ReceiverDataMessageOf<int>::dataContents must sit at 0xC -- @004b860c reads message+0xC"
);
void
ThermalSight::ToggleLampMessageHandler(ReceiverDataMessageOf<int> *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)
//
// Same power/heat gating as the Searchlight, but the watched quantity is the
// thermal-vision mode, and when the active state CHANGES on the locally-viewed
// mech it toggles the global "pvision" render flag:
// * base tick (FUN_004b181c).
// * thermalActive follows requestedOn (@0x1DC) unless the host is shut down
// (this[0x40] == 1), in which case it is forced off.
// * the watched-voltage alarm level (@0x198) must read Ready(4); else blank.
// * heat-state level (@0x140): <2 keeps it, ==2 blanks it.
// * push on/off into the AlarmIndicator (@0x1E0).
// * if the state changed AND this mech is the one being viewed
// (PTR_DAT_004fd4b8 -> +0x48 != 0), call ToggleGlobalThermalVision
// (FUN_0045fe44) to flip the cockpit IR view.
//
void
ThermalSight::ThermalSightSimulation(Scalar time_slice)
{
Check(this);
PowerWatcher::Simulation(time_slice); // FUN_004b181c (base per-frame update)
int previous = thermalActive; // @0x1D8
thermalActive = (HostShutDown()) ? 0 : requestedOn; // this[0x40]==1 ? 0 : @0x1DC
if (WatchedVoltageLevel() == 4) // @0x198 == Ready
thermalActive = (thermalActive != 0);
else
thermalActive = 0;
if (HeatStateLevel() < 2) // @0x140
thermalActive = (thermalActive != 0);
else if (HeatStateLevel() == 2)
thermalActive = 0;
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)
}
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// CreateStreamedSubsystem -- ThermalSight @004b87d4
//
// Chains to PowerWatcher::CreateStreamedSubsystem (FUN_004b198c) then stamps
// classID 0x0BDE (+0x20) and model size 0xF4 (+0x24). No thermal-specific
// fields are read.
//
int
ThermalSight::CreateStreamedSubsystem(
NotationFile *model_file,
const char *model_name,
const char *subsystem_name,
SubsystemResource *subsystem_resource,
NotationFile *subsystem_file,
const ResourceDirectories *directories,
int passes
)
{
if (
!PowerWatcher::CreateStreamedSubsystem( // FUN_004b198c
model_file, model_name, subsystem_name,
subsystem_resource, subsystem_file, directories, passes
)
)
{
return False;
}
subsystem_resource->subsystemModelSize = 0xF4; // +0x24
subsystem_resource->classID = RegisteredClass::ThermalSightClassID; // 0x0BDE, +0x20
return True;
}
//===========================================================================//
// WAVE 4 -- compile-time layout locks. PowerWatcher is Torso-proven at 0x1D8,
// so the own fields land at their EXACT binary offsets; sizeof is bounded to
// the factory alloc. (BtAlarm is a 4-byte stand-in for the binary's 0x54
// GaugeAlarm -- immaterial: nothing reads ThermalSight at a raw offset past
// its own fields, and the shared BT_LOCAL_ALARM_SHIM stays 4 bytes because
// searchlight.hpp shares that guard.)
//===========================================================================//
struct ThermalSightLayoutCheck
{
static_assert(offsetof(ThermalSight, thermalActive) == 0x1D8, "ThermalSight thermalActive @0x1D8 (PowerWatcher ends 0x1D8)");
static_assert(offsetof(ThermalSight, requestedOn) == 0x1DC, "ThermalSight requestedOn @0x1DC");
static_assert(offsetof(ThermalSight, stateAlarm) == 0x1E0, "ThermalSight stateAlarm @0x1E0 (proves base ends 0x1D8)");
static_assert(sizeof(ThermalSight) <= 0x234, "sizeof(ThermalSight) must fit the factory Memory::Allocate(0x234)");
};
//===========================================================================//
// WAVE 4 factory bridge -- ThermalSight (factory case 0xBDE, "MechDisplay" stub).
// The real class at 0xBDE (ctor @004b8718) is ThermalSight; the factory built
// a MechDisplay RECON_SUBSYS stub. No cache write; not a weapon (raw
// part_012.c:10186-10195 stores roster-only).
//===========================================================================//
Subsystem *CreateThermalSightSubsystem(Mech *owner, int id, void *seg)
{
return (Subsystem *) new (Memory::Allocate(0x234))
ThermalSight(owner, id, (ThermalSight::SubsystemResource *)seg, ThermalSight::DefaultData);
}