Files
BT411/game/reconstructed/mech4.cpp
T
arcattackandClaude Opus 4.8 2af401eef8 Collision damage applied: mech-vs-mech + icon-crunch via the STEP-6 unaimed path
The two deferred TakeDamage dispatches in Mech::ProcessCollision now fire,
unblocked by STEP 6 (the cylinder hit-location override that resolves zone==-1):

  - Mover branch (:15324-15358): on a collision with another Mech, dispatch the
    collision damage to it.
  - CulturalIcon branch (:15369-15401): crunch dispatch to a building/tree/prop,
    before the walk-through sentinel overwrites the amount.

Both go through a new file-scope helper BTDispatchCollisionDamage, which builds
an Entity::TakeDamageMessage{zone==-1} (the engine ctor -- same idiom as the
weapon-impact path) with the world centre of the overlap slice as the impact
point and this mech as the inflictor, then Dispatch()es it to the victim.  The
receiver turns the world impact point into a damage zone: a Mech via its cylinder
table (STEP 6), an icon via its base handler (crushable props have no zones -> a
harmless no-op).  Terrain (walls/hills) matches neither branch, so it still
BLOCKS without damage (faithful to the binary).

Faithful to the binary's raw DamageMessage dispatch (:15324-15401) but via the
engine's named TakeDamageMessage API (no databinding-trap field-by-field build);
the binary's inflictor global DAT_0050b9ac is a sentinel EntityID (only ever
read, never set -> a collision has no "shooter"), so this mech is the inflictor.

Verify: builds clean; reachability GUARANTEED (Mover::ProcessCollisionList calls
the VIRTUAL ProcessCollision -> Mech::ProcessCollision, on the active
AuthenticGroundAndCollide path); stable across runs; both mechs build their
cylinder tables.  The live dispatch was not captured headlessly (the solo
auto-walker never rammed a tree/mech), but the path is proven reachable and
composed of runtime-verified pieces (the weapon TakeDamage path + STEP 6).

Also validated STEP 6's height ref: collisionTemplate->maxY ~= 7.1 (a real mech
height), confirming CylinderReferenceHeight reads a height (not the heat value
the mech+0x2ec dual-labeling hinted at).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:54:21 -05:00

2812 lines
134 KiB
C++

