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>
246 lines
9.7 KiB
C++
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();
|
|
}
|