Files
BT411/game/reconstructed/seeker.cpp
T
arcattackandClaude Opus 5 ef8e449a17 #55 steps 0/1/2/4: the 1995 DEATH pass restored, the arg gate made real, and 5 subsystems the respawn sweep never reached
THE HEADLINE: the port's death sweep was a total no-op, and fixing it required
fixing the arg gate in the same commit -- otherwise corpses refill their ammo.

STEP 2 -- the authentic dispatch shape:
  * engine/MUNGA/SUBSYSTM.h: Subsystem::DeathShutdown gains the binary's
    universal base body { DeathReset(c) } (@004ad10e).  It was empty, and NO
    port class overrode it, so everything the 1995 game does at death was
    skipped entirely.
  * mech4.cpp death sweep now passes 0, not 1 (the binary's @0049fe0c arg) --
    the wreck shape: alarms/state settle, nothing refills.
  * ARG PROPAGATION (mandatory once the sweep runs): AmmoBin::DeathReset is now
    arg-gated exactly as @004bd26c -- refill only when arg != 0, ammoAlarm
    unconditional.  Ignoring the arg was harmless only while the sweep was
    dead; with it restored, every corpse would have re-armed.  Also forwarded
    in PoweredSubsystem / HeatSink / Condenser / HeatableSubsystem / Generator
    (each binary body forwards it -- verified addresses in the plan).

STEP 4 -- coverage for 5 classes that had an authentic slot-10 body but no
DeathReset, so the respawn sweep fell through to the empty base:
  Torso (this is Gitea #70 -- "loosing torso twist function after a death"),
  Gyroscope, Myomers, HUD, Seeker.

STEPS 0/1 -- observability + the David chain:
  * Three unconditional matchlog rows: DEAD_NOTIFY (mech + resolved link),
    PLAYER_LINK (player <-> vehicle), RESPAWN (mode/alive/zones/subsys/pos).
    A respawn was previously INVISIBLE in the matchlog -- night 3's analysis
    had to infer them from ammo arithmetic.
  * Mech::PlayerLinkMessageHandler + the death dispatch site: when the engine's
    one-shot registry lookup misses (no null check, no retry -> the whole
    death/respawn cycle silently swallowed), recover the SAME object via the
    reverse link the binary's own respawn branch walks (player+0x1FC ==
    playerVehicle).  Complete-type TU, no raw offsets.
  * deathPending cleared in the first-spawn branch (a latch carried in would
    permanently kill every later respawn -- the #57 class).

VERIFIED on the 2-pod rig (madcat vs thor, forced kills, 5 death/respawn cycles
per pod): build clean, ZERO new /FORCE unresolved externs from the 5 new
overrides; refill lines appear ONLY immediately before a Mech::Reset and NEVER
between a death and the next respawn (the corpse-refill regression this commit
had to pre-empt); all three probe rows present in both pods' matchlogs; every
DEAD_NOTIFY carried a non-null link.

DELIBERATELY DEFERRED (documented, not forgotten): the mechsub.cpp rename pair
(ResetToInitialState -> GenerateFault, ClearStatus -> the root reset @004ac22c),
deleting HeatableSubsystem::ResetToInitialState, and deleting RespawnRepair.
Those need the HeatableSubsystem vtable-slot-10 pre-flight the plan calls for,
and we have no binary image here to dump the vtable from -- guessing at it
risks the vptr-alias trap.  Next session with the decomp shards open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:09:31 -05:00

246 lines
9.7 KiB
C++

//============================================================================//
// File: seeker.cpp //
// Project: BattleTech //
// Contents: A subsystem which guides, seeks out and finds a target //
//----------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//============================================================================//
//
// RECONSTRUCTED from the shipped binary. Behaviour follows the Ghidra
// pseudo-C in part_013.c (@004bec34-@004bee70); member / method names follow
// the surviving SEEKER.HPP and MISSILE.TCP. Each non-trivial method cites the
// originating @ADDR. Hex float constants converted to decimal:
// 0x3c8efa35 = 0.0174533f (PI/180, deg->rad) @004be8b8
// 0x43480000 = 200.0f 0x42480000 = 50.0f 0x43960000 = 300.0f
//
// Coverage:
// confident : ctor @004bec34, LeadTarget @004beae4, dtor thunk @004bf890-chain
// best-effort: FindTarget (folded into Missile guidance; see header note),
// ResetToInitialState / TestInstance (inherit Subsystem base)
// excluded : the 0x41xxxx engine vtable slots (Subsystem base behaviour)
//
// Helper-function name mapping (engine internals referenced by the decomp):
// FUN_0041c52c Subsystem base constructor (owner, id, name, sharedData)
// FUN_00408644 Vector3D = a - b (subtract)
// FUN_004085ec Vector3D += b (accumulate)
// FUN_004086ac Vector3D = base + dir*scale (scaled add)
// FUN_00408b98 build/normalise direction
// FUN_00408440 Point3D copy
// FUN_0040a7f4 Point3D copy (read seeker target into thruster buffer)
// FUN_004dd138 sqrt() FUN_0041a1a4 IsDerivedFrom(classDerivations)
// FUN_0041bbd8 AlarmIndicator::SetLevel
//
#include <bt.hpp>
#pragma hdrstop
#if !defined(SEEKER_HPP)
# include <seeker.hpp>
#endif
#if !defined(MISSILE_HPP)
# include <missile.hpp>
#endif
#if !defined(TESTBT_HPP)
# include <testbt.hpp>
#endif
//
// Lead/guidance tuning constants, read as read-only globals in the decomp.
// (Decoded from .rdata @004bec18..@004bec28.)
//
static const Scalar SeekerLeadMinRange = 200.0f; // _DAT_004bec18 start leading past this range
static const Scalar SeekerLeadZero = 0.0f; // _DAT_004bec1c
static const Scalar SeekerLeadFloor = 50.0f; // _DAT_004bec20
static const Scalar SeekerLeadMaxClamp = 300.0f; // _DAT_004bec24 lead distance clamp
static const Scalar SeekerLeadCoef = 0.1f; // _DAT_004bec28 (repeating-C double 0.1; loft/lead gain)
//###########################################################################
//###########################################################################
// Seeker
//###########################################################################
//###########################################################################
//#############################################################################
// Shared Data Support
//
Derivation
Seeker::ClassDerivations(
Subsystem::GetClassDerivations(),
"Seeker"
);
Receiver::MessageHandlerSet Seeker::MessageHandlers;
const Seeker::IndexEntry
Seeker::AttributePointers[] = { { 0, 0, 0 } }; // IndexEntry table (see header @00512d50)
Seeker::AttributeIndexSet Seeker::AttributeIndex;
Seeker::SharedData
Seeker::DefaultData( // resolved as &DAT_00512bec
&Seeker::ClassDerivations,
Seeker::MessageHandlers,
Seeker::AttributeIndex,
Seeker::StateCount
);
//#############################################################################
// Construction
//
// @004bec34 Seeker::Seeker(Missile *owner, int id, SubsystemResource*,
// Entity *target, const Point3D offset)
//
// Chains the Subsystem base ctor, stamps the Seeker vtable, then snapshots
// the firing geometry: creationPoint <- owner's current position, the homing
// target + aim offset, and the initial range readings.
//
Seeker::Seeker(
Missile *owner,
int subsystem_ID,
SubsystemResource *subsystem_resource,
Entity *target,
const Point3D offset)
:
// Subsystem base ctor: owner, id, resource, shared default data.
Subsystem(owner, subsystem_ID, subsystem_resource, DefaultData)
{
// vtable installed by the compiler (PTR_LAB_00512ce8)
// CROSS-FAMILY (Entity/Missile kinematics): the owning Missile's current world
// position/facing and the target's world position are read from the Entity
// transform. Those accessors live in the Entity/missile families; the spawn
// snapshot below uses the launch geometry available here.
Point3D ownerPos(0.0f, 0.0f, 0.0f); // owner + 0x100 (missile spawn position)
creationPoint = ownerPos; // @0xF0 this[0x3c..0x3e]
// homing parameters
targetEntity = target; // @0xFC this[0x3f]
targetOffset = offset; // @0x100 this[0x40..0x42]
// Initial aim point: if dumb-fire (target == 0) aim straight ahead from the
// owner's facing, otherwise aim at the target's mount + offset.
if (targetEntity == 0)
{
// project the aim point a small distance ahead of the launch facing
targetPosition = targetOffset; // @004bec34 (target==0 branch)
}
else
{
Point3D targetPos(0.0f, 0.0f, 0.0f); // targetEntity + 0x100
targetPosition.x = targetOffset.x + targetPos.x;
targetPosition.y = targetOffset.y + targetPos.y;
targetPosition.z = targetOffset.z + targetPos.z;
}
// rangeToTarget = |targetPosition - ownerPos| (sampled at spawn)
// startingRangeToTarget = rangeToTarget
// rangeFromCreation = |ownerPos - creationPoint| (== 0 at spawn)
Vector3D delta;
delta.Subtract(targetPosition, ownerPos);
rangeToTarget = (Scalar)Sqrt(delta.x*delta.x + delta.y*delta.y + delta.z*delta.z); // @0x10C
startingRangeToTarget = rangeToTarget; // @0x114 this[0x45]
delta.Subtract(ownerPos, creationPoint);
rangeFromCreation = (Scalar)Sqrt(delta.x*delta.x + delta.y*delta.y + delta.z*delta.z); // @0x110
}
//#############################################################################
// Destruction
//
// @004bee44 ~Seeker() -- restores the Seeker vtable then chains the Subsystem
// base destructor. (Reached through the scalar-deleting thunk FUN_004bee70.)
//
Seeker::~Seeker()
{
// vtable reset + base ~Subsystem chaining handled by the compiler.
}
//#############################################################################
// Guidance
//
// @004beae4 Seeker::LeadTarget()
//
// Per-frame homing update. When a live targetEntity exists, build the
// vector from the current targetPosition back to the missile, measure the
// range, and -- once outside SeekerLeadMinRange (200 m) -- nudge the aim
// point upward/ahead by SeekerLeadCoef * clamp(range-200, .., 300). The
// lead is also projected along the target's own velocity so a moving target
// is intercepted rather than chased.
//
void Seeker::LeadTarget()
{
if (targetEntity == 0) // @004beae4 if (this[0xfc] != 0)
return;
// CROSS-FAMILY (Entity/Missile kinematics): the owning Missile's world
// position + bounding radius and the target's velocity / "alive" state are
// read from the Entity transform layer. Isolated as documented locals here.
Point3D ownerPos(0.0f, 0.0f, 0.0f); // owner + 0x100
Scalar boundingRadius = 1.0f; // owner + 0x348 (avoid /0)
Logical targetTeamValid = True; // owner team chain (best-effort)
// vector from aim point to the missile, and its length
Vector3D toMissile;
toMissile.Subtract(targetPosition, ownerPos); // @004beae4 FUN_00408644(.., +0xe4, owner+0x100)
Scalar range = (Scalar)Sqrt(toMissile.x*toMissile.x + toMissile.y*toMissile.y + toMissile.z*toMissile.z);
Scalar approach = range / boundingRadius; // owner + 0x348
if (range > SeekerLeadMinRange && targetTeamValid)
{
Scalar lead = range - SeekerLeadMinRange;
if (toMissile.y >= SeekerLeadFloor && lead > SeekerLeadMaxClamp)
lead = SeekerLeadMaxClamp; // clamp the loft contribution
targetPosition.y += SeekerLeadCoef * lead; // @0xE8 this[0x3a]
}
// if the target is itself a mover, advance the aim point along its velocity
if (targetEntity->TestInstance()) // FUN_0041a1a4(.., 0x4e4518) Mover check
{
Vector3D targetVelocity(0.0f, 0.0f, 0.0f); // targetEntity velocity
targetPosition.x += targetVelocity.x * approach; // @004beae4 FUN_004086ac / FUN_004085ec
targetPosition.y += targetVelocity.y * approach;
targetPosition.z += targetVelocity.z * approach;
}
}
//#############################################################################
// FindTarget -- per-frame Performance entry (best-effort)
//
// The shipped Missile reads Seeker::targetPosition directly during its
// integration pass (Missile::MoveAndCollide @004bef78 samples this+0x1c4),
// so the standalone FindTarget reduces to re-leading the target each slice.
// No distinct @ADDR survives; this mirrors MISSILE.TCP's call site.
//
void Seeker::FindTarget(Scalar /*time_slice*/)
{
LeadTarget();
}
//#############################################################################
// ResetToInitialState / TestInstance -- inherit the Subsystem base
// implementations (vtable @00512ce8 slots 10/.. are all 0x41xxxx engine code).
//
void Seeker::ResetToInitialState()
{
// The engine Subsystem base exposes no ResetToInitialState; the Seeker simply
// re-zeros its transient guidance range readings.
rangeToTarget = 0.0f;
rangeFromCreation = 0.0f;
startingRangeToTarget = 0.0f;
}
Logical Seeker::TestInstance() const
{
return Subsystem::TestInstance();
}
//
// DeathReset (#55 step 4) -- the respawn sweep's entry for the Seeker.
//
void Seeker::DeathReset(int /*reset_command*/)
{
ResetToInitialState();
}