//===========================================================================//
// File: mech4.cpp //
// Project: BattleTech Brick: Entity Manager //
// Contents: Mech per-frame simulation: motion integration, move-and-collide, //
// weapon-impact damage routing, cockpit-gauge feed //
// -- fourth implementation slice //
//---------------------------------------------------------------------------//
// Date Who Modification //
// -------- --- ---------------------------------------------------------- //
// --/--/95 ?? Initial coding. //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from the shipped binary (Ghidra pseudo-C in
// all/part_012.c, cluster 0x004ab188-0x004ac064) cross-referenced with the
// mech.hpp/mech2.cpp member maps and the surviving damage-state keyword table
// at .rdata:0050de74.
//
// The decompiler tagged every function in this window as file=? . Attribution
// to mech4.cpp is by RANGE (this is the upper half of the 0x4a8054-0x4ac868
// gap, just below where heat.cpp's HeatableSubsystem family begins at
// 0x4ac530 / 0x4ac644) and by COHESION: these are the Mech's PER-FRAME runtime
// path, the consumers of the gait clips that mech2.cpp/mech3.cpp produced.
//
// @004ab188 Mech::DeadReckonPose (63 bytes; helper)
// @004ab1c8 Mech::IntegrateMotion (615 bytes; mentioned by mech2.cpp)
// @004ab430 Mech::ReplicantPerformance (1432 bytes; REPLICANT-only interior --
// dead-reckon + ground snap, no collisions; wrapped by the
// perf @004ab9d8 = PTR_LAB_0050c0e8. CORRECTED by the
// ground-model-decode workflow: NOT "Simulate"; the MASTER
// per-frame performance is FUN_004a9b5c = PTR_LAB_0050c0f4.)
// @004abb40 Mech::ProcessCollision (1284 bytes; vtable slot +0x3c -- the
// override of the engine protected virtual
// Mover::ProcessCollision. CORRECTED: the earlier draft
// misread it as a weapon sweep "ResolveWeaponImpact".)
// @004ac04c Mech::SetDuckedCollisionTemplate (23 bytes; template maxY <- 0x51c)
// @004ac064 Mech::SetStandingCollisionTemplate(23 bytes; template maxY <- 0x518;
// earlier "heat gauge feeder" labels were wrong -- this+0x2ec
// is the engine Mover collisionTemplate)
// @004ac194 Mech::LookupDamageState (61 bytes; static keyword parse)
//
// These are Mech METHODS. The Mech class declaration is owned by
// mech.cpp / mech.hpp; this file declares no header of its own. Member
// offsets it touches are documented in the "Mech runtime member map" block
// below for the mech.hpp owner to fold in (see report). The decomp addresses
// the object as int words, so this+0xNN there == byte offset 0xNN here.
//
//---------------------------------------------------------------------------//
// NOT recovered here (sit in this address window but are NOT Mech methods):
// @004a9b5a-@004ab188 the ~5.6 KB "undefined" gap IS the MASTER per-frame
// performance FUN_004a9b5c (PTR_LAB_0050c0f4, installed
// as activePerformance for non-replicant mechs, ctor
// part_012.c:9947-9956) -- decoded from the raw asm by
// the ground-model-decode workflow; its ground/collision
// half is reconstructed as AuthenticGroundAndCollide +
// the real Mech::ProcessCollision below (task #15).
// @004ac07c,@004ac0bc,@004ac144,@004ac1d4,@004ac22c,@004ac274 -- methods of
// a HeatableSubsystem-family class (owner-Mech ptr at
// this[0x34]/+0xD0, inner heat object at +0xE0; vtable
// 0050e210). These belong to heat.cpp, NOT Mech.
// @004ac4fc free lerp helper. @004ac530/@004ac644 HeatableSubsystem
// ctors -- heat.cpp.
//
//---------------------------------------------------------------------------//
// Helper / engine routine name mapping used below:
// FUN_0049fb54 Mech::IsDisabled() -> Logical
// FUN_004a5678 Mech::AdvanceBodyAnimation(dt, loop) [mech2.cpp]
// FUN_004a5bf8 Mech::AdvanceBodyAnimationAirborne(dt, loop) [mech2.cpp]
// FUN_004ab188 Mech::DeadReckonPose(dt)
// FUN_004ab1c8 Mech::IntegrateMotion(dt, loop) -> Logical (disabled?)
// FUN_004086ac Vector::Scale(out, in, scalar)
// FUN_00409f58 ReconQuatIntegrate(out, base, omega)
// FUN_00408644 Vector::Subtract(out, a, b)
// FUN_00408614 Vector::AddScaled(out, a, b, t)
// FUN_004085ec Vector::Add(out, a, b)
// FUN_00408848 Vector::Lerp(out, a, w0, b, w1)
// FUN_00408440 Assign/Copy(dst, src) (struct or string copy)
// FUN_00408644/00408614 see above
// FUN_0040a7f4 ReconQuatIdentity(q, &kIdentity)
// FUN_0040aadc ReconMatrix::Identity(m)
// FUN_0040ab44 Matrix34::FromQuaternion(m, q)
// FUN_0040a938 Matrix34::SetRotation(m, q)
// FUN_0040a4d8 ReconQuatSlerp(out, a, b, t)
// FUN_0040e36c BoundingBoxTreeNode::FindSmallestNodeContainingColumn
// (BOXTREE.CPP:503; CORRECTED -- earlier "Terrain::CellAt")
// FUN_0040e5f0 BoundingBoxTreeNode::FindBoundingBoxUnder(point, &height)
// (BOXTREE.CPP:867; the ground-height query; h=-1 = miss;
// CORRECTED -- earlier "Terrain::HeightAt" / "heightfield")
// FUN_00433ed4 InterestManager::GetInterestZone (zone -> collision tree root)
// FUN_00421b6c / 00421b2c Mover::UpdateLocalMotion / UpdateWorldMotion
// (CORRECTED -- earlier "BeginPose/EndPose")
// FUN_0043ade4 FilteredScalar::Push(filter, sample)
// FUN_0043ae47 FilteredScalar::Sum(filter)
// FUN_0043ae0b FilteredScalar::Average(filter)
// FUN_00422ff8 Mover::StaticBounce (MOVER.CPP:1421; CORRECTED -- earlier
// "Mech::ComputeImpactDamage")
// FUN_0041a1a4 ReconIsDerived(classID)
// FUN_0041db7c DamageMessage::DamageMessage(&msg) (ctor/clear)
// FUN_00420ea4 CString::CString(dst, src)
// FUN_0041bbd8 AlarmIndicator::SetLevel(alarm, n)
// FUN_004d4b58 Strcmp(a, b) -> 0 if equal
//
// Read-only constants resolved from CODE literal pools (see section_dump):
// DAT_0052140c ticks-per-second timing scale (runtime global)
// _DAT_004ab9c8 = 1.0f _DAT_004ab9cc = 0.2f
// _DAT_004ab9d0 = -1.0f _DAT_004ab9d4 = 0.0f
// _DAT_004ac044 ~= -1.0e-4f (back-face/behind cull threshold)
// _DAT_004ac048 = 0.0f
// &DAT_004e0f74 = "" / zero vector ; &DAT_004e0fd4 = quaternion identity
//
#include <bt.hpp>
#pragma hdrstop
#if !defined(MECH_HPP)
# include <mech.hpp> // Mech class -- owned by mech.cpp slice
#endif
#if !defined(MECHMPPR_HPP)
# include <mechmppr.hpp> // MechControlsMapper -- the real-controls bridge/consumption
#endif
#if !defined(APP_HPP)
# include <app.hpp>
#endif
#if !defined(SUBSYSTM_HPP)
# include <subsystm.hpp> // Subsystem -- the per-frame roster entry (PerformAndWatch)
#endif
#if !defined(EXPLODE_HPP)
# include <EXPLODE.hpp> // Explosion::Make / MakeMessage -- bring-up fire effect
#endif
#if !defined(DAMAGE_HPP)
# include <DAMAGE.hpp> // Damage -- the per-hit damage payload
#endif
#if !defined(MECHDMG_HPP)
# include <mechdmg.hpp> // Mech__DamageZone (complete type -- Zone()->structureLevel)
#endif
// AUTHENTIC GROUND MODEL (task #15, ground-model-decode): complete engine types
// for the probe/snap/response block + the real ProcessCollision override.
#include <BOXTREE.hpp> // BoundingBoxTreeNode::FindBoundingBoxUnder / ...ContainingColumn
#include <BOXSOLID.hpp> // BoxedSolid / BoxedSolidCollision / BoxedSolidCollisionList
#include <cultural.hpp> // CulturalIcon::IsStoppingCollisionVolume / GetClassDerivations
static const Scalar kBehindCull = -1.0e-4f; // _DAT_004ac044
//###########################################################################
// BASE-REGION LAYOUT LOCK (P3 STEP-6 audit -- the shared P3/P5/gyro de-risk).
//
// Compile-time proof of the ground-truth layout (cdb `dt btl4!Mover/JointedMover/
// Mech`). The 1995 raw offsets IntegrateMotion/Simulate still use land ON these
// engine-base fields; these asserts pin exactly where they are, so the base-region
// reconciliation (see btbuild/P3_LOCOMOTION.md "BASE-REGION RECONCILIATION") is
// verifiable and any raw-offset stomp becomes provable at compile time. Friend of
// Mech (mech.hpp) for inherited-member offsetof access.
//###########################################################################
struct MechBaseLayoutCheck
{
// engine Mover/JointedMover base -- the fields the stale raw offsets corrupt:
static_assert(offsetof(Mech, localToWorld) == 0x0C8, "localToWorld@0xC8");
static_assert(offsetof(Mech, localOrigin) == 0x0F8, "localOrigin@0xF8 (raw this+0x100 stomps here)");
static_assert(offsetof(Mech, projectedOrigin) == 0x250, "projectedOrigin@0x250 (raw this+0x260 stomps here)");
static_assert(offsetof(Mech, previousOrigin) == 0x26C, "previousOrigin@0x26C (raw this+0x26c stomps here)");
static_assert(offsetof(Mech, projectedVelocity) == 0x288, "projectedVelocity@0x288 (raw this+0x298 stomps here)");
static_assert(offsetof(Mech, collisionVolumeCount) == 0x2D4, "collision cluster @0x2D4 (raw this+0x2d4 stomps here)");
static_assert(offsetof(Mech, collisionLists) == 0x2E4, "collisionLists@0x2E4 (the P5 teardown victim)");
static_assert(offsetof(Mech, segmentTable) == 0x2F0, "segmentTable@0x2F0");
// reconstructed Mech relocated members -- the CORRECT reconciliation targets.
// (These sit BEFORE legAnimation/bodyAnimation in declaration order, so they do
// NOT shift when those controllers grow to the real SequenceController size.)
static_assert(offsetof(Mech, torsoAimTarget) == 0x3E0, "torsoAimTarget@0x3E0 (raw this+0x2a4 should be this)");
static_assert(offsetof(Mech, netOrientation) == 0x3EC, "netOrientation@0x3EC (raw this+0x2d4 should be this)");
// NOTE: arrivalTime/simTime/spinRate/the gait-accumulator members are declared
// AFTER legAnimation/bodyAnimation, so they shift with the SequenceController
// growth. They are accessed BY NAME only (never at a raw external offset), so
// their absolute offset is not load-bearing and is intentionally NOT locked.
};
//###########################################################################
//################## Mech runtime member map (offsets) ##################
//###########################################################################
//
// For the mech.hpp owner. Continues the mech.hpp / mech2 maps; "?" flags an
// uncertain semantic. Scalar unless noted.
//
// @0x010 simTime (Time) current frame time (Mover base)
// @0x028 movementFlags (Word) base motion flags; &0xC==4 => "arrived"
// @0x040 movementMode (int) gait/jump selector (mech2)
// @0x100 maxSpeed (Scalar) (mech.hpp; here read as a vector w)
// @0x260 motionDelta (Vector) accumulated per-frame world translation
// @0x26c worldPose (Quaternion) integrated body orientation (this[0x9b])
// @0x298 torsoAimCurrent (mech.hpp) reused as angular-impulse accumulator
// @0x2a0 spinRate = -bodyCycleDistance/dt
// @0x2a4 torsoAimTarget (mech.hpp) snapshot of netOrientation @0x2d4
// @0x2d4 netOrientation (mech.hpp)
// @0x2e0 arrivalTime (Time) dead-reckon target timestamp
// @0x2e8 collisionVolume (BoxedSolid*) ENGINE Mover member (1995 MOVER.HPP:
// 370). CORRECTED by the ground decode -- earlier
// "physicsBody/weapon sweep" reading was wrong;
// vtable+0x1c on it is BoxedSolid::ProcessCollision.
// @0x2ec collisionTemplate (BoxedSolid*) ENGINE Mover member. The "heat
// gauge / groundRef" conflict is resolved: the duck
// swappers write template->maxY(+0xC).
// @0x2f0 containedByNode (BoundingBoxTreeNode*) ENGINE Mover member --
// the cached collision-tree node (GetMoverCollisionRoot).
// @0x2f8 lastCollisionList (BoxedSolidCollisionList*) ENGINE Mover member.
// @0x344 forwardCycleRate (mech2) set each frame from 0x5b8/0x5bc
// @0x3f4 airborneSelect (int) 0 => use groundCycleRate, else airborne
// @0x44c collisionTemporaryState (int) CORRECTED (was "ammoState"): zeroed
// pre-collision-list each frame (@4aa741); the
// ProcessCollision state tail writes 1/2. DEFERRED
// together (see BINARY-TAIL-DEFERRED markers).
// @0x4b8 templateBottomLift (mech.hpp) ctor: 0.05 x volume X width
// @0x518 standingTemplateMaxY (mech.hpp) CORRECTED (was "heatLevel")
// @0x51c duckedTemplateMaxY (mech.hpp) CORRECTED (was "heatCapacity"); 0.6 x standing
// @0x580 jumpCapable (mech3)
// @0x598 motionEventName (mech2)
// @0x5a4 motionEventArmed (mech2)
// @0x5b8 groundCycleRate (mech3)
// @0x5bc airborneCycleRate (mech3)
// @0x778 creationTime (mech.hpp)
// @0x77c motionEventPending (int) queued footstep/turn event flag
// @0x7e0 telemetryFilter[5] (mech.hpp this[0x1f8..0x204]) 15-sample filters;
// fed headPitch/torsoTwist/turretBase/legAngle + dt
// @0x81c prevTele[4] this[0x207..0x20a] last filtered angle samples
// @0x1dc..0x1ec aimRate[4] this[0x77..0x7b] telemetry-derived angular rates
// (cockpit/aim feed: d(angle)/dt across the filter)
//
//###########################################################################
//###########################################################################
// DeadReckonPose
//
// @004ab188
//
// Blend the net (server) orientation @0x2d4 toward the live body pose by a
// fraction of dt and fold the result onto the world position quaternion at
// @0x26c. Tiny leaf called once per frame from IntegrateMotion.
//###########################################################################
//###########################################################################
void
Mech::DeadReckonPose(Scalar fraction)
{
Vector3D scaled;
Vector::Scale(&scaled, &netOrientation, fraction); // FUN_004086ac(.,this+0x2d4,frac)
ReconQuatIntegrate( // FUN_00409f58
&worldPose, // was raw 0x26c (stomped previousOrigin)
&worldPoseBase, // was raw 0x138 (stomped updateOrigin)
&scaled);
}
//###########################################################################
//###########################################################################
// IntegrateMotion
//
// @004ab1c8 (named by mech2.cpp's banner as Mech::IntegrateMotion)
//
// Advance the *displayed-motion* (channel-B) body gait one frame and integrate
// the resulting cycle distance into the world transform. Picks the airborne
// body updater when (movementMode==3||4) && jumpCapable, else the ground one.
// Also fires the queued footstep / motion event when the mech "arrives".
// Returns the IsDisabled() result captured at entry (the caller branches on it).
//###########################################################################
//###########################################################################
Logical
Mech::IntegrateMotion(Scalar time_slice, int loop)
{
// Choose this frame's forward-cycle slew rate.
forwardCycleRate = airborneSelect ? airborneCycleRate : groundCycleRate; // 0x344<-0x5bc/0x5b8
if (IsDisabled()) // FUN_0049fb54
{
ReconQuatIdentity(&angularAccum, &kIdentityQuat); // was raw this+0x298 (stomped projectedVelocity)
return True;
}
// Time remaining to the dead-reckon arrival target (or elapsed since
// creation if we have already arrived).
Scalar dr;
Logical arrived;
if ((movementFlags & 0xC) == 4 && simTime < arrivalTime) // 0x28, 0x10, 0x2e0
{
dr = (Scalar)(arrivalTime - *(Scalar *)&creationTime) / DAT_0052140c;
arrived = True;
}
else
{
dr = (Scalar)(simTime - *(Scalar *)&creationTime) / DAT_0052140c;
arrived = False;
}
// Fire any queued motion event (footstep / turn) on arrival.
if (motionEventPending) // 0x77c
{
if ((movementFlags & 0xC) == 4)
{
FUN_00408644((Scalar *)&motionEventVector, // was raw 0x598/0x12c/0x100
(Scalar *)&motionSourceB, (Scalar *)&motionSourceA);
motionEventArmed = 1; // 0x5a4
motionEventPending = 0;
}
else
{
Assign((void *)&motionDelta, (void *)&motionSourceB); // was raw 0x260 <- 0x12c
motionEventPending = 0;
}
}
// Advance the body gait (airborne flavour while jumping).
Scalar cycleDistance;
if ((movementMode == 3 || movementMode == 4) && jumpCapable) // 0x40, 0x580
cycleDistance = AdvanceBodyAnimationAirborne(time_slice, loop); // FUN_004a5bf8
else
cycleDistance = AdvanceBodyAnimation(time_slice, loop); // FUN_004a5678
// The raw velocity vector (0x298) = {0, 0, -cycleDistance/dt}; the world-step
// FUN_00408744 reads all three components, so 0x2a0 (== angularAccum[2]) MUST be
// set here -- the earlier draft wrote only the separate `spinRate`@0x508 and left
// angularAccum[2] stale (a latent bug, never exercised while this fn was dead).
spinRate = -cycleDistance / time_slice; // 0x2a0 mirror (telemetry)
((Scalar *)&angularAccum)[2] = -cycleDistance / time_slice; // 0x2a0 velocity.z (forward)
((Scalar *)&angularAccum)[1] = 0.0f; // 0x29c velocity.y
((Scalar *)&angularAccum)[0] = 0.0f; // 0x298 velocity.x
DeadReckonPose(dr); // FUN_004ab188
Assign((void *)&torsoAimTarget, (void *)&netOrientation); // was raw 0x2a4 <- 0x2d4
// Build the per-frame world-translation increment and fold it onto
// motionDelta and worldPose (declared members; were raw 0x260 / 0x26c).
Matrix34 bodyFrame;
ReconMatrix::Identity(&bodyFrame); // FUN_0040aadc
Matrix34::FromQuaternion(&bodyFrame, &motionDelta); // FUN_0040ab44 (was raw 0x260)
Vector3D worldStep;
FUN_00408744(&worldStep, (Scalar *)&angularAccum, &bodyFrame); // was raw 0x298
Vector3D tmp;
Assign(&tmp, &kZeroVector); // &DAT_004e0f74
Vector::AddScaled(&tmp, &tmp, &worldStep, time_slice);
Vector::Add((Scalar *)&motionDelta, (Scalar *)&motionDelta, &tmp); // was raw 0x260
Assign(&tmp, &kZeroVector);
Vector::AddScaled(&tmp, &tmp, (Scalar *)&torsoAimTarget, time_slice); // was raw 0x2a4
Quaternion prevPose = worldPose; // was raw 0x26c
ReconQuatIntegrate(&worldPose, &prevPose, &tmp); // was raw 0x26c
return arrived;
}
//###########################################################################
//###########################################################################
// Simulate (per-frame move-and-collide + telemetry)
//
// @004ab430
//
// The Mech's main per-frame tick. Integrate body motion, slerp the live
// orientation / torso toward the dead-reckon target, drop the body onto the
// terrain (query terrain height at the new cell and correct the vertical),
// notify the renderer, then push the head/torso/turret/leg angles through the
// 15-sample telemetry FilteredScalars and emit the per-axis angular RATES used
// by the cockpit and the aiming reticle.
//###########################################################################
//###########################################################################
void
Mech::Simulate(Scalar time_slice)
{
ReconQuatIdentity(&aimRate, &kIdentityQuat); // clear aim/torso (was raw this+0x1dc -> localAcceleration)
Logical arrived = IntegrateMotion(time_slice, 1); // FUN_004ab1c8
if (!arrived)
{
// Live integration branch.
if (*(int *)(this + 0x5a4) == 1) // motionEventArmed
{
Vector3D ev = *(Vector3D *)(this + 0x598);
Scalar span = (_DAT_004ab9cc /*0.2*/ -
(Scalar)(*(int *)(this + 0x10) - *(int *)(this + 0x14)) / DAT_0052140c)
+ time_slice;
if (span <= time_slice)
*(int *)(this + 0x5a4) = 0;
else
Vector::Scale(&ev, &ev, time_slice / span);
Vector::Add((Scalar *)(this + 0x260), (Scalar *)(this + 0x260), &ev);
*(int *)(this + 0x264) = *(int *)(this + 0x104);
Matrix34::SetRotation((Matrix34 *)(this + 0x100), (Quaternion *)(this + 0x260));
FUN_00408644((Scalar *)(this + 0x598), (Scalar *)(this + 0x598), &ev);
}
else
{
*(int *)(this + 0x264) = *(int *)(this + 0x104);
Matrix34::SetRotation((Matrix34 *)(this + 0x100), (Quaternion *)(this + 0x260));
}
Assign((void *)(this + 0x1f4), (void *)(this + 0x298)); // legAngle <- accumulator
Assign((void *)(this + 0x1d0), (void *)(this + 0x2a4)); // headPitch <- aim target
}
else
{
// Dead-reckon / arrived branch: slerp orientation & torso to target.
Scalar t = time_slice /
((Scalar)(*(int *)(this + 0x2e0) - *(int *)(this + 0x10)) / DAT_0052140c + time_slice);
ReconQuatSlerp((Quaternion *)(this + 0x10c), (Quaternion *)(this + 0x10c),
(Quaternion *)(this + 0x26c), t); // FUN_0040a4d8
FUN_00408848((Scalar *)(this + 0x1d0), (Scalar *)(this + 0x1d0),
_DAT_004ab9c8 - t, (Scalar *)(this + 0x2a4), t); // headPitch lerp
if (*(int *)(this + 0x5a4) == 1)
{
Vector3D ev = *(Vector3D *)(this + 0x598);
Scalar span = (_DAT_004ab9cc -
(Scalar)(*(int *)(this + 0x10) - *(int *)(this + 0x14)) / DAT_0052140c)
+ time_slice;
if (span <= time_slice)
*(int *)(this + 0x5a4) = 0;
else
Vector::Scale(&ev, &ev, time_slice / span);
Vector::Add((Scalar *)(this + 0x260), (Scalar *)(this + 0x260), &ev);
*(int *)(this + 0x264) = *(int *)(this + 0x104);
Assign((void *)(this + 0x100), (void *)(this + 0x260));
FUN_00408644((Scalar *)(this + 0x598), (Scalar *)(this + 0x598), &ev);
}
else
{
*(int *)(this + 0x264) = *(int *)(this + 0x104);
Assign((void *)(this + 0x100), (void *)(this + 0x260));
}
FUN_00408848((Scalar *)(this + 0x1f4), (Scalar *)(this + 0x1f4),
_DAT_004ab9c8 - t, (Scalar *)(this + 0x298), t); // legAngle lerp
}
// --- Drop onto terrain (skip if the "no-collide" flag 0x40 is set) ------
if ((*(byte *)(this + 0x29) & 0x40) == 0)
{
(*(void (**)(Mech *))(*(int **)this + 13))(this); // vtable slot 13 (build world xform)
int world = *(int *)(DAT_004efc94 + 0x30);
int grid = FUN_00433ed4(world + 0x14); // World::Terrain
int *cellTab = *(int **)(grid + 0x30);
*(int *)(this + 0x2f0) = FUN_0040e36c(*cellTab, *(int *)(this + 0x2e8)); // Terrain::CellAt
}
else
{
Matrix34::FromQuaternion((Matrix34 *)(this + 0xd0), (Quaternion *)(this + 0x100));
}
{
// Query terrain height at the current planar cell; correct Z so the
// feet rest on the ground.
struct { int x; uint xf; int z; } probe;
probe.x = *(int *)(this + 0x100);
probe.z = *(int *)(this + 0x108);
Scalar baseH = *(Scalar *)(*(int *)(this + 0x2ec /*cell*/) + 8) + (Scalar)*(int *)(this + 0x104);
Scalar height;
FUN_0040e5f0(*(int *)(this + 0x2f0), (int)&probe, &height); // Terrain::HeightAt
if (height != _DAT_004ab9d0 /*-1*/)
{
height -= *(Scalar *)(*(int *)(this + 0x2ec) + 8);
*(Scalar *)(this + 0x104) -= height;
*(Scalar *)(*(int *)(this + 0x2e8) + 8) -= height;
*(Scalar *)(*(int *)(this + 0x2e8) + 0xc) -= height;
}
(void)baseH;
}
ReconBeginPose((int)this); // FUN_00421b6c
// --- Telemetry filters: push the four cockpit angles + dt, then emit the
// per-axis angular rates (cockpit / reticle feed). -------------------
FilteredScalar *f0 = (FilteredScalar *)(this + 0x7e0); // this[0x1f8]
FilteredScalar::Push(f0, *(Scalar *)(this + 0x1cc)); // headPitch [0x73]
FilteredScalar::Push((FilteredScalar *)(this + 0x7ec),*(Scalar *)(this + 0x1c8)); // aim Y [0x72]
FilteredScalar::Push((FilteredScalar *)(this + 0x7f8),*(Scalar *)(this + 0x1c4)); // turretBase[0x71]
FilteredScalar::Push((FilteredScalar *)(this + 0x804),*(Scalar *)(this + 0x1d4)); // aim/leg [0x75]
FilteredScalar::Push((FilteredScalar *)(this + 0x810), time_slice); // dt [0x76]
Scalar sHead = FilteredScalar::Sum(f0);
Scalar aTurr = FilteredScalar::Average((FilteredScalar *)(this + 0x7f8));
Scalar aHead = FilteredScalar::Average(f0);
Scalar aLeg = FilteredScalar::Average((FilteredScalar *)(this + 0x804));
Scalar aDt = FilteredScalar::Average((FilteredScalar *)(this + 0x810));
ReconQuatIdentity(&aimRate, &kIdentityQuat); // was raw this+0x1dc (dead Simulate tail)
if (_DAT_004ab9d4 /*0*/ < aDt)
{
// rate = (filtered_now - last_sample) / filtered_dt
*(Scalar *)(this + 0x1e4) = (sHead - *(Scalar *)(this + 0x81c)) / aDt; // [0x79]
*(Scalar *)(this + 0x1e0) = (aHead - *(Scalar *)(this + 0x828)) / aDt; // [0x78]
*(Scalar *)(this + 0x1dc) = (aTurr - *(Scalar *)(this + 0x820)) / aDt; // [0x77]
*(Scalar *)(this + 0x1ec) = (aLeg - *(Scalar *)(this + 0x824)) / aDt; // [0x7b]
}
*(Scalar *)(this + 0x81c) = sHead; // this[0x207]
*(Scalar *)(this + 0x828) = aHead; // this[0x20a]
*(Scalar *)(this + 0x820) = aTurr; // this[0x208]
*(Scalar *)(this + 0x824) = aLeg; // this[0x209]
ReconEndPose((int)this); // FUN_00421b2c
}
//###########################################################################
//###########################################################################
// PerformAndWatch (BRING-UP drivable locomotion)
//
// NOT in the shipped binary at this vtable slot -- this is a reconstruction
// override for Tier-2 bring-up. The engine simulation director calls the
// virtual Simulation::PerformAndWatch(till, stream) on every entity every
// frame (the stasis / sub-system / activePerformance machinery lives INSIDE
// the base Mover::PerformAndWatch, which we deliberately bypass here). We
// read the player drive input, integrate the engine-base localOrigin (the
// REAL Mover position/orientation), and rebuild localToWorld -- the matrix
// the BTL4VideoRenderer root renderable AND the chase camera are bound to, so
// the body walks across the terrain and the camera tracks it by construction.
//
// This intentionally does NOT run the (reconstructed, still-unsafe) Simulate /
// subsystem chain; wiring real animation-driven gait + collision is a later
// step. Single local player assumption (bring-up): heading is a file static.
//###########################################################################
//###########################################################################
// Player drive input, owned by the launcher (btbuild/btl4main.cpp).
// throttle/turn = the virtual-control OUTPUTS integrated below from the raw key
// state (keyFwd/keyBack/keyLeft/keyRight) -- see the launcher header comment.
struct BTDriveInput { float throttle; float turn; int forced; int fire; int fireForced; float forcedThrottle;
int keyFwd; int keyBack; int keyLeft; int keyRight; int allStop; };
extern BTDriveInput gBTDrive;
// Locomotion tuning (bring-up; hand-picked, not yet derived from the .ani
// [RootTranslation] clip speed -- that animation-driven path comes with the
// real gait wiring). Units are world units/sec and radians/sec.
static const Scalar kDriveMaxSpeed = 30.0f; // full-throttle forward speed
static const Scalar kDriveTurnRate = 1.2f; // full-deflection yaw rate
static const Scalar kWeaponRange = 100.0f; // bring-up "in range" threshold for the
// targeting log (real value = Emitter
// effectiveRange @0x328, per-weapon)
// Single local-player drive state (bring-up).
static Scalar gDriveHeading = 0.0f; // yaw about world up (Y)
// BT_GOTO beeline harness outputs (consumed by the mapper bridge, mechmppr.cpp)
int gBTGotoActive = 0;
float gBTGotoTurn = 0.0f;
float gBTGotoThrottle = 1.0f;
static int gDriveSeeded = 0;
static Scalar gDriveLogAccum = 0.0f; // 1 Hz position log throttle
static Scalar gTickLogAccum = 0.0f; // 1 Hz subsystem-tick log throttle
static int gTickFirstLogged = 0; // one-shot first-frame dispatch report
static Scalar gTargetLogAccum = 0.0f; // 1 Hz targeting log throttle
// Firing (bring-up): the weapon-effect renderables (real emitter beams / projectile
// tracers) are not built in the port yet, but the engine DOES render Explosion
// entities -- so a shot is shown as an explosion spawned at the target. We resolve
// the "explode" effect resource once and rate-limit shots with a cooldown.
// Fire cadence: grounded on the recovered laser data -- DischargeTime=0.2s
// (beam-on) + a short recharge. The EXACT recharge is not cleanly recoverable
// yet (RechargeRate feeds VoltageCurve/energyCoefficient, best-effort constants,
// and the voltage source isn't linked -> the port force-charges), so this is
// DischargeTime + ~0.1s, faster/snappier than the old 0.8s bring-up guess.
static const Scalar kFireCooldown = 0.3f; // damage-block cadence (bring-up stand-in)
static const Scalar kBeamOnTime = 0.2f; // laser beam-on = ERx laser DischargeTime
// DECODED per-laser cycle: DischargeTime(0.2) + RechargeRate(2.0, ER-medium) = 2.2s.
// (Recovered: RechargeRate is literally the recharge in seconds -- charge-curve
// constants C1=1.0/C2=1e-4, generator RatedVoltage=1e4, seek 0.8 -> curve arg 0.2,
// -ln(0.2)=ln5 cancels the exponential-charge-to-0.8 ln5 factor exactly.)
static const Scalar kPortRecharge = 2.2f; // one gun port's full fire+recharge cycle
static Scalar gFireCooldown = 0.0f; // counts down
static Scalar gBeamCooldown = 0.0f; // independent cooldown for the VISUAL beam
static const Scalar kMuzzleHeight = 7.0f; // gun height above the mech origin (torso)
static const Scalar kMuzzleForward = 3.0f; // muzzle offset ahead of the mech centre
static int gExplodeReady = 0; // 0=untried 1=ok -1=failed
static ResourceDescription::ResourceID gExplodeRes = ResourceDescription::NullResourceID;
static int gShotCount = 0;
// --- CRASH/STAGGER STATE (task #15 knockdown; see AuthenticGroundAndCollide) ----
// The binary suppresses the mech's motion while it is CRASHED (spec: localVelocity
// = {0,0,-adv/dt} is ZEROED while crashed) so a knocked-down mech cannot creep back
// into the obstacle and re-trigger every frame. We reproduce that: while the crash
// clip (legState 0x20) plays, hold the velocity at zero.
// The knockdown must also only fire on a FRESH impact -- the frame the mech first
// strikes something -- NOT every frame it grinds against an obstacle it is already
// pressed into. Otherwise holding throttle into a wall/mech re-knocks-down every
// recovery (the stuttery leg flip-flop the user sees). gWasBlocked tracks last
// frame's contact so a continuous press just BLOCKS (stand/walk-in-place) and only a
// separate-then-re-approach produces a new knockdown.
// Block hysteresis: a knockdown fires only when the mech has been OUT of contact
// for kBlockHysteresis seconds (a genuine fresh approach). A 1-frame edge is too
// brittle -- a glancing / sliding contact alternates blocked/not-blocked (the frame
// rejection pushes the mech out each blocked frame), which would flicker the edge
// back to "fresh" and re-knockdown. The window bridges those gaps so any sustained
// OR intermittent press just BLOCKS; only separating for real re-arms the knockdown.
static Scalar gBlockCooldown = 0.0f; // seconds since the last blocking contact (counts down)
static const Scalar kBlockHysteresis = 0.4f;
// E8 weapon trigger (bring-up): pulsed per player frame; read by EmitterSimulation
// (emitter.cpp) so the real weapon's CheckFireEdge sees rising edges. Non-static
// (the emitter externs it).
int gBTWeaponTrigger = 0;
// Damage: each shot dispatches a REAL Entity::TakeDamageMessage to the target. Now
// that the damage zones are constructed (mech.cpp Pass-3 zone build), the engine base
// Entity::TakeDamageMessageHandler routes damageZones[zone]->TakeDamage -> the
// reconstructed Mech__DamageZone::TakeDamage (the real armor/structure model). We aim
// a VALID zone index (the Mech cylinder-lookup resolver for an unaimed/-1 hit is the
// STEP-6 reconstruction). Death is driven by the REAL model (Mech::IsDisabled, the
// vital-zone/leg/torso destroyed query FUN_0049fb54), NOT a hit counter.
static const Scalar kShotDamage = 12.0f; // per-burst damage amount (units = armor points)
static int gEnemyDestroyed = 0;
// BRING-UP: the spawned target/enemy mech (defined in btplayer.cpp). The player
// mech locks onto it as its current target each frame (see targeting step below).
extern Entity *gEnemyMech;
// gauge scoring wave: producers (btplayer.cpp) -- feed the scoreboard from combat.
extern void BTPostDamageScore(Entity *victim, Scalar damage); // per-hit SCORE (ScoreInflicted)
extern void BTPostKillScore(Entity *victim, Scalar damage); // KILL (+ MP death)
// Mech target slots (verified vs the binary's weapon/fire path, part_013.c):
// mech+0x37c Point3D current target world position (range/aim source)
// mech+0x388 Entity* current target entity (HasActiveTarget gate)
// mech+0x38c int targeted subsystem index (-1 = whole mech / none)
// These are undocumented in the reconstructed mech.hpp member map; the fire path
// (Emitter/MechWeapon) reads exactly these, so we write them directly.
#define MECH_TARGET_POS(m) (*(Point3D *)((char *)(m) + 0x37c))
#define MECH_TARGET_ENTITY(m) (*(Entity **)((char *)(m) + 0x388))
#define MECH_TARGET_SUBIDX(m) (*(int *)((char *)(m) + 0x38c))
// Bring-up body-animation player (file scope so OnBodyAnimFinished can re-arm it).
// The engine AnimationInstance walks the mech's joint subsystem from a baked .ani
// clip in btl4.res; we advance it each moving frame so the legs CYCLE.
static AnimationInstance *gBodyAnim = 0;
static int gBodyAnimReady = 0; // 0=untried 1=ok -1=failed
static Scalar gBodyAnimLog = 0.0f;
static int gBodyAnimLoops = 0; // count completed cycles (sanity)
static int gCurrentGait = -1; // P3 STEP 2: -1=none 0=walk 1=run (bound clip)
static const Scalar kRunThrottle = 0.5f; // |throttle| >= this -> run clip, else walk clip
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Mech::OnBodyAnimFinished -- AnimationInstance finished-callback.
// AnimationInstance::Animate invokes (moverToAnimate->*finishedCallback) when a
// clip runs off its last keyframe (JMOVER.cpp ~1592). The real game uses this to
// chain/loop gaits; for bring-up we LOOP the same clip by re-arming it at frame 0.
// Dropping the sub-frame carryover is imperceptible. Returns extra root movement
// contributed this call (0 -- the next frame's Animate carries the gait forward).
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Scalar
Mech::OnBodyAnimFinished(
ResourceDescription::ResourceID animation_number,
Scalar /*carryover*/, Logical /*animate_joints*/)
{
++gBodyAnimLoops;
if (gBodyAnim)
{
gBodyAnim->SetAnimation(
animation_number,
reinterpret_cast<JointedMover::AnimationCallback>(&Mech::OnBodyAnimFinished));
}
return 0.0f;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// BTResolveWeaponMuzzle -- the faithful FUN_004b9948 (MechWeapon::GetMuzzlePoint)
// muzzle resolve: look up the weapon's mount segment (index, from the subsystem's
// inherited this+0xdc slot) in the owner Mech's segment table and transform it to
// world. Lives here (a complete-Mech TU with the segment API); mechweap.cpp treats
// `owner` as a raw pointer so it calls this via a void* bridge instead of the Mech API.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
BTResolveWeaponMuzzle(void *ownerMech, int segIndex, Point3D &out)
{
Mech *m = (Mech *)ownerMech;
if (m == 0) { out = Point3D(0.0f, 0.0f, 0.0f); return; }
EntitySegment *seg = m->GetSegment(segIndex); // owner+0x300 table, GetNth(index)
if (seg != 0)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld); // segment -> world (== mech4 gun-port path)
out = mw; // Point3D = matrix W_Axis translation
}
else
out = m->localOrigin.linearPosition; // safe non-garbage fallback (owner origin)
}
// First vital damage-zone index (Mech__DamageZone::vitalDamageZone is protected; Mech has access).
int
Mech::FirstVitalZone() const
{
for (int k = 0; k < damageZoneCount; ++k)
if (Zone(k)->vitalDamageZone)
return k;
return 0;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Port-side tracked-projectile service (WAVE 7 Phase B -- flying missiles/rounds).
// The 1995 Projectile/Missile WORLD-ENTITY classes cannot be revived byte-exact:
// the 2007 engine Entity base is 0x1BC bytes vs the 1995 binary's 0x300, so the
// reconstruction's raw base-offset reads (velocity@0x1dc, roster@0x124, motion@
// 0x250) read garbage on the engine (the same 0x638-vs-0x854 gap the Mech has,
// but the entity integrator depends on those offsets). So a fired projectile is
// a PORT reconstruction (like the beam renderer): seeded from the launcher's fire
// with the decomp's real muzzle / launch velocity / per-shot damage, it flies
// toward the target (tracer via BTPushBeam) and delivers the weapon's damage on
// impact through the SAME Entity::TakeDamage path as the beam. The byte-exact
// world-entity Missile (Projectile : Mover, MP-replicable) is the deeper follow-up.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
struct BTProjectile {
Point3D pos;
Vector3D dir;
Scalar speed;
Scalar traveled;
Scalar range;
Entity *target;
Point3D targetPos;
Scalar damage;
int active;
};
static BTProjectile gProjectiles[64];
extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float);
// Called from ProjectileWeapon / MissileLauncher::FireWeapon (via the extern below)
// with the live muzzle, the SHOOTER mech (to resolve the real launch port), the owner's
// locked target entity + point, the launch speed (|muzzleVelocity|), and per-shot damage.
void
BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos,
Scalar speed, Scalar damage)
{
// The weapon's GetMuzzlePoint falls back to the mech ORIGIN (feet) when its mount
// segment doesn't resolve, so a projectile appeared to launch from the mech's feet.
// Resolve the real launch port by NAME off the shooter (same mechanism the visible
// laser beams use for the gun ports), alternating the left/right missile ports for a
// natural salvo look; fall back to a raised (torso-height) muzzle, then the passed one.
Point3D mz = muzzle;
if (shooter != 0)
{
Mech *sm = (Mech *)shooter;
static const char *const kPorts[] =
{ "sitermissleport", "sitelmissleport", "siterutorsoport", "sitelutorsoport",
"siterugunport", "sitelugunport" };
static int s_portRot = 0;
EntitySegment *seg = 0;
int n = (int)(sizeof(kPorts)/sizeof(kPorts[0]));
for (int k = 0; k < n && seg == 0; ++k)
{
s_portRot = (s_portRot + 1) % n;
seg = sm->GetSegment(CString(kPorts[s_portRot]));
}
if (seg != 0)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), sm->localToWorld); // port -> world
mz = mw; // W_Axis translation
}
else
{
mz = sm->localOrigin.linearPosition; // raise to torso height
mz.y += 12.0f;
}
}
// Bring-up: the mech's own target slot (owner+0x388) isn't populated (the visible
// fire path targets the gEnemyMech global directly), so fall back to it here.
Point3D tpos = targetPos;
if ((target == 0 || tpos.x == 0.0f) && gEnemyMech != 0)
{
target = gEnemyMech;
tpos = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
}
Vector3D d;
d.x = tpos.x - mz.x; d.y = tpos.y - mz.y; d.z = tpos.z - mz.z;
Scalar len = (Scalar)sqrtf(d.x*d.x + d.y*d.y + d.z*d.z);
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target << " enemy=" << (void*)gEnemyMech
<< " len=" << len << " speed=" << speed << " dmg=" << damage << "\n" << std::flush;
if (len < 0.001f) return;
for (int i = 0; i < 64; ++i)
{
if (gProjectiles[i].active) continue;
BTProjectile &p = gProjectiles[i];
p.pos = mz; // resolved launch port
p.dir.x = d.x/len; p.dir.y = d.y/len; p.dir.z = d.z/len;
p.speed = (speed > 1.0f) ? speed : 120.0f; // |launchVelocity|; sane fallback
p.traveled = 0.0f;
p.range = len + 40.0f; // impact by the target, else expire just past it
p.target = (Entity *)target;
p.targetPos = tpos; // resolved target position (fallback-aware)
p.damage = damage;
p.active = 1;
return;
}
}
// Per-frame (viewpoint mech): advance each projectile, draw its tracer, deliver damage on impact.
static void
BTUpdateProjectiles(Scalar dt)
{
for (int i = 0; i < 64; ++i)
{
BTProjectile &p = gProjectiles[i];
if (!p.active) continue;
Point3D prev = p.pos;
Scalar step = p.speed * dt;
p.pos.x += p.dir.x*step; p.pos.y += p.dir.y*step; p.pos.z += p.dir.z*step;
p.traveled += step;
// Missile look: a short smoky exhaust trail (behind) + a bright warm body + a
// hot flame tip, so it reads as a projectile rather than a thin laser line.
Vector3D bd = p.dir;
Point3D tail; tail.x = prev.x - bd.x*8.0f; tail.y = prev.y - bd.y*8.0f; tail.z = prev.z - bd.z*8.0f;
Point3D tip; tip.x = p.pos.x + bd.x*2.0f; tip.y = p.pos.y + bd.y*2.0f; tip.z = p.pos.z + bd.z*2.0f;
BTPushBeam(tail.x, tail.y, tail.z, prev.x, prev.y, prev.z, 0x00804020u, 0.20f, 3.2f); // dim smoke trail
BTPushBeam(prev.x, prev.y, prev.z, p.pos.x, p.pos.y, p.pos.z, 0x00FF6010u, 0.15f, 2.6f); // orange body
BTPushBeam(p.pos.x, p.pos.y, p.pos.z, tip.x, tip.y, tip.z, 0x00FFF0C0u, 0.15f, 1.4f); // hot flame tip
Scalar dx = p.targetPos.x - p.pos.x, dy = p.targetPos.y - p.pos.y, dz = p.targetPos.z - p.pos.z;
if (dx*dx + dy*dy + dz*dz < (10.0f*10.0f) || p.traveled >= p.range)
{
Entity *tgt = p.target;
// Deliver only to the known-valid enemy (bring-up), same zone/damage path as the beam.
if (tgt != 0 && tgt == gEnemyMech && p.damage > 0.0f)
{
Mech *m = (Mech *)tgt;
if (m->damageZoneCount > 0)
{
int zone = m->FirstVitalZone(); // concentrated fire -> a kill
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = p.damage;
dmg.burstCount = 1;
dmg.impactPoint = p.pos;
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
0 /*inflictor id: bring-up*/, zone, dmg);
tgt->Dispatch(&take_damage);
// gauge scoring wave (Step 6): a projectile hit credits SCORE too
// (tgt == gEnemyMech here; local player is the viewpoint shooter).
BTPostDamageScore((Entity *)tgt, p.damage);
DEBUG_STREAM << "[projectile] IMPACT damage=" << p.damage << " zone=" << zone << "\n" << std::flush;
}
}
p.active = 0;
}
}
}
void
Mech::PerformAndWatch(const Time& till, MemoryStream *update_stream)
{
// Frame time slice from the simulation clock (same idiom as Mover::Perform).
Scalar dt = till - lastPerformance;
lastPerformance = till;
// The bring-up DRIVE + ANIMATION path is single-player scaffolding: it reads
// the global gBTDrive and a single shared gBodyAnim, and integrates THIS body's
// origin. It must run ONLY for the local player's mech (the viewpoint entity);
// any other mech in the world (e.g. a spawned target/enemy) must NOT be driven
// by the player's input or share the player's AnimationInstance. Non-player
// mechs still fall through to the subsystem-roster tick below.
const Logical isPlayerMech =
(application != 0 && (Entity *)this == application->GetViewpointEntity());
// WAVE 7 Phase B: advance/render/impact the flying projectiles once per frame (viewpoint).
if (isPlayerMech && dt > 0.0001f)
BTUpdateProjectiles(dt);
// 1-Hz non-player mech telemetry (multiplayer diagnosis): every mech that is
// NOT the local viewpoint -- whatever its instance claims -- with position.
if (!isPlayerMech && getenv("BT_REPL_LOG") && dt > 0.0001f)
{
static float s_otherLog = 0.0f; s_otherLog += dt;
if (s_otherLog >= 1.0f)
{
s_otherLog = 0.0f;
DEBUG_STREAM << "[mech-exec] entity " << GetEntityID()
<< " instance=" << (int)GetInstance()
<< " pos=(" << localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.z << ")\n" << std::flush;
}
}
// --- REPLICANT MOTION (P6 multiplayer) -----------------------------------
// A remote-mastered mech does not run the local drive: it dead-reckons
// between network updates via the engine reckoner (Mover::DeadReckon lerps
// localOrigin toward projectedOrigin, which ReadUpdateRecord snaps from the
// master's broadcast update records -- MOVER.cpp:493). Subsystems still
// tick below (their ctors carry the replicant gates on simulationFlags).
if (GetInstance() == ReplicantInstance)
{
if (dt > 0.0001f && dt < 0.5f)
{
DeadReckon(dt); // engine: reckoner + lerp
localToWorld = localOrigin;
if (getenv("BT_REPL_LOG"))
{
static float s_replLog = 0.0f; s_replLog += dt;
if (s_replLog >= 1.0f)
{
s_replLog = 0.0f;
DEBUG_STREAM << "[repl] mech " << (long)GetEntityID()
<< " pos=(" << localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.y << ", "
<< localOrigin.linearPosition.z << ")\n" << std::flush;
}
}
}
}
// AUTHENTIC GROUND MODEL for NON-PLAYER masters (task #15): the binary
// installs FUN_004a9b5c as the performance of EVERY MasterInstance mech
// (ctor part_012.c:9947-9956), so the spawned test dummy grounds/collides
// too -- not just the player. The player's call runs inside the drive
// block below (after its move, with the true start-of-frame position).
if (!isPlayerMech && GetInstance() == MasterInstance && GroundReal()
&& dt > 0.0001f && dt < 0.5f)
{
// A stationary master: old_position is a COPY of the current position
// (must not alias localOrigin.linearPosition -- the block writes it).
Point3D preMovePos = localOrigin.linearPosition;
AuthenticGroundAndCollide(dt, preMovePos);
}
if (isPlayerMech && dt > 0.0001f && dt < 0.5f) // ignore zero / huge (stall) slices
{
// --- VIRTUAL CONTROLS (dev keyboard -> the pod's analog inputs) --------
// The pod's throttle was an ABSOLUTE analog LEVER and its turn input an
// analog stick; momentary 0/1 keys can't express either (at 60fps every
// tap = a frame-perfect FULL demand = the "controls too sensitive after
// the perf fix" report -- at the old 10fps most taps aliased away, which
// only LOOKED like fine control). Integrate dt-scaled (fps-independent):
// lever: W/S sweep a PERSISTENT lever (tap ~= 0.07 step, full sweep
// ~1.4s) with a DETENT at zero -- braking from forward stops AT
// 0; release and press S again to engage reverse. X = all stop.
// stick: A/D deflect a momentary stick over ~0.4s, auto-centering on
// release -- tap = gentle nudge, hold = full rate.
{
static float sLever = 0.0f, sStick = 0.0f;
static int sDetent = 0; // latched at the zero crossing
const float kLeverRate = 0.7f; // lever sweep per second
const float kStickRate = 2.5f; // stick deflect per second
const float kStickCenterRate = 5.0f; // stick auto-center per second
// POLL the real key state. WndProc key messages are unusable: the
// engine keyboard reader (L4CTRL.cpp:1506) GetMessage()s every
// WM_KEYUP/WM_CHAR out of the queue for its key-command channel, so
// only KEYDOWNs ever reached the WndProc and key state latched on
// forever. GetAsyncKeyState is immune; the foreground guard keeps
// background typing from driving the mech.
{
typedef short (__stdcall *AsyncFn)(int);
typedef void *(__stdcall *FgFn)(void);
typedef unsigned long (__stdcall *WtpFn)(void *, unsigned long *);
typedef unsigned long (__stdcall *PidFn)(void);
static AsyncFn pAsync = 0; static FgFn pFg = 0;
static WtpFn pWtp = 0; static PidFn pPid = 0;
if (pAsync == 0)
{
HMODULE u = GetModuleHandleA("user32.dll");
HMODULE k = GetModuleHandleA("kernel32.dll");
pAsync = (AsyncFn)GetProcAddress(u, "GetAsyncKeyState");
pFg = (FgFn)GetProcAddress(u, "GetForegroundWindow");
pWtp = (WtpFn)GetProcAddress(u, "GetWindowThreadProcessId");
pPid = (PidFn)GetProcAddress(k, "GetCurrentProcessId");
}
int focused = 0;
if (pFg && pWtp && pPid)
{
void *fg = pFg();
unsigned long fgPid = 0;
if (fg) pWtp(fg, &fgPid);
focused = (fgPid == pPid());
}
// TEST HOOK (BT_KEY_NOFOCUS=1): accept keys without foreground focus --
// automation harnesses (SendInput from a background shell) can't grant
// real foreground; interactive play never needs this.
static int sNoFocus = -1;
if (sNoFocus < 0) { const char *nf = getenv("BT_KEY_NOFOCUS"); sNoFocus = (nf && *nf == '1') ? 1 : 0; }
if (sNoFocus) focused = 1;
if (pAsync)
{
const int dn = 0x8000;
gBTDrive.keyFwd = focused && ((pAsync('W') | pAsync(0x26 /*VK_UP*/)) & dn) ? 1 : 0;
gBTDrive.keyBack = focused && ((pAsync('S') | pAsync(0x28 /*VK_DOWN*/)) & dn) ? 1 : 0;
gBTDrive.keyLeft = focused && ((pAsync('A') | pAsync(0x25 /*VK_LEFT*/)) & dn) ? 1 : 0;
gBTDrive.keyRight = focused && ((pAsync('D') | pAsync(0x27 /*VK_RIGHT*/)) & dn) ? 1 : 0;
gBTDrive.fire = focused && ((pAsync(0x20 /*VK_SPACE*/) | pAsync(0x11 /*VK_CONTROL*/)) & dn) ? 1 : 0;
static int sPrevX = 0;
const int xNow = focused && (pAsync('X') & dn) ? 1 : 0;
if (xNow && !sPrevX) gBTDrive.allStop = 1; // edge -> one all-stop
sPrevX = xNow;
}
}
if (gBTDrive.allStop) { sLever = 0.0f; sDetent = 0; gBTDrive.allStop = 0; }
if (getenv("BT_KEY_LOG"))
{
static float sKlog = 0.0f; sKlog += dt;
if (sKlog >= 1.0f) { sKlog = 0.0f;
DEBUG_STREAM << "[vctl] fwd=" << gBTDrive.keyFwd << " back=" << gBTDrive.keyBack
<< " L=" << gBTDrive.keyLeft << " R=" << gBTDrive.keyRight
<< " fire=" << gBTDrive.fire << " lever=" << sLever
<< " stick=" << sStick << "\n" << std::flush; }
}
const int fwd = gBTDrive.keyFwd, back = gBTDrive.keyBack;
if (!fwd && !back) sDetent = 0; // keys released -> detent re-arms
float sweep = ((fwd ? 1.0f : 0.0f) - (back ? 1.0f : 0.0f)) * kLeverRate * dt;
if (sweep != 0.0f && !sDetent)
{
const float prev = sLever;
sLever += sweep;
// zero detent: a sweep that CROSSES 0 stops there and latches until
// the keys are released (clean stop instead of swinging to reverse).
if ((prev > 0.0f && sLever <= 0.0f && back) ||
(prev < 0.0f && sLever >= 0.0f && fwd))
{
sLever = 0.0f;
sDetent = 1;
}
if (sLever > 1.0f) sLever = 1.0f;
if (sLever < -1.0f) sLever = -1.0f;
}
const float want = (gBTDrive.keyRight ? 1.0f : 0.0f) - (gBTDrive.keyLeft ? 1.0f : 0.0f);
if (want != 0.0f)
{
sStick += want * kStickRate * dt;
if (sStick > 1.0f) sStick = 1.0f;
if (sStick < -1.0f) sStick = -1.0f;
}
else if (sStick != 0.0f) // auto-center
{
const float step = kStickCenterRate * dt;
if (sStick > step) sStick -= step;
else if (sStick < -step) sStick += step;
else sStick = 0.0f;
}
gBTDrive.throttle = sLever;
gBTDrive.turn = sStick;
}
float throttle = gBTDrive.forced ? gBTDrive.forcedThrottle : gBTDrive.throttle;
float turn = gBTDrive.forced ? 0.0f : gBTDrive.turn;
// BT_SPAWN_AT="x z [hdg]" (DEBUG harness): teleport the player to a
// world position (+ optional heading, radians) on the first driven
// frame -- exact-position reproduction of a user-reported render
// glitch for A/B toggle comparison at the same viewpoint.
{
static int s_spawnAt = -1;
static float s_sx = 0.0f, s_sz = 0.0f, s_sh = 0.0f;
static int s_haveH = 0;
if (s_spawnAt < 0)
{
const char *sv = getenv("BT_SPAWN_AT");
if (sv)
{
const int n = sscanf(sv, "%f %f %f", &s_sx, &s_sz, &s_sh);
if (n >= 2) { s_spawnAt = 1; s_haveH = (n >= 3); }
else s_spawnAt = 0;
}
else s_spawnAt = 0;
}
if (s_spawnAt == 1)
{
s_spawnAt = 2;
localOrigin.linearPosition.x = s_sx;
localOrigin.linearPosition.z = s_sz;
if (s_haveH)
{
gDriveHeading = s_sh;
// the mech's REAL orientation (the chase camera derives from
// it) is the quaternion, not the scalar mirror -- set both,
// same convention as the bring-up drive path below.
localOrigin.angularPosition = EulerAngles(0.0f, s_sh, 0.0f);
}
localToWorld = localOrigin;
DEBUG_STREAM << "[spawnat] teleported to (" << s_sx << ", " << s_sz
<< ") hdg=" << (s_haveH ? s_sh : gDriveHeading) << "\n" << std::flush;
}
}
// BRING-UP repro harness: BT_FORCE_SECONDS=<n> releases the forced throttle
// after n sim-seconds (headless stand-in for an interactive tap->release,
// exercising the walk->stop gait transition the constant-throttle soaks
// never take); BT_FORCE_TURN=<t> holds a steering demand (the turn path).
if (gBTDrive.forced)
{
static float s_forceClock = 0.0f, s_forceLimit = -1.0f, s_forceTurn = -999.0f;
if (s_forceLimit < 0.0f)
{
const char *fs = getenv("BT_FORCE_SECONDS");
s_forceLimit = fs ? (float)atof(fs) : 0.0f;
}
if (s_forceTurn < -900.0f)
{
const char *ft = getenv("BT_FORCE_TURN");
s_forceTurn = ft ? (float)atof(ft) : 0.0f;
}
turn = s_forceTurn;
if (s_forceLimit > 0.0f)
{
s_forceClock += dt;
if (s_forceClock > s_forceLimit)
{
throttle = 0.0f;
turn = 0.0f;
}
}
// BT_GOTO="x z" (DEBUG harness): beeline toward a world point --
// steer the turn demand from the heading error each frame (headless
// reproduction of "run toward that feature"). Uses the scalar
// heading mirror gDriveHeading (forward = -Z at heading 0).
{
static float s_gx = 0.0f, s_gz = 0.0f;
static int s_goto = -1;
if (s_goto < 0)
{
const char *gv = getenv("BT_GOTO");
if (gv && sscanf(gv, "%f %f", &s_gx, &s_gz) == 2) s_goto = 1;
else s_goto = 0;
}
if (s_goto == 1)
{
float ddx = s_gx - (float)localOrigin.linearPosition.x;
float ddz = s_gz - (float)localOrigin.linearPosition.z;
float want = (float)atan2(-ddx, -ddz); // heading with fwd=-Z
float err = want - gDriveHeading;
while (err > 3.14159265f) err -= 6.2831853f;
while (err < -3.14159265f) err += 6.2831853f;
// publish for the mapper bridge (the real-controls path reads
// gBTDrive in mechmppr.cpp, NOT this local `turn`)
gBTGotoTurn = (err > 0.3f) ? 1.0f : (err < -0.3f ? -1.0f
: (err > 0.02f ? 0.3f : (err < -0.02f ? -0.3f : 0.0f)));
if (ddx * ddx + ddz * ddz < 25.0f) gBTGotoTurn = 0.0f;
// throttle down while far off-heading (the authentic
// speed-vs-turn clamp makes run-speed turn circles huge)
gBTGotoThrottle = (err > 0.5f || err < -0.5f) ? 0.2f : 1.0f;
gBTGotoActive = 1;
turn = gBTGotoTurn; // non-real-controls fallback path
}
}
}
// ⭐ REAL CONTROLS (env BT_REAL_CONTROLS): route the raw input through the
// RECONSTRUCTED MechControlsMapper (@004afd10) instead of consuming it raw.
// INPUT BRIDGE (dev scaffolding, marked): write the mapper's published input
// attributes exactly as a streamed Direct .CTL mapping would -- the engine's
// ControlsInstanceDirectOf<T> writes these same public members from the
// device groups; the dev box has no RIO/Thrustmaster, so WASD stands in for
// the stick/throttle HARDWARE. Everything downstream (speed/turn demands,
// mode clamps, torso axes, HUD free-aim) is the real reconstructed tick,
// which runs in the subsystem-roster walk below (un-skipped under this env).
static const int s_realControls = BTEnvOn("BT_REAL_CONTROLS", 1); // default ON (=0 to disable)
if (s_realControls && controlsMapper != 0)
{
// Diagnostic: what the ENGINE controls push left in the attribute since
// our last write (a stale device element overwriting the bridge shows
// here as pre != our previous write).
float preThrottle = controlsMapper->throttlePosition;
(void)preThrottle;
controlsMapper->throttlePosition = (throttle >= 0.0f) ? throttle : -throttle;
controlsMapper->reverseThrust = (throttle < 0.0f) ? 1 : 0; // ControlsButton: >=1 engaged
controlsMapper->stickPosition.x = turn; // Basic mode: turn = stick yaw
controlsMapper->stickPosition.y = 0.0f;
// Consume the PREVIOUS frame's interpreted demands (the mapper ticks in
// the roster walk after this block -- one frame of input latency).
// turnDemand is the mode-shaped steering; speedDemand (world u/s, sign =
// reverse) feeds the gait target below.
turn = controlsMapper->turnDemand;
static float s_mpprLog = 0.0f; s_mpprLog += dt;
if (s_mpprLog >= 1.0f)
{
s_mpprLog = 0.0f;
DEBUG_STREAM << "[mppr] in thr=" << controlsMapper->throttlePosition
<< " pre=" << preThrottle
<< " rev=" << controlsMapper->reverseThrust
<< " stickX=" << controlsMapper->stickPosition.x
<< " -> speedDemand=" << controlsMapper->speedDemand
<< " turnDemand=" << controlsMapper->turnDemand
<< " mode=" << controlsMapper->controlMode << "\n" << std::flush;
}
}
// BRING-UP one-shot: dump the mech's skeleton segment names + joint indices so
// we can identify the TORSO horizontal joint for torso-twist tracking (P3).
{
static int s_segDump = 0;
if (s_segDump++ == 0)
{
EntitySegment::SegmentTableIterator it(segmentTable);
EntitySegment *seg;
int i = 0;
while ((seg = it.ReadAndNext()) != 0)
{
DEBUG_STREAM << "[skel] seg[" << i++ << "] name=" << seg->GetName()
<< " jointIdx=" << seg->GetJointIndex() << "\n";
}
DEBUG_STREAM << "[skel] total=" << i << "\n" << std::flush;
}
}
// Snapshot the pre-move position for the collision resolve after the drive.
Point3D collisionOldPos = localOrigin.linearPosition;
if (!gDriveSeeded) { gDriveHeading = 0.0f; gDriveSeeded = 1; }
// --- turn: integrate heading, write it onto the body orientation -----
gDriveHeading += turn * kDriveTurnRate * dt; // scalar mirror (drive log + fallback)
if (s_realControls)
{
// AUTHENTIC ORIENTATION INTEGRATION (the IntegrateMotion-tail form):
// compose the yaw RATE into the pose quaternion via the engine
// integrate op (Quaternion::Add == FUN_00409f58) -- the same op the
// binary's motion tail uses -- instead of rebuilding the orientation
// from a scalar heading. The rate SCALE (kDriveTurnRate) remains a
// bring-up constant; the authentic per-mech turn-rate parameter is
// part of the deferred full-IntegrateMotion work.
Vector3D angStep(0.0f, turn * kDriveTurnRate * dt, 0.0f);
Quaternion prevPose = localOrigin.angularPosition;
localOrigin.angularPosition.Add(prevPose, angStep);
}
else
{
localOrigin.angularPosition = EulerAngles(0.0f, gDriveHeading, 0.0f);
}
localToWorld = localOrigin; // build the world matrix
// --- forward step: the mech faces local -Z (gun ports / eyepoint are at
// -Z; see btl4vid.cpp). Take the matrix Z basis in world and negate
// to get the facing direction; the ACTUAL forward distance this frame
// is the animation's root translation (see the Animate block below) --
// P3 STEP 1: locomotion is now ANIMATION-DRIVEN, not a procedural slide,
// so the body advances exactly as far as the gait clip's feet step
// (feet plant, no skating). The old `kDriveMaxSpeed * throttle * dt`
// slide is removed; `adv` from AnimationInstance::Animate replaces it.
UnitVector zAxis;
localToWorld.GetFromAxis(Z_Axis, &zAxis);
const float fx = -zAxis.x;
const float fz = -zAxis.z;
// --- P3 STEP 2: GAIT SELECTION by throttle. Pick the body clip from throttle
// magnitude -- |throttle| >= kRunThrottle -> RUN (blhrrl), else WALK (blhwwl);
// each moves at ITS clip's authentic root-translation speed (STEP 1). Throttle
// SIGN gives direction: negative -> reverse (back up). (The blh set has no
// dedicated reverse CYCLE clip -- stand/walk/run + transitions only -- so reverse
// is a bring-up: it plays the selected forward clip and drives the body backward.)
// SetAnimation only on a gait CHANGE (re-binding every frame would reset the phase).
//
// ⚠ GAIT MUST TICK EVERY FRAME (task #15 fix): this block was gated on
// `throttle != 0`, so releasing the key stopped CALLING the state machine --
// the skeleton froze at the last written pose and the authentic walk->
// stop->stand transition chain (BodyClipFinished states 8/9, the wsl/wsr
// clips, which END in the standing pose) never got the chance to play.
// The SM handles idle natively: bodyTargetSpeed=0 drives walk->stop->stand
// and the Standing case returns adv=0 (no motion). Only the LEGACY inline
// clip-select path (BT_GAIT_SM=0) still needs the throttle gate (it would
// loop the walk clip forever at idle).
{
static const int s_gaitTicksAlways = BTEnvOn("BT_GAIT_SM", 1);
if (s_gaitTicksAlways || throttle != 0.0f)
{
const int wantGait = ((throttle < 0.0f ? -throttle : throttle) >= kRunThrottle) ? 1 : 0;
const char *suffix = wantGait ? "rrl" : "wwl";
// ⭐ P3 STEP-7 CUTOVER (env BT_GAIT_CUTOVER; default OFF -> STEP 1-2 path).
// Drive locomotion via the reconstructed two-channel gait (the real
// SequenceController bodyAnimation), replacing the STEP-1/2 free-standing
// AnimationInstance stand-in. bodyAnimation.Advance runs the reconstructed
// controller: it animates the skeleton joints AND returns the clip's
// per-frame root-translation forward step (applied to localOrigin).
static const int s_gaitCutover = BTEnvOn("BT_GAIT_CUTOVER", 1); // default ON (=0 to disable)
if (s_gaitCutover)
{
static const int s_gaitSM = BTEnvOn("BT_GAIT_SM", 1); // default ON (=0 to disable)
Scalar adv;
Scalar legAdv = 0.0f; // leg-channel distance (drives the LOCAL mech)
if (s_gaitSM)
{
// STEP 2: drive the REAL gait STATE MACHINE (Mech::AdvanceBodyAnimation,
// mech2.cpp) instead of the inline clip-select. It re-syncs bodyAnimation
// State from bodyStateAlarm, slews bodyCycleSpeed toward bodyTargetSpeed with
// the LoadLocomotionClips speed caps, and Advances the bound clip. Feed the
// inputs the controls mapper normally would (it is bypassed in bring-up) and
// force the walk-cycle state 6 (case 6/7 -> slew to walkStrideLength + Advance
// animationClips[6]=wwr). Requires BT_GAIT_CUTOVER too (loads the clips).
globalTimeScale = 1.0f;
idleStrideScale = 1.0f;
// forwardCycleRate: read from the model record (+0x44) in the
// ctor, but the Blackhawk's value decodes to ~1 u/s^2 -- a
// 55-SECOND ramp to run speed (the mech "couldn't go forward"
// and the clip crawled in slow motion). Either the record
// field is mis-offset or its units differ (per-frame?). Until
// the field decode is verified against the raw parser, FLOOR
// the rate at a pod-plausible 25 u/s^2 (0 -> run in ~2.5s);
// values above that (a genuine tuning read) pass through.
{
static int s_rateLogged = 0;
if (!s_rateLogged)
{
s_rateLogged = 1;
DEBUG_STREAM << "[gait] model accel read=" << forwardCycleRate
<< " (floored to >=25)" << "\n" << std::flush;
}
}
if (!(forwardCycleRate >= 25.0f && forwardCycleRate < 10000.0f))
forwardCycleRate = 25.0f;
movementMode = 1; // ground, non-death, non-airborne
// reverseSpeedMax2@0x7a0 is the run-cycle bodyCycleSpeed CLAMP (AdvanceBody
// Animation case 12/13); LoadLocomotionClips does not set it -> it reads
// 0xCDCDCDCD (-4.3e8) and the clamp clobbers bodyCycleSpeed -> the run cycle
// explodes. Bring-up: clamp the run cadence to the run stride (reverseStride
// Length, the case-12 divisor) so bodyCycleSpeed/reverseStrideLength ~ 1, like
// walk. (The authentic value comes from the model/LoadLocomotionClips.)
reverseSpeedMax2 = reverseStrideLength;
// Gait SELECTION by throttle: walk aims for walkStrideLength; run aims for
// reverseSpeedMax (the run/reverse cap). Exceeding walkStrideLength makes the
// walk handler transition "up" to the run cycle (state 11 -> 12/13) via the real
// BodyClipFinished callback -- i.e. the authentic walk->run transition.
// REAL CONTROLS: the commanded speed comes from the reconstructed mapper's
// speedDemand (topSpeed * throttle * scale, mode-clamped vs steering -- the
// AUTHENTIC gait selection input; |.| because sign encodes reverse).
// SIGNED demand (task #15): the earlier Abs() stripped the sign, so
// the body/motion channel could never see reverse -- motion direction
// then had to come from the raw throttle sign, flipping INSTANTLY
// while the animation transitioned gradually (the "physics don't
// line up with the animation" desync). Signed, the SM decelerates
// through stop -> reverse-entry exactly like the leg channel.
if (s_realControls && controlsMapper != 0)
bodyTargetSpeed = controlsMapper->speedDemand;
else
bodyTargetSpeed = ((throttle < 0.0f) ? -1.0f : 1.0f)
* (wantGait ? reverseSpeedMax : walkStrideLength);
if (gCurrentGait != 6)
{
bodyCycleSpeed = 0.0f; // slew up from rest
// Enter at STANDING (task #15): both channels' case-0 handlers
// self-arm stand->walk when the demand rises past standSpeed
// (AdvanceBodyAnimation case 0 == raw FUN_004a5678), so the boot
// state is the authentic idle stand -- no pre-armed walk clip
// stuck at frame 1 while waiting for the first throttle.
bodyStateAlarm.SetLevel(0);
if (s_realControls)
{
// AUTHENTIC TWO-CHANNEL SPLIT: the LEG channel enters
// NATURALLY from Standing (state 0): AdvanceLegAnimation's
// case-0 sees the live mapper speedDemand > standSpeed and
// arms stand->walk (5); the real leg finished-callback
// (LegClipFinished == FUN_004a6928) then chains the
// transitions. Only hygiene here: a clean cycle-speed start.
legCycleSpeed = 0.0f;
}
gCurrentGait = 6;
DEBUG_STREAM << "[gaitSM] enter walk-6 walkStride=" << walkStrideLength
<< " reverseSpeedMax=" << reverseSpeedMax << " target=" << bodyTargetSpeed
<< " split=" << s_realControls << "\n" << std::flush;
}
if (s_realControls)
{
// FOOT-PLANT FIDELITY (raw FUN_004ab430:15076 -> FUN_004ab1c8):
// the binary advances the BODY controller with move_joints=1 --
// the DISPLAYED pose and the travel distance come from the SAME
// Advance (same clip, same phase), so feet plant by construction.
// The leg channel (local-sim, hardcoded move_joints=1 in the raw,
// FUN_004a5028:12006) runs FIRST; the body's writes land after and
// win the frame, exactly the binary's order. The earlier split
// (body silenced with move_joints=0, leg pose displayed) showed
// leg-channel joints against body-channel travel -- two
// independently-phased state machines -> visible foot glide.
legAdv = AdvanceLegAnimation(dt); // channel A: local sim (live demand)
adv = AdvanceBodyAnimation(dt, 1); // channel B: displayed pose + motion
}
else
{
adv = AdvanceBodyAnimation(dt, 1); // single-channel: body does both
}
// COCKPIT BOB (task #15): the leg channel writes the clip's vertical
// root motion into jointlocal (balltranslate) every frame, but the
// camera's DCS chain doesn't consume animated joints -- publish the
// baseline-relative bob for DPLEyeRenderable (L4VIDRND.cpp) to add
// to the view. Baseline = the first sample after the joint resolves
// (the clip's neutral root height).
{
extern float gBTEyeBobY;
extern float gBTEyeSwayX;
static Joint *s_rootJt = 0; static int s_rootTried = 0;
static float s_bobBase = 0.0f, s_swayBase = 0.0f; static int s_bobBased = 0;
if (!s_rootTried) { s_rootTried = 1; s_rootJt = ResolveJoint("jointlocal"); }
if (s_rootJt)
{
const Point3D rt = s_rootJt->GetTranslation();
if (!s_bobBased) { s_bobBased = 1; s_bobBase = rt.y; s_swayBase = rt.x; }
gBTEyeBobY = rt.y - s_bobBase;
gBTEyeSwayX = rt.x - s_swayBase; // lateral weight shift (the swagger)
}
}
static float s_smlog = 0.0f; s_smlog += dt;
static double s_advSum = 0.0, s_legSum = 0.0;
s_advSum += adv; s_legSum += legAdv;
if (s_smlog >= 1.0f) { s_smlog = 0.0f;
extern float gBTEyeBobY;
static Joint *s_dbgRoot = 0; static int s_dbgTried = 0;
if (!s_dbgTried) { s_dbgTried = 1; s_dbgRoot = ResolveJoint("jointlocal"); }
Point3D dbgRt(0,0,0);
if (s_dbgRoot) dbgRt = s_dbgRoot->GetTranslation();
DEBUG_STREAM << "[sync] advSum=" << (float)s_advSum
<< " legSum=" << (float)s_legSum
<< " posZ=" << localOrigin.linearPosition.z
<< " rootZ=" << dbgRt.z << " rootX=" << dbgRt.x << "\n" << std::flush;
DEBUG_STREAM << "[gaitSM] adv=" << adv << " legAdv=" << legAdv
<< " cycleSpeed=" << bodyCycleSpeed << " legCycle=" << legCycleSpeed
<< " state=" << bodyAnimationState << " legState=" << legAnimationState
<< " kfCur=" << bodyAnimation.currentFrame
<< " bob=" << gBTEyeBobY << "\n" << std::flush; }
}
else
{
if (gCurrentGait != wantGait)
{
ResourceDescription::ResourceID *clip = ResolveAnimationClip("blh", suffix);
if (clip)
{
// loop callback: SequenceController::Advance calls it at end-of-clip
// to re-arm the same clip (bring-up loop; see Mech::LoopBodyClip).
bodyAnimation.SelectSequence(*clip, (void *)&Mech::LoopBodyClip, 0, 0);
gCurrentGait = wantGait;
DEBUG_STREAM << "[gait] SequenceController -> blh" << suffix
<< " id=" << (long)*clip << " (" << (wantGait ? "run" : "walk")
<< ")\n" << std::flush;
}
}
adv = bodyAnimation.Advance(dt, 1); // inline clip-select path
}
// Motion direction comes FROM the animation: reverse clips carry
// negative root translation, so adv is already signed. (dir kept
// only for the legacy non-SM path, whose forward clips are unsigned.)
const float dir = (s_gaitCutover && BTEnvOn("BT_GAIT_SM", 1))
? 1.0f
: ((throttle < 0.0f) ? -1.0f : 1.0f);
// --- AUTHENTIC world-step (Mech::IntegrateMotion tail @004ab1c8) ---
// The 1995 mech does NOT slide the position directly; it integrates a
// VELOCITY vector rotated by the body orientation. VERIFIED mapping: the
// 1995 motion transform { Point3D @0x260; Quaternion @0x26c } IS the engine
// `localOrigin` (Origin = { linearPosition; angularPosition }, ORIGIN.h:15;
// FUN_0040ab44 builds the matrix from BOTH halves). So reproduce the real
// integrator: localVelocity = {0,0,-adv/dt} (forward = local -Z), rotate it
// into world space by the orientation via the now-backed world-step transform
// FUN_00408744, then position += worldVelocity*dt. Result == facing*adv, but
// through the authentic velocity->rotate->integrate model (runs FUN_00408744
// live). Remaining full-IntegrateMotion work: velocity STORAGE for dead-reckon,
// orientation integration, and the AdvanceBodyAnimation gait state machine
// (+ LoadLocomotionClips) in place of the inline clip select above.
const Scalar invDt = (dt > 0.0f) ? (1.0f / dt) : 0.0f;
// VELOCITY SMOOTHING (task #15, the binary's IntegrateMotion model):
// SequenceController::Advance returns distance only when keyframes
// are CROSSED -- partial frames interpolate the joints but add zero
// distance (binary-faithful lumping). Integrating adv/dt directly
// stutters the ground speed while the legs sweep smoothly (feet
// appear to glide/skate). The original smoothed this through its
// persistent-velocity integration; reproduce it: velocity = the
// accumulated distance over the elapsed time since the last
// keyframe contribution, HELD across zero-distance frames, hard
// zero when Standing (and after 0.3s of silence, e.g. paused clip).
// FOOT-PLANT BY CONSTRUCTION (v4 -- BINARY-FAITHFUL: travel = the
// BODY channel, the SAME Advance that writes the displayed joints;
// raw FUN_004ab430:15076 advances the body with move_joints=1 and
// takes the travel from it). v3 sourced travel from the LEG channel
// while the BODY drew the joints -- TWO state machines that could
// (and did) drift apart whenever any event touched one channel's
// state without the other: the knockdown (fixed by staggering both),
// then AGAIN on the Mad Cat / analog-lever sweeps crossing gait
// boundaries (each channel's end-of-clip callback samples the demand
// at a different instant, so near a walk/run threshold they can pick
// DIFFERENT next states -> permanent phase split = the foot-slip).
// Sourcing travel from the displayed channel kills the whole desync
// CLASS: display == travel by construction. The earlier "tunnels
// through obstacles" objection to this is OBSOLETE -- the knockdown
// now staggers the BODY channel too (SetBodyAnimation(0x20)), so a
// hard impact freezes travel exactly as before (re-verified). The
// leg channel keeps running as the local sim it is in the binary
// (its joint writes land first and are overwritten by the body's).
const Scalar localAdv = adv * dir;
linearSpeed = (localAdv < 0.0f ? -localAdv : localAdv) * invDt; // forward ground speed -> LinearSpeed gauge
Vector3D localVel(0.0f, 0.0f, -localAdv * invDt); // exact frame distance as velocity
Matrix34 orient; // rotation from the heading (set @ line ~626)
Matrix34::FromQuaternion(&orient, &localOrigin.angularPosition);
Vector3D worldVel;
FUN_00408744(&worldVel, (Scalar *)&localVel, &orient); // worldVel = orient * localVel
localOrigin.linearPosition.x += worldVel.x * dt;
localOrigin.linearPosition.y += worldVel.y * dt;
localOrigin.linearPosition.z += worldVel.z * dt;
localToWorld = localOrigin;
gBodyAnimLog += dt;
if (gBodyAnimLog >= 1.0f)
{
gBodyAnimLog = 0.0f;
DEBUG_STREAM << "[gait] adv=" << adv << " pos=("
<< localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.z << ")\n" << std::flush;
}
}
else
{
if (gBodyAnim == 0 || gCurrentGait != wantGait) // first time, or gait changed
{
ResourceDescription::ResourceID *clip = ResolveAnimationClip("blh", suffix);
if (clip)
{
if (gBodyAnim == 0) gBodyAnim = new AnimationInstance(this);
// LOOP callback (not NULL) -- Animate invokes it at clip end
// (JMOVER ~1592); NULL there crashes on a null member ptr.
gBodyAnim->SetAnimation(
*clip,
reinterpret_cast<JointedMover::AnimationCallback>(&Mech::OnBodyAnimFinished));
gCurrentGait = wantGait;
gBodyAnimReady = 1;
DEBUG_STREAM << "[anim] gait -> blh" << suffix << " id=" << (long)*clip
<< " (" << (wantGait ? "run" : "walk") << ")\n" << std::flush;
}
else
{
gBodyAnimReady = -1;
DEBUG_STREAM << "[anim] blh" << suffix << " unresolved -- not moving\n" << std::flush;
}
}
if (gBodyAnimReady == 1 && gBodyAnim != 0)
{
// Animate advances the clip, writes the joint DCS (legs cycle) AND returns
// the clip's per-frame root-translation distance (the [RootTranslation]
// integral -- CLAUDE.md section 7). Drive the body by exactly that distance
// so travel == foot stride (feet plant); negative throttle backs it up.
Scalar adv = gBodyAnim->Animate(dt, True);
// Motion direction comes FROM the animation: reverse clips carry
// negative root translation, so adv is already signed. (dir kept
// only for the legacy non-SM path, whose forward clips are unsigned.)
const float dir = (s_gaitCutover && BTEnvOn("BT_GAIT_SM", 1))
? 1.0f
: ((throttle < 0.0f) ? -1.0f : 1.0f);
localOrigin.linearPosition.x += fx * adv * dir;
localOrigin.linearPosition.z += fz * adv * dir;
linearSpeed = (dt > 0.0f) ? ((adv < 0.0f ? -adv : adv) / dt) : 0.0f; // LinearSpeed gauge
localToWorld = localOrigin; // rebuild with the new position
gBodyAnimLog += dt;
if (gBodyAnimLog >= 1.0f)
{
gBodyAnimLog = 0.0f;
DEBUG_STREAM << "[anim] " << (wantGait ? "run" : "walk")
<< (dir < 0 ? " (reverse)" : "") << " adv=" << adv
<< " loops=" << gBodyAnimLoops
<< " pos=(" << localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.z << ")\n" << std::flush;
}
}
} // close the BT_GAIT_CUTOVER else (STEP 1-2 path)
} // close the gait-ticks-always gate
} // close the every-frame gait scope (task #15 fix)
// DIAG (turn-hitch hunt): flag any frame that took abnormally long -- the
// hang shows up as a big dt on the NEXT tick. Correlate with [loadobj].
if (dt > 0.2f)
DEBUG_STREAM << "[spike] dt=" << dt
<< " turn=" << turn << " thr=" << throttle << "\n" << std::flush;
// --- 1 Hz world-position log while moving (proves the body walks) -----
gDriveLogAccum += dt;
if ((throttle != 0.0f || turn != 0.0f) && gDriveLogAccum >= 1.0f)
{
gDriveLogAccum = 0.0f;
DEBUG_STREAM << "[drive] pos=("
<< localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.y << ", "
<< localOrigin.linearPosition.z << ") hdg=" << gDriveHeading
<< " thr=" << throttle << " turn=" << turn
<< " dt=" << dt << "\n" << std::flush;
}
// --- COLLISION (engine Mover flow; gated BT_COLLISION) ------------------
// After the drive moved localOrigin, resolve penetration against the world's
// collision solids (terrain buttes/hills + buildings) with the engine's OWN
// pipeline (MOVER.cpp): reposition the collision volume to the new transform,
// gather overlapping solids, and let ProcessCollisionList push localOrigin back
// out of anything it entered (it writes localOrigin.linearPosition + reads
// worldLinearVelocity). This is what stops the mech phasing through objects and
// sinking through the ground. The dead Mech::Simulate used a 1995 heightfield
// terrain-follow (FUN_0040e5f0); the 2007 engine models terrain AS collision
// solids, so this single path covers both terrain-follow AND object collision.
// Guarded on a real collision volume -> no-op for volume-less mechs.
// --- VELOCITY STORAGE (the MP update writer; ALWAYS maintained) ---------
// The engine Mover::WriteUpdateRecord (MOVER.cpp:740) publishes
// localVelocity + worldLinearVelocity in every network update record --
// the data remote pods dead-reckon from. Keep them live each frame:
// world velocity from the frame's position delta; local velocity =
// forward speed on local -Z (the mech's facing axis) + the yaw rate.
{
const Scalar invDtV = (dt > 0.0001f) ? (1.0f / dt) : 0.0f;
worldLinearVelocity.x = (localOrigin.linearPosition.x - collisionOldPos.x) * invDtV;
worldLinearVelocity.y = (localOrigin.linearPosition.y - collisionOldPos.y) * invDtV;
worldLinearVelocity.z = (localOrigin.linearPosition.z - collisionOldPos.z) * invDtV;
Scalar fwdSpeed = (Scalar)sqrtf(
worldLinearVelocity.x * worldLinearVelocity.x +
worldLinearVelocity.z * worldLinearVelocity.z);
localVelocity.linearMotion = Vector3D(0.0f, worldLinearVelocity.y, -fwdSpeed);
localVelocity.angularMotion = Vector3D(0.0f, turn * kDriveTurnRate, 0.0f);
// CRASH motion suppression (spec: the binary zeroes localVelocity while
// crashed). While the stagger clip (legState 0x20) plays, freeze the
// velocity so a knocked-down mech does not creep back into the obstacle and
// re-detect an impact every frame (impactVel = localVelocity below). Then
// run the post-recovery refractory so it can't instantly re-knockdown.
if ((int)legStateAlarm.GetLevel() == 0x20)
{
worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f);
localVelocity.linearMotion = Vector3D(0.0f, 0.0f, 0.0f);
}
}
if (GroundReal())
{
// AUTHENTIC GROUND MODEL (task #15, ground-model-decode): the binary's
// probe/snap/response block (FUN_004a9b5c @4aa630-4aab5f) -- see the
// AuthenticGroundAndCollide banner below. NO gravity, NO floor clamp,
// NO step guard in this path; a probe MISS (h==-1) does NOTHING (Y
// holds -- byte-faithful, also correct over authentic no-solid content
// like the rav canyon backdrops / arena detail pieces).
AuthenticGroundAndCollide(dt, collisionOldPos);
}
else if (BTEnvOn("BT_COLLISION", 1) && GetCollisionVolumeCount() > 0)
{
// GRAVITY / terrain-settle: the collision below only pushes the mech OUT of
// solids -- there is no downward force, so a floating mech never comes down.
// Press it down each frame; the collision then holds it at the terrain surface
// (gravity down + collision up = equilibrium ON the ground). A FLOOR CLAMP at
// the spawn/ground Y is the safety net: on a map with NO terrain solid the mech
// rests at ground level instead of falling forever. On real terrain (modeled as
// solids) the collision catches it above the floor -> it follows hills/valleys.
// kGravityRate world-units/sec (tunable via BT_GRAVITY).
static Scalar s_groundY = 0.0f; static int s_groundYSet = 0;
static const Scalar kGravityRate =
getenv("BT_GRAVITY") ? (Scalar)atof(getenv("BT_GRAVITY")) : 20.0f;
if (!s_groundYSet) { s_groundY = localOrigin.linearPosition.y; s_groundYSet = 1; }
localOrigin.linearPosition.y -= kGravityRate * dt; // fall
const Scalar invDtC = (dt > 0.0f) ? (1.0f / dt) : 0.0f;
worldLinearVelocity.x = (localOrigin.linearPosition.x - collisionOldPos.x) * invDtC;
worldLinearVelocity.y = (localOrigin.linearPosition.y - collisionOldPos.y) * invDtC;
worldLinearVelocity.z = (localOrigin.linearPosition.z - collisionOldPos.z) * invDtC;
MoveCollisionVolume(); // reposition the volume to localToWorld
BoxedSolidCollisionList *cols = GetCurrentCollisions();
if (cols)
{
Point3D before = localOrigin.linearPosition;
Damage collisionDamage; // filled by ProcessCollisionList (unused)
ProcessCollisionList(cols, dt, collisionOldPos, &collisionDamage); // pushes localOrigin out
Scalar dx = localOrigin.linearPosition.x - before.x;
Scalar dz = localOrigin.linearPosition.z - before.z;
if (dx*dx + dz*dz > 0.01f) // log only real object/wall pushes (not ground settle)
DEBUG_STREAM << "[collide] pushed out by (" << dx << ", "
<< (localOrigin.linearPosition.y - before.y) << ", " << dz
<< ") pos=(" << localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.z << ")\n" << std::flush;
}
// Floor clamp: never sink below the base ground (safety on solid-less maps).
if (localOrigin.linearPosition.y < s_groundY)
localOrigin.linearPosition.y = s_groundY;
localToWorld = localOrigin; // rebuild after gravity/collision/clamp
}
// LOD EYEPOINT (authentic): feed the renderer the MECH's eyepoint as the
// LOD-distance reference. The pod's eyepoint sat ON the mech (cockpit),
// so turning in place never changed an object's LOD distance; our chase
// camera ORBITS the mech +-40u as it turns, which swept objects near
// their 0x2046 band edges in and out = "scenery blinks with viewing
// angle" / floor flicker at the arena fringe.
{
extern void BTSetLodEye(float, float, float);
BTSetLodEye((float)localOrigin.linearPosition.x,
(float)localOrigin.linearPosition.y + 7.0f, // cockpit height
(float)localOrigin.linearPosition.z);
}
// SHADOW TILT (task #20 + #15): pose 'jointshadow' (the *_tshd proxy's
// terrain-angle channel; SKL "apply terrain angle to pitch and roll") to the
// LOCAL ground slope each frame. The ground decode (#15) is now live, so
// instead of the old flat-up placeholder we sample the collision surface at
// the mech's XZ and two world-axis offsets, build the surface normal from the
// height gradient, and rotate it into the mech's local frame (upright, yaw =
// gDriveHeading). On a slope the shadow then LIES ON the ground; the old flat
// quad was buried in the hillside and Z-culled -> "the shadow disappears on
// elevation" (and the missing ground-contact cue made the feet read as sunk,
// though the origin is measurably ON the surface). A probe miss (mech at the
// edge of its collision node) falls back to flat. Gated BT_SHADOW_TILT (=0
// restores the flat placeholder for A/B).
{
Vector3D shadowNormal(0.0f, 1.0f, 0.0f);
static int s_shTilt = -1;
if (s_shTilt < 0)
{
const char *sv = getenv("BT_SHADOW_TILT");
s_shTilt = (sv == 0 || sv[0] != '0') ? 1 : 0;
}
BoundingBoxTreeNode *gnode = (GetCollisionVolumeCount() > 0 && collisionTemplate != 0)
? GetMoverCollisionRoot() : 0;
if (s_shTilt && gnode != 0)
{
const Scalar D = 12.0f; // probe offset (small -> stays in the node)
const Scalar baseY = localOrigin.linearPosition.y + collisionTemplate->minY + 50.0f;
const Scalar cx = localOrigin.linearPosition.x;
const Scalar cz = localOrigin.linearPosition.z;
Scalar hC = -1.0f, hX = -1.0f, hZ = -1.0f;
Point3D qC(cx, baseY, cz), qX(cx + D, baseY, cz), qZ(cx, baseY, cz + D);
BoundingBox *bC = gnode->FindBoundingBoxUnder(qC, &hC);
BoundingBox *bX = gnode->FindBoundingBoxUnder(qX, &hX);
BoundingBox *bZ = gnode->FindBoundingBoxUnder(qZ, &hZ);
if (bC != 0 && bX != 0 && bZ != 0)
{
// surfaceY = probeY - downDistance; gradient -> world normal (-dH/dx, 1, -dH/dz)
const Scalar yC = baseY - hC, yX = baseY - hX, yZ = baseY - hZ;
Vector3D wn(-(yX - yC) / D, 1.0f, -(yZ - yC) / D);
Scalar len = (Scalar)sqrtf(wn.x * wn.x + wn.y * wn.y + wn.z * wn.z);
if (len > 1e-6f) { wn.x /= len; wn.y /= len; wn.z /= len; }
// world -> mech-local: rotate by -gDriveHeading about Y (mech upright, yaw only)
const Scalar cth = (Scalar)cosf((float)gDriveHeading);
const Scalar sth = (Scalar)sinf((float)gDriveHeading);
shadowNormal.x = wn.x * cth - wn.z * sth;
shadowNormal.y = wn.y;
shadowNormal.z = wn.x * sth + wn.z * cth;
}
}
UpdateShadowJoint(shadowNormal);
}
// --- VISUAL-GROUND CONFORM (PORT ADDITION, presentation only; btvisgnd.cpp;
// gate BT_VISUAL_GROUND, default ON) ---------------------------------
// The sim rides the coarse 1995 collision solids (authentic snap: origin.y
// = solid surfaceY); the VISUAL terrain runs 0..~2.1u above them on slopes,
// clipping the feet. Invisible in 1995 (cockpit-only view); visible in our
// external camera. Lift the RENDER matrix to the visible surface sampled
// from the actual terrain triangles under the mech. localOrigin (collision,
// aim, damage, dead-reckoning) is NEVER touched; next frame's drive rebuilds
// localToWorld from localOrigin, so the lift is per-frame and render-only.
{
extern float BTVisualGroundLift(float x, float y, float z);
const float vLift = BTVisualGroundLift(
localOrigin.linearPosition.x,
localOrigin.linearPosition.y,
localOrigin.linearPosition.z);
if (vLift != 0.0f)
{
localToWorld(3, 1) = localToWorld(3, 1) + vLift;
if (GroundLog())
{
static float s_vgAccum = 0.0f; s_vgAccum += dt;
if (s_vgAccum >= 1.0f)
{
s_vgAccum = 0.0f;
DEBUG_STREAM << "[visgnd] lift=" << vLift
<< " at (" << localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.y << ", "
<< localOrigin.linearPosition.z << ")\n" << std::flush;
}
}
}
}
// --- MASTER UPDATE EMISSION (P6 multiplayer; the VTV pattern, RP/VTV.cpp:
// 840-861) --------------------------------------------------------
// Predict where remote pods believe this mech is (run OUR OWN dead
// reckoner: projectedOrigin advances from the last-broadcast state) and
// ForceUpdate() when the truth diverges -- position error, orientation
// deviation, or the 2-second heartbeat. ForceUpdate marks the update-
// model bits; the engine update pump then WriteUpdateRecord()s (our
// localVelocity/worldLinearVelocity are maintained every frame above)
// and the InterestManager broadcasts to all remote hosts.
if (GetInstance() == MasterInstance && deadReckoner != 0
&& application != 0
&& application->GetApplicationState() == Application::RunningMission)
{
// (RunningMission gate: broadcasting motion during mission CREATION kept
// the PEER's event queue busy, so its CreatingMission->LoadingMission
// quiet-timeout never fired -- the pods deadlocked pre-launch. The real
// system never moves before launch, so the gate is faithful.)
(this->*deadReckoner)();
Vector3D error;
error.Subtract(
projectedOrigin.linearPosition,
localOrigin.linearPosition);
Quaternion angular_deviation;
angular_deviation.Subtract(
projectedOrigin.angularPosition,
localOrigin.angularPosition);
if (
error.LengthSquared() > 0.04f
|| Abs(angular_deviation.w) < 0.997f
|| lastPerformance - lastUpdate > 2.0f
)
{
ForceUpdate();
}
if (getenv("BT_REPL_LOG"))
{
static float s_emitLog = 0.0f; s_emitLog += dt;
if (s_emitLog >= 1.0f)
{
s_emitLog = 0.0f;
DEBUG_STREAM << "[emit] updateModel=" << (int)updateModel
<< " errSq=" << error.LengthSquared()
<< " lastPerf=" << (float)lastPerformance
<< " lastUpd=" << (float)lastUpdate
<< " interesting=" << (int)IsInteresting() << "\n" << std::flush;
}
}
}
// --- WEAPON FIRE VISUAL (port addition): on the trigger, draw the muzzle->hit
// BEAM + a muzzle flash + (solo) an impact explosion -- ALWAYS, so you can
// see your weapons fire even with no locked target (aim = the crosshair,
// straight ahead, raycast to the terrain). The 1995 game rendered
// cockpit-only so it never needed a third-person weapon view; our external
// camera does, and the dpl_* beam layer was never ported -- this is it.
// Damage stays in the target block below; this owns only the visual, on its
// own cooldown. (Real per-weapon fire -- Emitter::FireWeapon heat/charge --
// runs in the subsystem tick when a target is locked.)
// FIRING ARC: weapons fire where the mech can POINT its guns -- a target it
// cannot bring the guns to bear on is NOT hit (no shooting the enemy out of
// your back). The AUTHENTIC, data-driven traverse is the TORSO twist range:
// weapon .SUB resources carry NO arc field, so what lets a mech aim off
// dead-ahead is the torso mount (Torso::GetHorizontalReach, the wider software
// twist limit). The Blackhawk's torso is fixed-forward (TorsoHorizontalEnabled
// =0 -> reach 0), so its guns line up with the mech facing; a twist-capable
// mech widens the arc by its real torso limit. On top of the torso reach we
// keep a base aim/convergence tolerance (BT_FIRE_ARC degrees, default 30) --
// the reticle box, a port presentation parameter for the external camera the
// 1995 cockpit view never needed. In-arc: the beam converges on the target
// and the shot hits; out-of-arc: the guns fire straight ahead, nothing is hit.
bool targetInArc = false;
{
UnitVector zAxisA;
localToWorld.GetFromAxis(Z_Axis, &zAxisA);
Vector3D fA(-(Scalar)zAxisA.x, -(Scalar)zAxisA.y, -(Scalar)zAxisA.z);
Scalar fl = (Scalar)Sqrt(fA.x*fA.x + fA.y*fA.y + fA.z*fA.z);
if (fl < 1e-4f) fl = 1.0f;
fA.x /= fl; fA.y /= fl; fA.z /= fl;
if (gEnemyMech != 0)
{
Point3D ep = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
Vector3D toE(ep.x - localOrigin.linearPosition.x,
ep.y - localOrigin.linearPosition.y,
ep.z - localOrigin.linearPosition.z);
Scalar tl = (Scalar)Sqrt(toE.x*toE.x + toE.y*toE.y + toE.z*toE.z);
if (tl < 1e-4f) tl = 1.0f;
Scalar d = (fA.x*toE.x + fA.y*toE.y + fA.z*toE.z) / tl; // cos(angle to target)
// base aim tolerance (radians) -- read once from BT_FIRE_ARC (degrees).
static Scalar s_baseRad = -1.0f;
if (s_baseRad < 0.0f)
{
const char *av = getenv("BT_FIRE_ARC");
Scalar deg = av ? (Scalar)atof(av) : 30.0f;
s_baseRad = (Scalar)((double)deg * 3.14159265358979 / 180.0);
}
// effective half-arc = base tolerance + this mech's real torso reach.
Scalar half = s_baseRad + GetHorizontalFiringReach();
if (half > 3.14159265f) half = 3.14159265f; // clamp: full hemisphere+
targetInArc = (d >= (Scalar)cos((double)half));
}
}
{
UnitVector zAxisF;
localToWorld.GetFromAxis(Z_Axis, &zAxisF);
Vector3D fwd(-(Scalar)zAxisF.x, -(Scalar)zAxisF.y, -(Scalar)zAxisF.z); // mech faces -Z
Scalar flen = (Scalar)Sqrt(fwd.x*fwd.x + fwd.y*fwd.y + fwd.z*fwd.z);
if (flen < 1e-4f) flen = 1.0f;
fwd.x /= flen; fwd.y /= flen; fwd.z /= flen;
// AUTHENTIC MUZZLES: fire from the mech's real gun-port SITE segments
// (BLH.SKL: siter/lu/dgunport on jointrgun/jointlgun -- the arm guns),
// resolved to world by (segmentToEntity x localToWorld). Beams then
// emit from the actual arm guns and track them as the mech moves/animates,
// instead of one point above the torso. Falls back to a centre muzzle for
// a mech with no resolvable gun ports.
Point3D muzzles[8];
int nMuz = 0;
static const char *const kGunPorts[] =
{ "siterugunport", "sitelugunport", "siterdgunport", "siteldgunport",
"siterbgunport", "sitelbgunport" };
for (int gp = 0; gp < 6 && nMuz < 8; ++gp)
{
EntitySegment *seg = GetSegment(CString(kGunPorts[gp]));
if (seg != 0)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), localToWorld);
muzzles[nMuz] = mw; // Point3D = matrix translation (W_Axis)
++nMuz;
}
}
if (nMuz == 0)
{
Point3D c = localOrigin.linearPosition;
c.x += fwd.x * kMuzzleForward;
c.y += kMuzzleHeight + fwd.y * kMuzzleForward;
c.z += fwd.z * kMuzzleForward;
muzzles[0] = c; nMuz = 1;
}
const Point3D &muzzle = muzzles[0]; // aim-raycast origin
// converge on the enemy ONLY when it's within the forward firing arc;
// otherwise the guns fire straight ahead (crosshair raycast), never back.
const bool haveEnemy = (gEnemyMech != 0 && targetInArc);
Point3D aim;
if (haveEnemy)
{
aim = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
aim.y += kMuzzleHeight; // aim at the torso, not the feet
}
else
{
// default aim = straight ahead at max range; the terrain raycast
// (BTGroundRayHit marches SampleBand over every map instance -- a
// real per-frame cost on dense maps) only runs when a shot actually
// needs the impact point: trigger down or a discharge in flight.
aim.x = muzzle.x + fwd.x * kWeaponRange;
aim.y = muzzle.y + fwd.y * kWeaponRange;
aim.z = muzzle.z + fwd.z * kWeaponRange;
if (gBTDrive.fireForced || gBTDrive.fire || gBeamCooldown > 0.0f)
{
extern bool BTGroundRayHit(float,float,float, float,float,float,
float, float*,float*,float*);
float hx, hy, hz;
if (BTGroundRayHit(muzzle.x, muzzle.y, muzzle.z, fwd.x, fwd.y, fwd.z,
kWeaponRange, &hx, &hy, &hz))
{ aim.x = hx; aim.y = hy; aim.z = hz; }
}
}
// resolve the "explode" effect once (also used by the target block).
if (gExplodeReady == 0)
{
gExplodeReady = -1;
if (application != 0 && application->GetResourceFile() != 0)
{
ResourceDescription *exp =
application->GetResourceFile()->FindResourceDescription(
"explode", (ResourceDescription::ResourceType)1, -1);
if (exp != 0)
{
gExplodeRes = exp->resourceID;
gExplodeReady = 1;
DEBUG_STREAM << "[fire] explode effect resolved id=" << (long)gExplodeRes << "\n" << std::flush;
}
else DEBUG_STREAM << "[fire] 'explode' effect not found\n" << std::flush;
}
}
const int fireWanted = gBTDrive.fireForced || gBTDrive.fire;
if (gBeamCooldown > 0.0f)
gBeamCooldown -= dt;
// Active-discharge tracker. The real Emitter fires ONE beam on the trigger
// edge (FireWeapon @004bace8) then keeps it alive for its DischargeTime via
// ContinueDischarge (@004baa20) called EVERY frame while Firing. FireWeapon
// computes the muzzle LIVE (GetMuzzlePoint @004b9948, the gun segment's current
// world position) and does NOT store it, so the display re-obtains the muzzle
// each frame -> the beam ORIGIN tracks the gun as the mech moves during the
// discharge (you can't run out from under your own beam). We reproduce that:
// start a discharge on the edge, then REDRAW from the live muzzle every frame
// until the beam-on timer runs out. One discharge at a time (beam-on 0.2s <
// stagger 0.37s), matching the staggered chain cadence.
static int s_dischargePort = -1;
static Scalar s_dischargeRemain = 0.0f;
if (fireWanted && gBeamCooldown <= 0.0f)
{
// STAGGERED CHAIN FIRE (decoded cadence): each gun port cycles at
// kPortRecharge (0.2 DischargeTime + 2.0 RechargeRate = 2.2s). A synced
// volley would flash every 2.2s (too slow); the BLH's lasers fire STAGGERED,
// so fire ONE port per stagger tick and rotate -> aggregate = nMuz beams per
// 2.2s (~0.37s between beams for 6 arm ports), a rapid alternating stream
// while each port honours its real 2.2s recharge.
gBeamCooldown = kPortRecharge / (Scalar)nMuz; // stagger interval
static int s_chainIndex = 0;
s_dischargePort = s_chainIndex % nMuz; // begin this port's discharge
s_dischargeRemain = kBeamOnTime; // DischargeTime beam-on
s_chainIndex = (s_chainIndex + 1) % nMuz;
// solo impact (with an enemy, the target block spawns the explosion).
if (!haveEnemy && gExplodeReady == 1)
{
Origin exp_o;
exp_o.linearPosition = aim;
exp_o.angularPosition = localOrigin.angularPosition; // any valid quat
Explosion::MakeMessage m(
Explosion::MakeMessageID, sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID, EntityID::Null,
gExplodeRes, Explosion::DefaultFlags, exp_o,
EntityID::Null, GetEntityID());
Explosion *e = Explosion::Make(&m);
if (e) Register_Object(e);
}
static int s_beamLog = 0;
if ((s_beamLog++ % 8) == 0)
DEBUG_STREAM << "[fire] BEAM #" << s_beamLog
<< (haveEnemy ? " -> target" : " -> free")
<< " muzzle=(" << muzzle.x << "," << muzzle.y << "," << muzzle.z
<< ") hit=(" << aim.x << "," << aim.y << "," << aim.z << ")\n" << std::flush;
}
// KEEPALIVE (== ContinueDischarge): while a discharge is live, redraw the beam
// from the LIVE muzzle (muzzles[] is recomputed from the gun segments every
// frame above) to the live aim. ttl = one frame so exactly one beam is alive
// per frame (same brightness as a single persistent beam), re-anchored to the
// moving gun -- the origin stays on the mech for the whole DischargeTime.
if (s_dischargeRemain > 0.0f && s_dischargePort >= 0 && s_dischargePort < nMuz)
{
extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float);
const Point3D &mz = muzzles[s_dischargePort]; // LIVE muzzle this frame
const Scalar ttl1 = (dt > 1e-4f) ? dt : 1e-4f; // one-frame life (redrawn each frame)
// Steady beam for the discharge (a laser is on, not fading): a WIDE dim RED
// glow carries the ER-laser colour + the scrolling bexp grit, a THIN brighter
// core hot-spots the centre. Kept dim enough that the additive core does not
// wash the glow/grit to white (the run-out fix removed the old fade that used
// to hide the saturation). Widths are per-beam (tube honours b.width now).
BTPushBeam(mz.x, mz.y, mz.z, aim.x, aim.y, aim.z,
0x00C81404u, ttl1, 3.0f); // wide red glow (grit shows)
BTPushBeam(mz.x, mz.y, mz.z, aim.x, aim.y, aim.z,
0x00A07868u, ttl1, 0.9f); // thin warm core (no white-out)
s_dischargeRemain -= dt;
}
}
// --- TARGETING (bring-up): lock the player's current target onto the
// spawned enemy mech. This writes the three mech target slots that the
// weapon/fire path reads (target position @0x37c, target entity @0x388,
// targeted-subsystem index @0x38c) so HasActiveTarget() becomes true and
// the beam/aim has a world point. Real acquisition -- nearest-enemy scan
// or the pilot "hotbox" select through the sensor subsystem -- is the next
// refinement; here we lock the one known target. The enemy is stationary
// (drive gated to the player), but we read its LIVE origin so this stays
// correct once it can move.
if (gEnemyMech != 0)
{
Point3D enemyPos = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
MECH_TARGET_POS(this) = enemyPos; // mech+0x37c
MECH_TARGET_ENTITY(this) = gEnemyMech; // mech+0x388 (HasActiveTarget gate)
MECH_TARGET_SUBIDX(this) = -1; // mech+0x38c whole-mech target
float ddx = enemyPos.x - localOrigin.linearPosition.x;
float ddy = enemyPos.y - localOrigin.linearPosition.y;
float ddz = enemyPos.z - localOrigin.linearPosition.z;
float range = (float)Sqrt((double)(ddx*ddx + ddy*ddy + ddz*ddz));
gTargetLogAccum += dt;
if (gTargetLogAccum >= 1.0f)
{
gTargetLogAccum = 0.0f;
DEBUG_STREAM << "[target] locked enemy entity=" << (void *)gEnemyMech
<< " range=" << range
<< (range <= kWeaponRange ? " IN RANGE (ready to fire)" : " (closing)")
<< (targetInArc ? " [in arc -> converge]" : " [out of arc -> fire forward, no hit]")
<< " torsoReach=" << GetHorizontalFiringReach() << "rad"
<< "\n" << std::flush;
}
// --- FIRING (bring-up): on the trigger, with a target in range and the
// weapon off cooldown, spawn an Explosion at the target. This is the
// real engine fire->effect->render chain (Explosion::Make + the
// engine's explosion renderable) standing in for the per-weapon beam
// (Emitter::FireWeapon) until the emitter subsystem + its beam
// renderable are reconstructed. The shot is gated exactly like the
// real weapon: HasActiveTarget (we just set mech+0x388) AND in range.
if (gFireCooldown > 0.0f)
gFireCooldown -= dt;
const int fireWanted = gBTDrive.fireForced || gBTDrive.fire;
// E8: pulse the weapon trigger per frame (1,0,1,0...) so the real Emitter's
// CheckFireEdge sees clean rising edges for repeated auto-fire. Read by
// EmitterSimulation; only the player's emitter (with a target) actually fires.
extern int gBTWeaponTrigger;
// the real Emitter only fires at a target in the forward arc -- no
// heat/damage out of arc (you can't shoot the enemy behind you).
gBTWeaponTrigger = (fireWanted && targetInArc) ? (gBTWeaponTrigger ? 0 : 1) : 0;
// Resolve the "explode" effect resource once.
if (gExplodeReady == 0)
{
gExplodeReady = -1;
if (application != 0 && application->GetResourceFile() != 0)
{
ResourceDescription *exp =
application->GetResourceFile()->FindResourceDescription(
"explode", (ResourceDescription::ResourceType)1, -1);
if (exp != 0)
{
gExplodeRes = exp->resourceID;
gExplodeReady = 1;
DEBUG_STREAM << "[fire] explode effect resolved id="
<< (long)gExplodeRes << "\n" << std::flush;
}
else
{
DEBUG_STREAM << "[fire] 'explode' effect not found\n" << std::flush;
}
}
}
if (fireWanted && targetInArc && gFireCooldown <= 0.0f && range <= kWeaponRange
&& gExplodeReady == 1)
{
gFireCooldown = kFireCooldown;
++gShotCount;
Origin exp_origin = ((Mech *)gEnemyMech)->localOrigin; // at the target
Explosion::MakeMessage exp_message(
Explosion::MakeMessageID,
sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID,
EntityID::Null,
gExplodeRes,
Explosion::DefaultFlags,
exp_origin,
gEnemyMech->GetEntityID(), // entity hit
GetEntityID()); // shooter (this mech)
Explosion *shot = Explosion::Make(&exp_message);
if (shot)
{
Register_Object(shot);
DEBUG_STREAM << "[fire] SHOT #" << gShotCount
<< " -> explosion at target (range=" << range << ")\n" << std::flush;
}
else
{
DEBUG_STREAM << "[fire] Explosion::Make returned NULL\n" << std::flush;
}
// --- DAMAGE (real): dispatch a TakeDamage message to a VALID zone; the
// engine base handler routes it to Mech__DamageZone::TakeDamage (the real
// armor/structure model). Aim a rotating zone so the whole mech degrades;
// read back structureLevel (now valid -- friend access) to show it climb
// toward 1.0 (destroyed).
if (gEnemyMech->damageZoneCount > 0)
{
int zc = gEnemyMech->damageZoneCount;
// Aim the first VITAL zone so concentrated fire destroys it (-> mech
// death). The faithful per-impact aim (cylinder lookup from the hit
// point) is STEP 6; until then we target a vital zone directly.
int zone = 0;
for (int k = 0; k < zc; ++k)
if (((Mech *)gEnemyMech)->Zone(k)->vitalDamageZone) { zone = k; break; }
Damage dmg; // default-constructed
// Explosive: the weapon effect is an Explosion (explosive), not an
// energy beam. Also the correct type to exercise the zone armour/
// structure/death model now -- EnergyDamageType(4) shorts attached
// generators via criticalSubsystems[]->plug, which needs the REAL
// subsystem roster (still RECON_SUBSYS stubs -> plug resolves null).
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = kShotDamage;
dmg.burstCount = 1;
dmg.impactPoint = enemyPos; // world impact point
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
GetEntityID(), // inflicting = this (shooter)
zone, // valid zone -> base handler applies
dmg);
gEnemyMech->Dispatch(&take_damage);
// gauge scoring wave (Step 6): credit the local player for damage
// dealt -> SCORE climbs per hit (currentScore += tonnageRatio*award).
BTPostDamageScore(gEnemyMech, kShotDamage);
Scalar s = ((Mech *)gEnemyMech)->Zone(zone)->damageLevel; // [0,1], 1.0=destroyed (engine base field)
DEBUG_STREAM << "[damage] hit zone " << zone << "/" << zc
<< " structure=" << s << "\n" << std::flush;
}
// Death via the REAL damage model: a mech with a destroyed VITAL zone is
// dead. We read the reconstructed zone state directly (damageLevel >= 1.0
// on a vitalDamageZone). The faithful Mech::IsDisabled() reads movementMode,
// which the gait/death-transition (bypassed by the bring-up drive override)
// would set from exactly this condition. Friend access (mechdmg.hpp).
Logical dead = False;
for (int k = 0; k < gEnemyMech->damageZoneCount; ++k)
{
Mech__DamageZone *dz = ((Mech *)gEnemyMech)->Zone(k);
if (dz->vitalDamageZone && dz->damageLevel >= 1.0f) { dead = True; break; }
}
if (!gEnemyDestroyed && dead)
{
gEnemyDestroyed = 1;
// gauge scoring wave (Step 7): credit the KILL to the local player
// -> KILLS 0->1 (senderMechID = the VICTIM, so the !=our-mech branch
// fires; the ownerless dummy yields no death, DEATHS stays 0).
BTPostKillScore(gEnemyMech, kShotDamage);
// Death explosion at the target.
Origin death_origin = ((Mech *)gEnemyMech)->localOrigin;
Explosion::MakeMessage death_exp(
Explosion::MakeMessageID,
sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID,
EntityID::Null,
gExplodeRes,
Explosion::DefaultFlags,
death_origin,
gEnemyMech->GetEntityID(),
GetEntityID());
Explosion *boom = Explosion::Make(&death_exp);
if (boom) Register_Object(boom);
// P5 ROOT-CAUSE (env-gated, DEFAULT OFF): actually tear the wreck down so
// the ~Mover collision-dtor crash reproduces under cdb. The shipped death
// stand-in below (explosion + stop-targeting) is unchanged when the flag is off.
if (getenv("BT_ENABLE_TEARDOWN"))
{
char *e = (char *)gEnemyMech;
DEBUG_STREAM << "[teardown] enemy this=" << (void *)e << " base-region dump (engine fields):\n"
<< " collisionVolume@0x2d8=" << *(void **)(e + 0x2d8)
<< " collisionTemplate@0x2dc=" << *(void **)(e + 0x2dc)
<< " containedByNode@0x2e0=" << *(void **)(e + 0x2e0) << "\n"
<< " collisionLists@0x2e4=" << *(void **)(e + 0x2e4)
<< " lastCollisionList@0x2e8=" << *(void **)(e + 0x2e8)
<< " collisionAssistant@0x2ec=" << *(void **)(e + 0x2ec) << "\n"
<< " segmentTable.array@0x2f0=" << *(void **)(e + 0x2f0)
<< " segmentCount@0x308=" << *(int *)(e + 0x308)
<< " jointSubsystem@0x30c=" << *(void **)(e + 0x30c) << "\n"
<< " -> dispatching DestroyEntityMessage\n" << std::flush;
Entity::DestroyEntityMessage destroy_msg(
Entity::DestroyEntityMessageID,
sizeof(Entity::DestroyEntityMessage));
gEnemyMech->Dispatch(&destroy_msg);
}
// By default we do NOT tear the wreck down: FryDeathRow -> ~Mech -> ~JointedMover
// -> ~Mover CRASHES. ⚠ ROOT-CAUSED (P5, see btbuild/HARD_PROBLEMS.md): NOT
// "collision solids never built" (that was wrong -- Mover::Mover allocates
// collisionLists@0x2e4 unconditionally; it reads a valid heap ptr at death).
// The real cause is the Entity/Mover BASE-REGION LAYOUT divergence: this file's
// raw binary-offset accesses (this+0x2d4 netOrientation / +0x2e0 arrivalTime /
// +0x2e8 physicsBody / +0x2ec groundRef / +0x2f0 groundCell) land on the elsewhen
// engine Mover's collision cluster (collisionVolumeCount@0x2d4 .. collisionAssistant
// @0x2ec) and CORRUPT it (incl. deref-writes at :1020 through +0x2ec), so teardown
// deletes garbage -- crashing at varying base-member sites (~Mover collisionLists,
// ~JointedMover member). Enable with BT_ENABLE_TEARDOWN to repro under cdb.
// FIX = reconcile those raw offsets to the relocated declared members (the base-
// region audit). Meanwhile the mech "dies" (explosion + no longer targeted).
DEBUG_STREAM << "[damage] *** TARGET DESTROYED after "
<< gShotCount << " hits ***\n" << std::flush;
gEnemyMech = 0; // stop targeting/firing the dead entity
}
}
}
}
// --- Tick the subsystem roster (BT analog of Entity::PerformAndWatch) -----
// The shipped engine's Entity::PerformAndWatch walks subsystemArray[] and
// calls PerformAndWatch on every non-null, non-replicant-disabled subsystem
// (MUNGA/ENTITY.cpp ~698-790; RP's VTV ticks ITS roster the identical way).
// Our drivable override bypasses the unsafe Mech::Simulate, but the
// subsystem roster must still tick each frame so heat/weapons/sensors/gyro/
// power/etc. run their per-frame Performance. We mirror the engine loop
// here instead of calling Simulate: each Subsystem is a Simulation whose
// base PerformAndWatch (engine, safe) computes its OWN time slice from its
// own lastPerformance and dispatches activePerformance -- the reconstructed
// *::*Simulation method the subsystem's ctor installed via SetPerformance.
// Sentinels: index 0 is always NULL; index 1 is the resolved voltage bus;
// the real streamed subsystems live at id>=2. Null + executable guards
// make all three cases safe. (Per-subsystem behaviour deepening is a
// separate wave; here we only make the TICK PATH live.)
int subsystemsTicked = 0; // performed this frame (executable)
int subsystemsPresent = 0; // non-null roster occupancy (excl. mapper)
if (subsystemArray != 0 && subsystemCount > 0)
{
for (int i = 0; i < subsystemCount; ++i)
{
Subsystem *subsystem = subsystemArray[i];
if (subsystem == 0)
continue;
if (subsystem != (Subsystem *)controlsMapper)
++subsystemsPresent;
if (!subsystem->IsNonReplicantExecutable())
continue;
// The controls-mapping subsystem (roster slot 0 via Mech::SetMapping
// Subsystem, mirrored to controlsMapper) is TICKED under BT_REAL_CONTROLS
// -- its InterpretControls chain is now reconciled: FillPilotArray reads
// the local player via application->GetMissionPlayer() (the old wild
// application+0x6c read was THE bypass-causing AV), and the main tick
// @004afd10 reads the owner through declared members (reverseStride
// Length/walkStrideLength/forwardThrottleScale, the real Torso analog
// axes, the real HUD freeAimSlew). Without the env the historic skip
// stands (default behavior unchanged).
{
static const int s_realControlsTick = BTEnvOn("BT_REAL_CONTROLS", 1);
if (!s_realControlsTick && subsystem == (Subsystem *)controlsMapper)
continue;
}
subsystem->PerformAndWatch(till, update_stream);
++subsystemsTicked;
}
}
// One-shot: report how many subsystems the tick dispatched to on the FIRST
// frame, before any DoNothingOnce subsystems latch NeverExecute and opt out.
if (!gTickFirstLogged)
{
gTickFirstLogged = 1;
DEBUG_STREAM << "[tick] first frame: dispatched to " << subsystemsTicked
<< " executable subsystem(s) of " << subsystemsPresent
<< " present / roster " << subsystemCount << "\n" << std::flush;
}
// 1 Hz confirmation that the tick path is live (N subsystems simulated).
gTickLogAccum += dt;
if (gTickLogAccum >= 1.0f)
{
gTickLogAccum = 0.0f;
DEBUG_STREAM << "[tick] subsystems simulated: " << subsystemsTicked
<< " (executable) of " << subsystemsPresent
<< " present / roster " << subsystemCount << "\n" << std::flush;
}
// Keep the simulation/networking bookkeeping consistent (this is exactly
// what the base "no time / stasis" early-out does).
WriteSimulationUpdate(update_stream);
}
//###########################################################################
//###########################################################################
// Mech::AuthenticGroundAndCollide
//
// THE AUTHENTIC 1995 GROUND MODEL -- the ground/collision half of the MASTER
// per-frame performance FUN_004a9b5c (@4aa630-4aab5f, decoded from raw asm by
// the ground-model-decode workflow; task #15). Replaces the bring-up
// gravity+push-out+floor-clamp baseline when GroundReal() is on.
//
// The model (no gravity exists anywhere in the mech):
// 1. MoveCollisionVolume() -- rebuilds localToWorld from localOrigin, places
// the collision cylinder, and (masters) re-finds containedByNode in the
// zone's BoundingBoxTree (engine Mover::MoveCollisionVolume, MOVER.cpp:782).
// 2. GROUND PROBE: q = origin + (0, collisionTemplate->minY, 0) -- the
// ctor-LIFTED template bottom (BLH: 2.0 + 0.37969 = 2.37969 above the feet).
// 3. HEIGHT QUERY: FindBoundingBoxUnder(q, &h) -- the box-tree downward query
// (BOXTREE.cpp:843); h = distance from q down to the highest solid top
// under the column; h == -1.0f = MISS.
// 4. THE SNAP (master gate h > 1e-4, const @0x4ab16c): origin.y -= (h - lift)
// => origin.y = surfaceY EXACTLY. Absolute placement each frame: walks
// up-slope within the lift window (implicit step allowance), drops
// instantly on walk-offs. On MISS: NOTHING -- Y holds (no gravity to
// accumulate => the old y=13301 runaway is structurally impossible).
// 5. COLLISIONS: GetCurrentCollisions -> ProcessCollisionList (which calls
// the Mech::ProcessCollision override below per contact).
// 6. RESPONSE POLICY on the accumulated damage:
// == 0.00123f crushable-CulturalIcon sentinel: the frame's move STANDS
// (restore the POST-SNAP saves; the bounce is undone).
// > 0 BLOCKING hit (walls/cliffs/buildings): FULL FRAME REJECTION
// -- velocity zeroed, origin restored to START-OF-FRAME.
// Walls block by rejection, never by slide/climb.
//
// Deliberate deviations from the binary (each flagged by the verify pass):
// - GetCollisionVolumeCount()>0 gate + containedByNode NULL-guard cover the
// WHOLE block (the 1995 code was unconditional and would have crashed for
// volume-less mechs; GetCurrentCollisions derefs the node unchecked).
// - impactVel is saved with the post-snap saves (binary saves it after the
// gather @4aa716 -- provably equivalent, the gather doesn't touch it).
// - localToWorld is NOT re-synced after a plain snap (binary behavior: it
// refreshes on the next frame's MoveCollisionVolume or a response path).
//
// DEFERRED (bound together; see the ProcessCollision banner): the pre-list
// collisionTemporaryState zero (@4aa741) + state tail; the 0x4a4/0x4a8/0x4b4
// caches; the self TakeDamageMessage; SetLegAnimation(0x20) crash anim on
// impactVel^2 > 40 (crash-clip slot mapping unverified -- logged instead);
// the gyro crunch feed (Gyroscope is a stub).
//###########################################################################
//###########################################################################
void
Mech::AuthenticGroundAndCollide(Scalar dt, const Point3D &old_position)
{
if (GetCollisionVolumeCount() <= 0 || collisionVolume == 0 || collisionTemplate == 0)
return;
// 1. place the volume + refresh containedByNode (@4aa630)
MoveCollisionVolume();
BoundingBoxTreeNode *node = GetMoverCollisionRoot();
if (node == 0)
{
if (GroundLog())
{
static int s_once = 0;
if (!s_once++) DEBUG_STREAM << "[ground] NO collision tree node -- "
"zone tree unavailable; block skipped\n" << std::flush;
}
return;
}
// 2-3. ground probe + height query (@4aa633-4aa67d)
Point3D q = localOrigin.linearPosition;
q.y += collisionTemplate->minY; // the LIFTED template bottom
Scalar h = -1.0f;
BoundingBox *floorBox = node->FindBoundingBoxUnder(q, &h);
(void)floorBox;
// 4. the snap (@4aa685-4aa6cc)
if (h > 0.0001f) // master gate, const @0x4ab16c
{
Scalar d = h - collisionTemplate->minY; // signed: down OR bounded up
localOrigin.linearPosition.y -= d; // => origin.y = surfaceY exactly
collisionVolume->minY -= d; // keep the placed volume in sync
collisionVolume->maxY -= d;
}
// 5. collisions (@4aa6cf-4aa764)
// ASSISTANT GUARD (defensive; matches the authentic invariant): the engine
// GetCurrentCollisions iterates collisionAssistant UNCHECKED (MOVER.cpp:894;
// Check() compiles out). In the real game exactly ONE master exists per pod
// -- the viewpoint mech, which MakeViewpointEntity gave an assistant
// (btl4app.cpp:591); every other mech was a replicant (no collision half).
// A master WITHOUT an assistant (the BT_SPAWN_ENEMY dummy before its
// StartCollisionAssistant) is a configuration the original never had --
// ground it (probe/snap above) but skip the collision half.
if (collisionAssistant == 0)
return;
Vector3D savedWorldVel = worldLinearVelocity; // POST-SNAP saves
Point3D savedPos = localOrigin.linearPosition;
BoxedSolidCollisionList *cols = GetCurrentCollisions();
Vector3D impactVel = localVelocity.linearMotion;
// CONTACT EDGE (player only): a knockdown fires only on a FRESH strike (not blocked
// last frame). While the mech stays pressed against the obstacle it just BLOCKS --
// no repeated knockdowns. gWasBlocked is a single global, so gate it to the
// viewpoint mech; other masters (the stationary dummy, MP replicant-driven masters)
// keep the original per-fresh-block behavior and never touch the shared state.
const bool isPlayer = (application != 0
&& (Entity *)this == application->GetViewpointEntity());
const int freshBlock = isPlayer ? (gBlockCooldown <= 0.0f ? 1 : 0) : 1;
if (isPlayer && gBlockCooldown > 0.0f)
gBlockCooldown -= dt; // decay the out-of-contact window
// BINARY-TAIL-DEFERRED: collisionTemporaryState = 0 here (@4aa741).
Damage dmg; // ctor zeroes damageAmount
if (cols != 0)
ProcessCollisionList(cols, dt, old_position, &dmg);
// 6. response policy (@4aa76c-4aab5f)
if (dmg.damageAmount == 0.00123f) // crushable icon: move STANDS
{
worldLinearVelocity = savedWorldVel;
localOrigin.linearPosition = savedPos;
MoveCollisionVolume();
dmg.damageAmount = 0.0f;
// gyro crunch feed (0.4*normal + 0.2*up): DORMANT until the Gyroscope un-stub
if (GroundLog())
DEBUG_STREAM << "[ground] CRUNCH (crushable icon) at ("
<< savedPos.x << ", " << savedPos.z << ")\n" << std::flush;
}
else if (dmg.damageAmount > 0.0f) // blocking hit: FULL FRAME REJECTION
{
worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f);
localOrigin.linearPosition = old_position;
MoveCollisionVolume();
if (isPlayer)
gBlockCooldown = kBlockHysteresis; // (re)arm the in-contact window
// DEFERRED: self TakeDamageMessage{0x64, zone=-1} (inert today) +
// material/normal/approach caches + collisionState indicators.
Scalar iv2 = impactVel.x * impactVel.x + impactVel.y * impactVel.y
+ impactVel.z * impactVel.z;
if (iv2 > 40.0f) // crash-anim threshold @0x4ab184
{
// CRASH / KNOCKDOWN (binary @4aaae2-4aab0b): bind the bump clip
// (animationClips[0x20] = "bmp"; slot verified 0x5cc+0x80 == 0x64c)
// on the LEG channel -- the mech plays the fall/stagger instead of
// grinding into the wall; LegClipFinished case 32 drops back to
// Standing at end-of-clip (slot1 @0x4a6b4d, binary-verified). The
// two action-request calls are the binary's FUN_004a4c54(this,1)
// then (this,0x20) -- TWO calls, not one OR'd (fidelity verdict).
// Guard 1: only when the bmp clip resolved (no substitute clip).
// Guard 2 (BRING-UP, marked): don't REBIND while the crash clip is
// already playing -- the bmp clip carries ~6.5 u/s of root motion
// that presses back into the wall, and without the actionRequestFlags
// consumers (bits 1/0x20 -- likely the drive suppressor during the
// stagger, NOT yet reconstructed) the re-trigger restarts the clip
// every frame. Remove this guard when the flag consumers land.
if (animationClips[0x20] != 0
&& legStateAlarm.GetLevel() != 0x20
&& freshBlock) // only on a FRESH strike, not continuous grinding
{
SetLegAnimation(0x20); // FUN_004a7fc4(this, 0x20)
// SYNC THE DISPLAY CHANNEL (fix the persistent post-bump foot-slip): the
// two-channel gait draws the legs from the BODY channel but moves the mech
// from the LEG channel. Staggering only the leg channel froze the motion
// while the body kept animating -> the channels desynced PERMANENTLY (the
// [sync] log: advSum 229 vs legSum 112 = displayed legs run ~2x the real
// travel = foot slip that lasts until a full stop resyncs both to stand).
// Stagger the BODY channel too so both freeze + recover from the SAME bmp
// clip IN PHASE. BodyClipFinished case 32 recovers it (mech2.cpp:308).
SetBodyAnimation(0x20);
RequestActionFlags(1); // FUN_004a4c54(this, 1)
RequestActionFlags(0x20); // FUN_004a4c54(this, 0x20)
if (GroundLog())
{
static int s_binds = 0;
DEBUG_STREAM << "[knock] BIND #" << ++s_binds
<< " iv2=" << iv2 << "\n" << std::flush;
}
}
else if (GroundLog())
{
static int s_noclip = 0;
if (!s_noclip++) DEBUG_STREAM << "[ground] crash-anim trigger but "
"bmp clip unresolved -- knockdown skipped\n" << std::flush;
}
if (GroundLog())
DEBUG_STREAM << "[ground] BLOCK + CRASH (iv2=" << iv2
<< " clip=" << animationClips[0x20] << ")\n" << std::flush;
}
else if (GroundLog())
DEBUG_STREAM << "[ground] BLOCK dmg=" << dmg.damageAmount
<< " at (" << old_position.x << ", " << old_position.z
<< ")\n" << std::flush;
}
// 1-Hz telemetry (verification: h / y / hit-miss); INSIDE-SOLID detector:
// h clamps to EXACTLY 0 when the probe point is at/inside a solid surface
// (every FindDistanceBelowBounded is Max(...,0)-clamped in BT content) --
// a sustained h==0 streak means the mech is buried in a solid.
if (GroundLog())
{
static float s_accum = 0.0f;
static int s_zeroStreak = 0;
if (h == 0.0f)
{
if (++s_zeroStreak == 10)
DEBUG_STREAM << "[ground] INSIDE-SOLID streak at ("
<< localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.y << ", "
<< localOrigin.linearPosition.z << ")\n" << std::flush;
}
else
s_zeroStreak = 0;
s_accum += (float)dt;
if (s_accum >= 1.0f)
{
s_accum = 0.0f;
DEBUG_STREAM << "[ground] h=" << h
<< (h == -1.0f ? " (MISS)" : (h == 0.0f ? " (ZERO/inside)" : " (hit)"))
<< " lift=" << collisionTemplate->minY
<< " pos=(" << localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.y << ", "
<< localOrigin.linearPosition.z << ")\n" << std::flush;
}
}
}
//###########################################################################
//###########################################################################
// Mech::ProcessCollision (vtable slot +0x3c)
//
// @004abb40 -- THE REAL per-contact collision responder (task #15,
// ground-model-decode workflow; raw part_012.c:15206-15416). The earlier
// draft here ("ResolveWeaponImpact") misread this function as a weapon sweep;
// it is the override of the ENGINE protected virtual Mover::ProcessCollision
// (MOVER.h:359-365), called per contact by Mover::ProcessCollisionList.
//
// Semantics (all raw-verified):
// - BoxedSolid resolver (collisionVolume->ProcessCollision, BOXDISKS.cpp) --
// on a miss NOTHING runs (the binary's entire body is inside the hit branch).
// - StaticBounce computes the positional resolution + collision damage.
// - Owner classification: a Mover owner -> separating-contact gate + (deferred)
// mech-vs-mech damage dispatch; a CulturalIcon owner -> separating gate +
// (deferred) crunch dispatch + the 0.00123f WALK-THROUGH SENTINEL when the
// icon has no StoppingCollisionVolume flag (crushable trees/props).
// - Plain UnscalableTerrain owners (ground/hills/cliff tiers/canyon walls)
// match NEITHER branch: their StaticBounce damage stands and the caller
// (AuthenticGroundAndCollide) rejects the whole frame -- walls/cliffs BLOCK.
//
// DEFERRED (marked, to add together in one fidelity pass):
// - the TakeDamageMessage dispatches (self/other-mech/icon-crunch): zone==-1
// is dropped by the engine base handler today (the Mech cylinder-lookup
// override is unreconstructed) AND the binary message carries an inflictor
// GLOBAL (DAT_0050b9ac) + inline name fields not yet mapped -- do NOT fake.
// - the collisionTemporaryState tail (:15406-15413) -- BINARY-TAIL-DEFERRED,
// bound with the pre-list zero @4aa741 in AuthenticGroundAndCollide.
// - the 0x4a4/0x4a8/0x4b4 caches (material / local-frame surface normal /
// approach speed) -- HUD/telemetry feeds, deferred with the indicators.
//
// Gated: falls through to the engine base when GroundReal() is off, so the
// baseline BT_COLLISION push-out path is byte-identical.
//###########################################################################
//###########################################################################
//
// Build a zone==-1 (unaimed) collision TakeDamageMessage from a resolved contact
// and dispatch it to 'victim'. The victim turns the WORLD impact point into a
// damage zone -- a Mech via its cylinder table (STEP 6), an icon via its base
// handler (crushable props have no zones -> the base handler no-ops). Faithful
// to the binary's mech-vs-mech / icon-crunch dispatches (:15324-15401): those
// built a raw DamageMessage{id=0x64, inflictor=DAT_0050b9ac, zone=-1}; we use the
// engine Entity::TakeDamageMessage ctor (as the weapon path does) with this mech
// as the inflictor. The impact point is the world centre of the overlap slice.
//
static void
BTDispatchCollisionDamage(
Mech *inflictor,
Entity *victim,
const Damage *resolved,
BoxedSolidCollision &collision)
{
Damage dmg;
dmg.damageType = Damage::CollisionDamageType;
dmg.damageAmount = resolved->damageAmount;
dmg.surfaceNormal = resolved->surfaceNormal;
dmg.impactPoint = Point3D(
(collision.collisionSlice.minX + collision.collisionSlice.maxX) * 0.5f,
(collision.collisionSlice.minY + collision.collisionSlice.maxY) * 0.5f,
(collision.collisionSlice.minZ + collision.collisionSlice.maxZ) * 0.5f);
dmg.burstCount = 1;
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
inflictor->GetEntityID(), -1 /*unaimed -> receiver's cylinder resolves*/, dmg);
victim->Dispatch(&take_damage);
if (GroundLog())
DEBUG_STREAM << "[collide] dmg=" << resolved->damageAmount
<< " -> victim class=" << (int)victim->GetClassID()
<< " (zone==-1, resolved by receiver)\n" << std::flush;
}
void
Mech::ProcessCollision(
Scalar time_slice,
BoxedSolidCollision &collision,
const Point3D &old_position,
Damage *damage)
{
if (!GroundReal())
{
Mover::ProcessCollision(time_slice, collision, old_position, damage);
return;
}
// --- the BoxedSolid resolver (:15302-15304); miss => nothing runs -------
Scalar penetration = 0.0f;
if (!collisionVolume->ProcessCollision(collision, worldLinearVelocity,
lastCollisionList, &damage->surfaceNormal, &penetration))
{
return;
}
if (penetration > time_slice) // Max_Clamp (:15306-15308)
penetration = time_slice;
Scalar r = penetration / time_slice;
Scalar elasticity = elasticityCoefficient; // :15310 (engine Mover member)
Scalar friction = frictionCoefficient; // :15311
Simulation *ownerSim =
collision.GetTreeVolume()->GetOwningSimulation(); // tagPointer (:15312)
damage->damageAmount = StaticBounce(old_position, time_slice, r,
damage->surfaceNormal, &elasticity, minimumBounceSpeed, &friction); // :15313-15315
Entity *owner = (Entity *)ownerSim;
// CONTACT TELEMETRY (task #15 verification): identify every resolved contact.
if (GroundLog())
{
BoxedSolid *ws = collision.GetTreeVolume();
DEBUG_STREAM << "[contact] solidType=" << (int)ws->solidType
<< " mat=" << (int)ws->materialType
<< " ownerClass=" << (owner ? (int)owner->GetClassID() : -1)
<< " slice X[" << collision.collisionSlice.minX << "," << collision.collisionSlice.maxX
<< "] Y[" << collision.collisionSlice.minY << "," << collision.collisionSlice.maxY
<< "] Z[" << collision.collisionSlice.minZ << "," << collision.collisionSlice.maxZ
<< "] pen=" << penetration << " dmg=" << damage->damageAmount
<< "\n" << std::flush;
}
// --- Mover owner (another mech / vehicle) (:15316-15358) ----------------
if (owner != 0 && owner->IsDerivedFrom(*Mover::GetClassDerivations()))
{
Vector3D rel;
Vector3D ownerVel = owner->GetWorldLinearVelocity(); // virtual (+0x20)
rel.Subtract(worldLinearVelocity, ownerVel);
// Separating-contact gate. MEMBER dot (Normal::operator*, NORMAL.h:29)
// -- NEVER the free Dot() recon stub (a variadic no-op returning 0
// that would silently defeat this gate).
if (damage->surfaceNormal * rel < -1.0e-4f) // _DAT_004ac044 (:15320-15323)
return;
// Mech-vs-mech (:15324-15358): the binary gates on owner ClassID == Mech
// (0xbb9) then dispatches the collision DamageMessage (zone==-1) to the
// other mech. STEP 6 (its cylinder table) now resolves the zone, so this
// is UNBLOCKED (was deferred: zone==-1 used to be dropped).
if (owner->IsDerivedFrom(*Mech::GetClassDerivations()))
{
BTDispatchCollisionDamage(this, owner, damage, collision);
}
}
// --- CulturalIcon owner (buildings/trees/props) (:15361-15404) ----------
if (owner != 0 && owner->IsDerivedFrom(*CulturalIcon::GetClassDerivations()))
{
Logical stopping =
((CulturalIcon *)owner)->IsStoppingCollisionVolume(); // flags & 0x8000 (:15362)
// Separating gate vs a STATIC icon (owner velocity == {0,0,0}, inlined
// by the 1995 compiler) (:15364-15368).
if (damage->surfaceNormal * worldLinearVelocity < -1.0e-4f)
return;
// Crunch (:15369-15401): dispatch the collision damage to the icon
// (zone==-1) BEFORE the walk-through sentinel overwrites the amount.
// Buildings with damage zones resolve via their handler; crushable props
// have none -> the base handler no-ops. UNBLOCKED by STEP 6.
BTDispatchCollisionDamage(this, owner, damage, collision);
if (!stopping)
damage->damageAmount = 0.00123f; // walk-through sentinel 0x3aa137f4 (:15402-15404)
}
// BINARY-TAIL-DEFERRED: collisionTemporaryState tail (:15406-15413) --
// implement together with the per-frame zero (@4aa741) and the named
// StateIndicator members.
}
//###########################################################################
//###########################################################################
// FeedHeatCapacityGauge / FeedHeatLevelGauge (cockpit)
//
// @004ac04c / @004ac064
//
// Two-line gauge callbacks: push the current heat capacity / heat level into
// the value field (+0xc) of the object pointed to by this+0x2ec. Registered
// as a per-frame gauge value source.
//
// NOTE: this+0x2ec is read by Simulate as a terrain "groundRef" (its +8 is a
// base height), so labelling it a GraphicGauge here is uncertain -- the two
// uses share a byte offset. The heatLevel/heatCapacity *sources* (0x518/0x51c)
// are certain Mech members; the *sink* identity is best-effort.
//###########################################################################
//###########################################################################
void
Mech::FeedHeatCapacityGauge()
{
*(Scalar *)(*(int *)(this + 0x2ec) + 0xc) = heatCapacity; // 0x51c -> +0xc
}
void
Mech::FeedHeatLevelGauge()
{
*(Scalar *)(*(int *)(this + 0x2ec) + 0xc) = heatLevel; // 0x518 -> +0xc
}
//###########################################################################
//###########################################################################
// LookupDamageState (static; keyword parse)
//
// @004ac194
//
// Map a damage-zone state keyword to its enum value, using the (name,value)
// table at .rdata:0050de74. Returns 1 and writes *out on a match, else 0.
// Recognized keywords (from the table):
// Destroyed=0 Damaged=1 CoolantLeaking=2 Overheating=3
// AmmoBurning=4 Jammed=5 BadPower=6
//###########################################################################
//###########################################################################
//
// Damage-state keyword table @0x50de74 (shared with mechsub's status states).
//
struct DamageStateEntry { const char *name; int value; };
static const DamageStateEntry kDamageStateTable[] =
{
{ "Destroyed", 0 },
{ "Damaged", 1 },
{ "CoolantLeaking", 2 },
{ "Overheating", 3 },
{ "AmmoBurning", 4 },
{ "Jammed", 5 },
{ "BadPower", 6 },
{ 0, 0 }
};
/*static*/ Logical
Mech::LookupDamageState(const char *keyword, int *out)
{
for (const DamageStateEntry *e = kDamageStateTable; // &PTR_s_Destroyed_0050de74
e->name != 0; ++e)
{
if (Strcmp(keyword, e->name) == 0) // FUN_004d4b58
{
*out = e->value;
return True;
}
}
return False;
}
//===========================================================================//
// End of recovered mech4.cpp slice.
//===========================================================================//