Files
BT411/game/reconstructed/sensor.cpp
T
arcattackandClaude Opus 5 33ca99eb69 Input-audit cause #1: revive the Avionics power bank + add the unhandled-message trace
THE CLASS OF BUG.  A default-constructed Receiver::MessageHandlerSet is a total
blackhole: entryCount 0 and NO parent chain, so Find() returns NullHandler for
every id and Receiver::Receive dropped the message with no log, no counter,
nothing.  docs/INPUT_PATH_AUDIT.md ranked this systemic cause #1; running its own
lint finds SEVEN such sets (Sensor, Searchlight, ThermalSight, Gyroscope,
MechTech, SubsystemMessageManager, Torso).  It is also why generators cannot be
switched off (#53) -- same shape.

FIX 1 -- Sensor / Avionics (the big one).  The streamed mappings aim 108 records
across the 18 mechs at it: the MFD2 ENG1 power bank, 6 clickable buttons plus keys
F5-F9 (route Avionics power to generators A-D, gen mode, coolant cut).  All
silently swallowed.  Sensor needed NO handlers of its own -- ids 4-8 already live
on PoweredSubsystem, which chains HeatSink for id 3, covering the whole 3-8 range
those buttons send.  It only ever needed to CHAIN, which is exactly why the
byte-identical bank on ENG2 works (Myomers chains, myomers.cpp:88-100; Sensor did
not).  Added Sensor::GetMessageHandlers() with the same function-local-static
idiom and pointed DefaultData at it instead of the empty set.

VERIFIED LIVE (scratchpad/avionics.py -- pages MFD2 and presses the bank):
  [gensel] Avionics -> GeneratorA (tapped)   ... B, C, D
  [gensel] Avionics mode -> 2
i.e. Avionics now behaves exactly like Myomers.  24 handler hits, 0 unhandled.

FIX 2 -- the standing guard.  Receiver::Receive now logs once per (class,
message id) pair when no handler is found: '[msg] UNHANDLED: <class> has no
handler for message id N -- silently dropped (is its MessageHandlerSet chained?)'.
Once-per-pair on purpose, since some ids broadcast every frame.  This one line
would have printed Avionics, Searchlight, ThermalSight, crouch and all four
generator buttons on the first boot instead of costing us months of player
reports.  Uses SharedData::derivedClasses -> Derivation::className.

DEFERRED, with reasons (not guessed at): Searchlight's ToggleLamp body exists but
is unreachable -- it needs a correctly-typed forwarder (Receiver::Handler is
void(const Message*); ToggleLamp is Logical(Message&), so a cast would be UB that
happens to work on x86) AND an id check, because its id 3 collides with HeatSink's
ToggleCooling and I have not traced whether PowerWatcher's chain reaches HeatSink.
#53 needs more than chaining: ToggleGeneratorOnOff @004b1ed0 has no body at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 10:05:57 -05:00

457 lines
17 KiB
C++

//===========================================================================//
// File: sensor.cpp //
// Project: BattleTech //
// Contents: Sensor subsystem -- radar/targeting effectiveness model //
//---------------------------------------------------------------------------//
// Date Who Modification //
// -------- --- ---------------------------------------------------------- //
// 8/10/95 GDU Initial coding. //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from the shipped binary (Ghidra pseudo-C, recovered shards
// part_012.c / part_013.c) cross-referenced with the surviving SENSOR.HPP and
// the sibling subsystem sources (powersub.hpp/.cpp, heat.hpp/.cpp). Each
// non-trivial method cites the originating @ADDR. Hex float constants have
// been converted to decimal:
// 0x3f800000 = 1.0f 0x3f000000 = 0.5f
//
// ---------------------------------------------------------------------------
// HOW THE SENSOR WAS LOCATED (and a corrected class identification)
// ---------------------------------------------------------------------------
// The Mech ctor (mech.cpp @004a1674) streams one Subsystem per segment via a
// switch over the streamed ClassID (part_012.c @~0x9978). The slot the prior
// mech.cpp reconstruction named "sensorSubsystem" (this[0x1f7]) is filled by
// case 0xBBE -> ctor FUN_004ae8d0, allocation 0x1E4. That object is far too
// small to be a PoweredSubsystem-derived Sensor (PoweredSubsystem alone is
// 0x31C bytes), so 0xBBE / 4ae8d0 is NOT the SENSOR.HPP Sensor -- that label
// in mech.cpp is a mis-guess (the slot caches some smaller HeatSink-family
// subsystem).
//
// The real Sensor is case 0xBC3 -> ctor FUN_004b1d18, allocation 0x328
// (= PoweredSubsystem 0x31C + three Scalar/Logical members at 0x31C/0x320/
// 0x324). Proof:
// * The shipped string pool @0050fae0 reads "Sensor\0RadarPercent\0
// SelfTest\0BadVoltage\0".
// * The AttributePointers IndexEntry table @0050fa50 binds those three
// names to attribute IDs 0x12/0x13/0x14 at object offsets 0x31C/0x320/
// 0x324 -- exactly SENSOR.HPP's radarPercent / selfTest / badVoltage and
// its RadarPercent/SelfTest/BadVoltage AttributeIDs.
// * CreateStreamedSubsystem @004b1dcc stamps classID 0xBC3 and model size
// 0x190 and parses no extra fields -- matching the empty
// Sensor__SubsystemResource in SENSOR.HPP.
//
// The earlier powersub.cpp/CLASSMAP work labelled this same class "Myomers"
// (vtable 0050fb0c, ctor 4b1d18) with INFERRED members
// outputVoltage/powered/voltageAvailable -- there is no "Myomers"/
// "OutputVoltage" string evidence for those. The recovered strings settle
// it: vtable 0050fb0c / ctor 4b1d18 / classID 0xBC3 is Sensor.
//
// Helper-function name mapping (engine internals referenced by the decomp):
// FUN_004b0f74 PoweredSubsystem base constructor (powersub.cpp)
// FUN_004b0bd0 PoweredSubsystem::PoweredSubsystemSimulation
// FUN_004b0e6c PoweredSubsystem::ResetToInitialState(powered)
// FUN_004b0efc PoweredSubsystem::HandleMessage
// FUN_004b115c PoweredSubsystem destructor
// FUN_0041b9ec AlarmIndicator(levels)
// FUN_0041bbd8 AlarmIndicator::SetLevel(n)
// FUN_0041a1a4 IsDerivedFrom(classDerivations)
// FUN_004022d0 operator delete / global free
// FUN_00402298 Memory::Allocate
//
#include <bt.hpp>
#pragma hdrstop
#if !defined(SENSOR_HPP)
# include <sensor.hpp>
#endif
#if !defined(APP_HPP)
# include <app.hpp>
#endif
#if !defined(TESTBT_HPP)
# include <testbt.hpp>
#endif
//
// Tuning constants observed as read-only float globals adjacent to the
// SensorSimulation body (.rdata @004b1d10/@004b1d14, recovered from
// section_dump.txt).
//
static const Scalar RadarBaseline = 1.0f; // _DAT_004b1d10 (0x3f800000)
static const Scalar HeatDegradationScale = 0.5f; // _DAT_004b1d14 (0x3f000000)
//###########################################################################
//###########################################################################
// Sensor
//###########################################################################
//###########################################################################
//#############################################################################
// Shared Data Support
//
// DefaultData @0050fa1c, ClassDerivations @0050fa2c.
//
Derivation
Sensor::ClassDerivations(
PoweredSubsystem::GetClassDerivations(),
"Sensor"
);
//
// MESSAGE HANDLERS (Gitea #53 family / input-path audit systemic cause #1).
//
// This was a DEFAULT-CONSTRUCTED `MessageHandlerSet`, which is a total blackhole:
// it has `entryCount = 0` and **no parent chain**, so `Find()` returns
// `NullHandler` for every id (`engine/MUNGA/RECEIVER.h`), and `Receiver::Receive`
// drops the message with no log, no warning and no counter. The Sensor is the
// cockpit's **"Avionics"** subsystem, and the streamed control mappings aim
// **108 records across the 18 mechs** at it -- the MFD2 ENG1 page's power bank
// (6 clickable buttons, keys F5-F9): route Avionics power to generator A-D, gen
// mode, coolant cut. Every one of them was silently swallowed.
//
// Sensor needs NO handlers of its own -- those ids (4-8) already exist on
// `PoweredSubsystem`. It only ever needed to CHAIN, which is exactly why the
// byte-identical bank on ENG2 works: Myomers chains (myomers.cpp:88-100) and
// Sensor did not. Same idiom, function-local static so construction order is
// safe.
//
Receiver::MessageHandlerSet&
Sensor::GetMessageHandlers()
{
static Receiver::MessageHandlerSet messageHandlers(
0, 0, // no ids of its own
PoweredSubsystem::GetMessageHandlers()); // copy-inherit ids 4-8
return messageHandlers;
}
Receiver::MessageHandlerSet
Sensor::MessageHandlers;
//
// Attribute Support -- the gauge bindings for the Sensor (named "Avionics" in the
// cockpit config): Avionics/RadarPercent (the map's capabilities ratio), SelfTest,
// BadVoltage. SENSOR.HPP declares the enum + AttributePointers[]; the empty
// default-ctor AttributeIndex published NOTHING (not even SimulationState). Build
// it from the table chained to PoweredSubsystem::GetAttributeIndex() (which
// resolves to HeatSink's dense index) so the merged index stays dense (ids run
// contiguously from PoweredSubsystem::NextAttributeID).
//
const Sensor::IndexEntry
Sensor::AttributePointers[]=
{
ATTRIBUTE_ENTRY(Sensor, RadarPercent, radarPercent), // @0x31C (map capabilities ratio)
ATTRIBUTE_ENTRY(Sensor, SelfTest, selfTest),
ATTRIBUTE_ENTRY(Sensor, BadVoltage, badVoltage)
};
Sensor::AttributeIndexSet
Sensor::AttributeIndex(
ELEMENTS(Sensor::AttributePointers),
Sensor::AttributePointers,
PoweredSubsystem::GetAttributeIndex()
);
Sensor::SharedData
Sensor::DefaultData(
&Sensor::ClassDerivations,
Sensor::GetMessageHandlers(), // was the EMPTY MessageHandlers (blackhole)
Sensor::AttributeIndex,
Sensor::StateCount
);
//#############################################################################
// Construction / Destruction
//
// @004b1d18 -- chains to the PoweredSubsystem ctor (FUN_004b0f74) passing
// Sensor::DefaultData (&DAT_0050fa1c), installs the Sensor vtable
// (PTR_FUN_0050fb0c), primes the three sensor members, marks the subsystem as
// carrying a per-frame Performance (instance flag bit 0x8 at this[0x28]) and
// -- unless this is a damaged copy segment (segment flags & 0xC == 4) --
// registers SensorSimulation as the active Performance (this[7..9], the
// 12-byte member-function pointer at PTR_FUN_0050fa94).
//
Sensor::Sensor(
Mech *owner,
int subsystem_ID,
SubsystemResource *subsystem_resource
):
PoweredSubsystem(owner, subsystem_ID, subsystem_resource, DefaultData)
{
Check(owner);
Check_Pointer(subsystem_resource);
// (WAVE 4 de-shim) the 5 cross-family shim fields are gone; the accessors
// now read the inherited base state directly. Only the 3 own fields init here.
radarPercent = RadarBaseline; // @0x31C = 1.0f
selfTest = False; // @0x320 = 0
badVoltage = False; // @0x324 = 0
// this[0x28] |= 0x8 -- flag "has an active per-frame Performance".
SetHasPerformanceFlag(); // param_1[10] |= 8
// INTEGRATION (gate reconcile): read OWNER simulationFlags (param_2+0x28) —
// the oracle-verified authoritative source — not the local segment shim.
// Sensor installs its per-frame Performance unless it is a damaged copy
// segment (owner flags & 0xC == 4); a live master (== 0) arms it.
if ((owner->simulationFlags & SegmentCopyMask) != 4) // (owner flags & 0xC) != 4
{
SetPerformance(&Sensor::SensorSimulation); // this[7..9] = &SensorSimulation
}
Check_Fpu();
}
//
// @004b1d90 -- reinstalls the vtable, clears the owning Mech's cached sensor
// back-reference ( *(owner + 0x374) = 0 ), then chains to ~PoweredSubsystem
// (FUN_004b115c) and frees the block when the deleting-dtor bit is set.
//
Sensor::~Sensor()
{
Check(this);
// *(this->owner + 0x374) = 0; -- drop Mech's cached sensor pointer
Check_Fpu();
}
//###########################################################################
// TestInstance -- Sensor
//
// @004b1e18 -> FUN_0041a1a4(**this[3], 0x50fa2c) (Sensor::ClassDerivations)
//
Logical
Sensor::TestInstance() const
{
return IsDerivedFrom(ClassDerivations);
}
//###########################################################################
// TestClass -- Sensor
//
// Standard subsystem TestClass (cf. HeatSink/PoweredSubsystem). BEST-EFFORT:
// no distinct Sensor body was captured; the family convention returns True.
//
Logical
Sensor::TestClass(Mech &)
{
return True;
}
//###########################################################################
// ResetToInitialState -- Sensor
//
// @004b1c18 (vtable slot 10). When (re)powering, re-primes the three sensor
// members to their ctor defaults, then chains to
// PoweredSubsystem::ResetToInitialState (FUN_004b0e6c).
//
// NOTE: the captured override takes the inherited `powered` flag
// (PoweredSubsystem::ResetToInitialState(Logical powered=True)); SENSOR.HPP
// declares it argument-less, so the default is shown here.
//
void
Sensor::ResetToInitialState()
{
const Logical powered = True;
if (powered)
{
radarPercent = RadarBaseline; // 1.0f
selfTest = False;
badVoltage = False;
}
PoweredSubsystem::ResetToInitialState(powered); // FUN_004b0e6c
}
//#############################################################################
// Per-frame simulation
//
//
// @004b1c4c -- the registered Performance. Runs the base powered-subsystem
// electrical update first, then derives the three sensor readouts from the
// thermal + electrical state:
//
// radarPercent = RadarBaseline - master->heatEnergy (this[0x38]->+0x158)
//
// * if the linked thermal master reports "heat model off" (this[0x10]==1):
// radarPercent = 0, selfTest = 0
// else
// selfTest = 1
//
// * if the electrical state alarm (PoweredSubsystem electricalStateAlarm
// level @0x278, this[0x9e]) is Ready(4):
// badVoltage = 0
// else
// badVoltage = 1, radarPercent = 0 (no power -> blind)
//
// * finally the heat-state alarm level (this[0x61] @0x184) trims the radar:
// NormalHeat(0) -> selfTest = 1
// DegradationHeat(1) -> radarPercent *= 0.5f, selfTest = 1
// FailureHeat(2) -> radarPercent = 0, selfTest = 0
//
void
Sensor::SensorSimulation(Scalar time_slice)
{
Check(this);
PoweredSubsystem::PoweredSubsystemSimulation(time_slice); // FUN_004b0bd0
// @004b1c4c:1829 radarPercent = RadarBaseline - *(this[0x38]+0x158)
// this[0x38] == the inherited MechSubsystem::damageZone (this+0xE0, an ENGINE
// DamageZone); +0x158 == DamageZone::damageLevel = this Sensor's OWN structural
// damage [0,1] (0 intact .. 1 destroyed, engine-clamped by DAMAGE.cpp:380). So
// radarPercent is a natural [0,1] capability ratio -- NO guard/normalization
// needed. (The prior read used the INHERITED heatEnergy ~1.3e7 -- a misidentified
// field -- which drove radarPercent hugely negative and needed the bring-up guard,
// now DELETED. Same anchor UpdateCoolant/heat.cpp:769 reads as zoneDamage.)
radarPercent = RadarBaseline - GetSubsystemDamageLevel();
if (HeatModelOff()) // this[0x10] == 1
{
radarPercent = 0.0f;
selfTest = False;
}
else
{
selfTest = True;
}
if (ElectricalStateLevel() == Ready) // this[0x9e] (@0x278) == 4
{
badVoltage = False;
}
else
{
badVoltage = True;
radarPercent = 0.0f;
}
switch (HeatStateLevel()) // this[0x61] (@0x184)
{
case NormalHeat: // 0
selfTest = True;
break;
case DegradationHeat: // 1
radarPercent *= HeatDegradationScale; // *= 0.5f
selfTest = True;
break;
case FailureHeat: // 2
radarPercent = 0.0f;
selfTest = False;
break;
default:
break;
}
Check_Fpu();
}
//#############################################################################
// Death and Damage Support
//
// BEST-EFFORT. No Sensor-specific TakeDamage / DeathReset bodies were
// captured; the Sensor vtable (@0050fb0c) carries the inherited
// PoweredSubsystem / HeatSink addresses in the damage/death slots. The
// bodies below simply chain to the base so the SENSOR.HPP interface compiles;
// a human should verify whether sensor.cpp originally added behaviour here.
//
void
Sensor::TakeDamage(Damage &damage)
{
PoweredSubsystem::TakeDamage(damage); // TODO: verify (inherited slot)
}
void
Sensor::DeathReset(Logical full_recovery)
{
PoweredSubsystem::DeathReset(full_recovery); // TODO: verify (inherited slot)
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// CreateStreamedSubsystem -- Sensor
//
// @004b1dcc -- chains to PoweredSubsystem::CreateStreamedSubsystem
// (FUN_004b13ac) then stamps the resource:
// resource->classID = 0x0BC3 (resource +0x20)
// resource->subsystemModelSize = 0x190 (resource +0x24)
// No Sensor-specific resource fields are read (the Sensor resource record is
// an empty extension of the PoweredSubsystem record).
//
int
Sensor::CreateStreamedSubsystem(
NotationFile *model_file,
const char *model_name,
const char *subsystem_name,
SubsystemResource *subsystem_resource,
NotationFile *subsystem_file,
const ResourceDirectories *directories,
int passes
)
{
if (
!PoweredSubsystem::CreateStreamedSubsystem( // FUN_004b13ac
model_file, model_name, subsystem_name,
subsystem_resource, subsystem_file, directories, passes
)
)
{
return False;
}
subsystem_resource->subsystemModelSize = sizeof(*subsystem_resource); // 0x190
subsystem_resource->classID = RegisteredClass::SensorClassID; // 0x0BC3
Check_Fpu();
return True;
}
//===========================================================================//
// WAVE 4 -- compile-time OVERFLOW lock (the property that matters for the
// placement-new into the 0x328 factory alloc).
// NOTE: the reconstructed HEAT-LEAF base chain (PoweredSubsystem : HeatSink :
// HeatableSubsystem) is NOT byte-exact to the binary (unlike the re-based
// WATCHER branch Torso sits on), so radarPercent does NOT land at the binary's
// 0x31C. That is fine here: SensorSimulation reads every field through NAMED
// members (compiled-consistent), and NOTHING reads Sensor at a raw binary
// offset yet. The one raw-offset consumer is the attribute table
// (RadarPercent -> 0x31C), used only by the (un-drawn) cockpit gauge -- so the
// exact-offset re-base of the heat-leaf branch is a Phase-4 HUD follow-up.
// Until then, no-overflow (sizeof <= alloc) is the correct, sufficient lock.
//===========================================================================//
struct SensorLayoutCheck
{
// Now BYTE-EXACT: with PoweredSubsystem byte-exact (ends 0x31C), Sensor's 3 own
// fields (ctor @004b1d18: radarPercent@0x31C / selfTest@0x320 / badVoltage@0x324)
// land exactly at 0x31C..0x328. (Public fields -> namespace-scope offsetof works.)
static_assert(offsetof(Sensor, radarPercent) == 0x31C, "Sensor::radarPercent @0x31C (attr 0x12)");
static_assert(offsetof(Sensor, selfTest) == 0x320, "Sensor::selfTest @0x320");
static_assert(offsetof(Sensor, badVoltage) == 0x324, "Sensor::badVoltage @0x324");
static_assert(sizeof(Sensor) == 0x328, "sizeof(Sensor) 0x328 (factory alloc, byte-exact)");
};
//===========================================================================//
// WAVE 4 factory bridge -- Sensor (factory case 0xBC3, "Myomers" mislabel).
// The real class at 0xBC3 (ctor @004b1d18) is Sensor (string pool @0050fae0 +
// surviving SENSOR.HPP); MyomersClassID is a factory-enum mislabel (the real
// Myomers is 0xBC6/@4b8fec). Object is 0x328 == the factory alloc (zero
// headroom; locked by SensorLayoutCheck above). Sensor's ctor is 3-arg
// (DefaultData applied internally) -- no shared_data argument.
//===========================================================================//
Subsystem *CreateSensorSubsystem(Mech *owner, int id, void *seg)
{
return (Subsystem *) new (Memory::Allocate(0x328))
Sensor(owner, id, (Sensor::SubsystemResource *)seg);
}