Files
BT411/game/reconstructed/mech4.cpp
T
arcattackandClaude Fable 5 cf9f56044e Aim coordinates: route all conversions through the true NDC (fixes the
sideways lock offset)

The windowed present stretches the fixed 800x600 backbuffer into the
client area while the projection's aspect follows the CLIENT (resize
rebuild) -- so the dpl2d/reticle frame, the client frame and NDC only
coincide for a backbuffer-shaped window. The mouse mapping and the
square-frame ray shortcut (cx = rx*tanHalfFov) were exact at screen
centre and drifted outward with a side-flipped sign: aiming left of the
enemy locked as if right of it (user report).

- BTSetAimProjection now publishes BOTH proj scales (P11 carries the
  aspect) + the backbuffer size (the dpl2d frame). NOTE: GetViewport at
  the loop top holds the PREVIOUS frame's last pass viewport (gauge
  passes shrink it) -> use the renderer's own GetWidth/GetHeight.
- BTGetAimRay / BTProjectToReticle convert reticle <-> NDC per axis
  (ndc_x = rx * vh/vw; camera x = ndc_x/P11) -- exact for any window
  shape and FOV.
- New BTClientToReticle undoes the present stretch for the mouse
  (client px -> viewport px -> reticle coords); mech4 uses it instead
  of the raw client-height mapping.
- 1Hz aim telemetry in the [target] log (hits/noRay/noPick): verified
  100% pick hit rate once the projection is live (the pre-visibility
  mission-load seconds report noRay -- that was the "flicker").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:13:45 -05:00

3245 lines
152 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
#include <hostmgr.hpp> // HostManager::GetEntityPointer (band-effect attacker resolve)
#if !defined(EMITTER_HPP)
# include <emitter.hpp> // Emitter/PPC beam state (the per-weapon beam render walk)
#endif
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)
// AUTHENTIC TARGETING (task #36): the reticle slew state, in reticle/dpl2d
// coordinates (centered origin, +y down, unit = half viewport height). The
// pod slewed this with the stick free-aim channel; the dev box uses the MOUSE
// (absolute cursor -> client -> reticle) or the BT_AIM="x y" harness. Read by
// the HUD Draw (crosshair position) and the pick-ray each sim frame.
float gBTAimX = 0.0f;
float gBTAimY = 0.0f;
// The HUD designator feed (mech4 -> btl4vid Draw): the designated target's
// world chest point + the lock state (0 = none, 1 = designated, 2 = HOT --
// the crosshair is ON it right now).
int gBTHudLockState = 0;
float gBTHudLockWorld[3] = { 0, 0, 0 };
// 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;
int gBTMissileTrigger = 0; // the split missile-fire channel (CTRL)
static int gBTMissileKey = 0; // raw key state (set by the keyboard poll)
// 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;
}
}
// AUTHENTIC (task #36): NO fallback target. The launcher passes the mech's
// own designated-target slots (owner+0x388 via GetTargetPosition); with no
// designation the missile does not launch -- the acquisition (crosshair on
// the enemy -> pick -> designate) is the ONLY route to a target, exactly
// like the energy weapons. (The old gEnemyMech fallback pre-dated the
// acquisition and let missiles bypass it.)
Point3D tpos = targetPos;
if (target == 0)
return;
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;
// AUTHENTIC missile look: the round is a short hot streak, and the trail
// is the real dsrm smoke-trail effect (psfx 0, "the lrm smoke trail")
// puffed along the flight path each frame with local +Z = backward (the
// .PFX's velocities stream the smoke behind the round). This replaces
// the old 3-segment white tracer lines (a bring-up placeholder).
Vector3D bd = p.dir;
BTPushBeam(prev.x, prev.y, prev.z, p.pos.x, p.pos.y, p.pos.z, 0x00FFB040u, 0.10f, 0.9f); // the round (hot streak)
{
extern void BTPfxTrailPuff(int, float, float, float, float, float, float, int);
BTPfxTrailPuff(0, prev.x, prev.y, prev.z, -bd.x, -bd.y, -bd.z, 2);
}
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)
{
// UNAIMED (STEP 6): the projectile's world impact position IS the
// hit point; Mech::TakeDamageMessageHandler resolves the struck
// zone from the cylinder hit-location table. (Previously this
// aimed the internal vital zone directly -> invisible insta-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*/, -1 /*unaimed -> cylinder resolves*/, 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=" << take_damage.damageZone << " (cyl-resolved)\n" << std::flush;
}
}
p.active = 0;
}
}
}
//###########################################################################
// MechDeathHandler effect-spawn bridge (mechdmg.cpp calls this)
//
// Spawns a damage-state descriptor's explosion at the mech. The authentic path
// (FUN_0043663c -> FUN_004364e4) broadcasts a class-5 message to the effect
// manager app+0x38 (the 0xBD3 SubsystemMessageManager, unreconstructed); we use
// the same established Explosion::Make port the weapon path uses. The binary
// derives the position from the subsystem (mech+0x184); we use the mech origin.
//###########################################################################
void
BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_index)
{
if (mech == 0)
return;
ResourceDescription::ResourceID res = (ResourceDescription::ResourceID)effect_resource;
if (res <= 0)
res = gExplodeRes; // fall back to the resolved generic explosion
if (res <= 0)
return; // nothing to spawn yet
//
// Effect position: the damaged zone's SEGMENT, in world space (the binary
// derives the effect position from the damaged subsystem/zone, not the mech
// origin -- an origin-anchored effect burns at ground level between the
// feet). Resolve segment_index through the segment table exactly as the
// gun-port muzzles do (GetSegmentToEntity x localToWorld); fall back to
// torso height over the origin when the zone has no segment.
//
Origin o = mech->localOrigin;
Point3D fxPos = o.linearPosition;
fxPos.y += kMuzzleHeight; // default: torso height
if (segment_index >= 0)
{
EntitySegment::SegmentTableIterator it(mech->segmentTable);
EntitySegment *seg;
while ((seg = it.ReadAndNext()) != NULL)
{
if (seg->GetIndex() == segment_index)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), mech->localToWorld);
fxPos = mw; // Point3D = matrix translation
break;
}
}
}
o.linearPosition = fxPos;
// IMPACT FRAME for the band effect: orient local -Z from the victim toward
// the LAST ATTACKER (lastInflictingID, maintained by TakeDamageMessageHandler)
// -- the .PFX offsets are authored "out of the struck armor". Falls back to
// the victim's own frame when the attacker can't be resolved.
if (application != 0 && application->GetHostManager() != 0)
{
Entity *attacker =
application->GetHostManager()->GetEntityPointer(mech->lastInflictingID);
if (attacker != 0 && attacker != (Entity *)mech)
{
float adx = (float)(mech->localOrigin.linearPosition.x
- attacker->localOrigin.linearPosition.x);
float adz = (float)(mech->localOrigin.linearPosition.z
- attacker->localOrigin.linearPosition.z);
if (adx * adx + adz * adz > 1e-6f)
o.angularPosition = // -Z at the attacker
EulerAngles(0.0f, (Scalar)atan2((double)adx, (double)adz), 0.0f);
}
}
Explosion::MakeMessage m(
Explosion::MakeMessageID, sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID, EntityID::Null,
res, Explosion::DefaultFlags, o,
mech->GetEntityID(), mech->GetEntityID());
Explosion *e = Explosion::Make(&m);
if (e)
Register_Object(e);
}
//###########################################################################
// Death sequence -- the un-exported master-perf death branch (region
// 0x4a9770-0x4ab188), reconstructed from its EXPORTED consumers + the RP
// VTV::DeathShutdown analog. See context/combat-damage.md "Death SEQUENCE".
//
// On a vital kill the damage side raises graphicAlarm to 9 (mechdmg.cpp:426/586).
// The authentic per-frame death transition (movementMode 5-8 = collapse -> 2/9 =
// disabled) lives in the Simulate the bring-up drive bypasses, so it is run here.
//###########################################################################
Logical
Mech::IsMechDestroyed()
{
// graphicAlarm level 9 = a vital zone / leg gone -> going down (mechdmg.cpp).
return graphicAlarm.GetLevel() >= 9 ? True : False;
}
void
Mech::UpdateDeathState(Scalar dt)
{
if (!IsMechDestroyed()) // alive
return;
if (movementMode == 9) // already settled (disabled/frozen)
{
// WRECK SMOKE (port addition, [T3]): keep the dead hulk smoking -- re-fire
// the death/rubble smoke plume (psfx 1 = DDTHSMK, "the mech death/rubble
// smoke plume") each time its authored 10-second emission window elapses.
// The .PFX itself trickles 3 particles/sec for 10s; re-arming it on that
// same cadence reads as a continuously burning wreck. The one-shot death
// visuals (dnboom + skins) stay in the kill path.
// The wreck hulk's quadratic SINK (the 1996 script's burial): the pieces
// settle into the ground and are gone ~17s after the kill. While the
// wreck is still visible, keep the death/rubble smoke plume alive on its
// authored 10s window; once buried, the smoke stops with it.
extern int BTWreckSinkTick(Entity *victim, float dt);
int wreck_visible = BTWreckSinkTick(this, (float)dt);
// BURIAL TRANSITION (one-shot): the authentic game REMOVES the dead
// entity (the death row) once its wreck is gone; full entity teardown is
// the remaining P5 follow-through (the render tree must be unhooked
// first), so until then the buried wreck goes INERT instead: no
// collision volume (stops phantom blocking -- the assistant's gather
// skips a mover with collisionVolumeCount 0, and MoveCollisionVolume
// early-outs) and no target lock (stops phantom hits/impact smoke on
// the empty spot).
if (!wreck_visible && collisionVolumeCount != 0)
{
// Zeroing the count stops MoveCollisionVolume from re-placing the
// box -- but the mech-vs-mech gather (Mover::GetCurrentCollisions)
// tests mover->collisionVolume DIRECTLY, never the count, so the
// stale box would keep blocking at the wreck spot. PARK it far
// underground: a box 100km down intersects nothing, ever.
collisionVolumeCount = 0;
if (collisionVolume != 0)
{
collisionVolume->minY -= 100000.0f;
collisionVolume->maxY -= 100000.0f;
}
if ((Entity *)this == gEnemyMech)
gEnemyMech = 0;
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] buried wreck went INERT (collision parked, "
"target lock dropped)\n" << std::flush;
}
wreckSmokeTimer -= dt;
if (wreck_visible && wreckSmokeTimer <= 0.0f)
{
wreckSmokeTimer = 10.0f; // DDTHSMK's release window
extern void BTStartPfx(int effect_number, float x, float y, float z);
BTStartPfx(1,
(float)localOrigin.linearPosition.x,
(float)(localOrigin.linearPosition.y + kMuzzleHeight),
(float)localOrigin.linearPosition.z);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] wreck smoke plume re-armed (psfx 1)\n" << std::flush;
}
return;
}
//
// DEATH = FREEZE, not collapse (decomp-verified 2026-07-08, task #32).
// The fall modes 5-8 latch leg/body clips from animation-table slots
// 0x1c-0x1f (FUN_004a5028) -- but NO loader anywhere in the binary fills
// those slots (mech+0x63c..0x648 appear in no exported function; the table
// loader FUN_004a80d4 stops at the knockdown slot 0x20), NO fall clip
// exists in the shipped 27-clip set, and firing the latch would bind
// resource id 0 -- a StaticAudioStream -- as keyframes (garbage). The
// fall modes are engine-lineage vestiges; BT's death modes are the FREEZE
// modes: IsDestroyed() == (movementMode 2 || 9), and FUN_004ab1c8 zeroes
// locomotion for them. So: shut the subsystems down and settle straight
// to 9 (disabled/frozen wreck) -- never 5-8. The death READ comes from
// the effect layer (dnboom + the ddthsmk plume) + the destroyed skins.
//
// RP VTV::DeathShutdown analog: shut every subsystem down. The base
// Subsystem::DeathShutdown is an empty virtual (SUBSYSTM.h); overrides
// act. This is a SHUTDOWN, not teardown -- it frees nothing and never
// removes the entity, so it is safe (the wreck STAYS).
for (int i = 0; i < GetSubsystemCount(); ++i)
{
Subsystem *s = GetSubsystem(i);
if (s != 0)
s->DeathShutdown(1);
}
movementMode = 9; // disabled: IsDisabled() true, locomotion frozen
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] mech " << GetEntityID()
<< " destroyed -> subsystem shutdown + frozen wreck (IsDisabled="
<< (int)IsDisabled() << ")\n" << std::flush;
}
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());
// DEATH consumer -- runs for EVERY mech (player + spawned targets). On a
// vital kill it collapses + shuts the subsystems down + settles to disabled;
// the drive's movementMode write below is guarded on !IsMechDestroyed so a
// dead mech keeps its death state instead of reverting to a live gait.
UpdateDeathState(dt);
// MechDeathHandler (@0042aa2c): per-frame destroyed-skin + explosion effects as
// this mech's zones cross damage thresholds. The binary ticks it off the mech's
// Performance list (mech+0xbc), which the bring-up drive override bypasses, so
// it is driven here. Runs for EVERY mech (so the enemy visibly falls apart too).
if (deathHandler != 0)
((MechDeathHandler *)deathHandler)->Tick();
// 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;
// SPLIT WEAPON CONTROLS (interim, toward the real controls-mapper
// weapon groups): SPACE / LMB = the energy weapons (lasers),
// CTRL / RMB = the missile launchers. (The mouse buttons pair
// with the mouse-slewed reticle -- task #36.)
gBTDrive.fire = focused && ((pAsync(0x20 /*VK_SPACE*/) |
pAsync(0x01 /*VK_LBUTTON*/)) & dn) ? 1 : 0;
gBTMissileKey = focused && ((pAsync(0x11 /*VK_CONTROL*/) |
pAsync(0x02 /*VK_RBUTTON*/)) & 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;
// V: toggle the view between the authentic COCKPIT eyepoint
// (the pod's only view) and the external chase camera.
static int sPrevV = 0, sViewInside = 0;
const int vNow = focused && (pAsync('V') & dn) ? 1 : 0;
if (vNow && !sPrevV)
{
sViewInside = !sViewInside;
extern void BTSetViewInside(int inside);
BTSetViewInside(sViewInside);
}
sPrevV = vNow;
// RETICLE SLEW (task #36): the pod slewed the target reticle
// with the stick free-aim channel; the dev-box stand-in is the
// MOUSE -- absolute cursor position mapped through the client
// rect into reticle coordinates (centered, +y down, unit =
// half the client height -- the dpl2d frame). BT_AIM="x y"
// pins the crosshair for headless harness runs.
{
static int sAimEnv = -1;
static float sAimEnvX = 0.0f, sAimEnvY = 0.0f;
if (sAimEnv < 0)
{
const char *av = getenv("BT_AIM");
sAimEnv = (av != 0 &&
sscanf(av, "%f %f", &sAimEnvX, &sAimEnvY) == 2) ? 1 : 0;
}
if (sAimEnv)
{
gBTAimX = sAimEnvX;
gBTAimY = sAimEnvY;
}
else if (focused && pFg != 0)
{
typedef int (__stdcall *CurFn)(POINT *);
typedef int (__stdcall *StcFn)(void *, POINT *);
typedef int (__stdcall *CrFn)(void *, RECT *);
static CurFn pCur = 0; static StcFn pStc = 0; static CrFn pCr = 0;
if (pCur == 0)
{
HMODULE u = GetModuleHandleA("user32.dll");
pCur = (CurFn)GetProcAddress(u, "GetCursorPos");
pStc = (StcFn)GetProcAddress(u, "ScreenToClient");
pCr = (CrFn)GetProcAddress(u, "GetClientRect");
}
void *wnd = pFg(); // focused == our window
POINT cp; RECT rc;
if (pCur && pStc && pCr && wnd != 0 &&
pCur(&cp) && pStc(wnd, &cp) && pCr(wnd, &rc) &&
rc.right > rc.left && rc.bottom > rc.top)
{
// client px -> reticle coords through the RENDERER
// (it undoes the backbuffer->client present stretch;
// a raw client-height mapping is only exact for a
// backbuffer-shaped window and skews the pick
// sideways otherwise).
extern void BTClientToReticle(float mx, float my,
float cw, float ch, float *rx, float *ry);
float rx = 0.0f, ry = 0.0f;
BTClientToReticle((float)cp.x, (float)cp.y,
(float)(rc.right - rc.left),
(float)(rc.bottom - rc.top), &rx, &ry);
gBTAimX = rx;
gBTAimY = ry;
}
}
}
}
}
// DEV: BT_AUTOFIRE=1 holds the trigger (drives the fireForced hook) and
// BT_AUTODRIVE=<0..1> holds the throttle (drives the forced hook) so the
// full walk->fire->damage->death chain can be exercised headlessly.
{
static int sAutoFire = -1;
static float sAutoDrive = -1.0f;
if (sAutoFire < 0)
{
const char *af = getenv("BT_AUTOFIRE");
sAutoFire = (af && *af == '1') ? 1 : 0;
const char *ad = getenv("BT_AUTODRIVE");
sAutoDrive = ad ? (float)atof(ad) : 0.0f;
}
gBTDrive.fireForced = sAutoFire;
if (sAutoDrive > 0.0f)
{
gBTDrive.forced = 1;
gBTDrive.forcedThrottle = sAutoDrive;
}
}
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;
if (!IsMechDestroyed()) // a dead mech keeps its death movementMode
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;
}
}
}
// FIRING ARC -- now an EXPLICIT OPT-IN presentation clamp only (task #36).
// AUTHENTIC: the binary has NO aim/arc test -- Emitter::FireWeapon engages
// the LOCKED target whenever HasActiveTarget (part_013.c:7758); the skill
// is ACQUIRING the lock (crosshair on the enemy -> pick ray -> designate).
// The old ±30°-default cone was a stand-in from before the acquisition
// existed. Set BT_FIRE_ARC=<degrees> to re-enable the cone (+ the mech's
// real torso reach) as an external-camera presentation clamp; unset = the
// authentic no-arc model.
bool targetInArc = true;
{
static Scalar s_baseRad = -2.0f;
if (s_baseRad < -1.5f)
{
const char *av = getenv("BT_FIRE_ARC");
s_baseRad = (av != 0) ?
(Scalar)((double)atof(av) * 3.14159265358979 / 180.0) : -1.0f;
}
if (s_baseRad >= 0.0f && gEnemyMech != 0)
{
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;
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;
Scalar half = s_baseRad + GetHorizontalFiringReach();
if (half > 3.14159265f) half = 3.14159265f;
targetInArc = (d >= (Scalar)cos((double)half));
}
}
{
// (The old fwd/muzzle-collection + straight-ahead `aim` block that
// lived here fed the pre-#33 single-visual beam; the per-weapon
// emitter beams below carry their own live muzzle + endpoint, so it
// was dead code and was removed with the task-#36 acquisition work.)
// 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;
}
}
// AUTHENTIC PER-WEAPON BEAMS (task #33): each Emitter/PPC draws ITS OWN
// beam from its live sim state. The state machine is the REAL one:
// FireWeapon arms beamFlag + dischargeTimer (= the weapon's authored
// DischargeTime); ServiceDischarge clears it when the window expires;
// the recharge gate is the weapon's own authored RechargeRate. The
// muzzle resolves LIVE from the weapon's real mount segment (the beam
// origin tracks the gun as the mech moves), the endpoint is the fire's
// stored world hit point, and the colour is the weapon's authored
// PipColor. Volley-vs-stagger patterns, cadence and colours all now
// emerge from each mech's real loadout (the BLH mounts 3 lasers + 2
// PPCs) instead of the old hardcoded single-look stagger, so every
// mech type fires like its data says. (Old stagger/keepalive block
// removed; see the git history for the bring-up scaffolding.)
{
extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float);
const Scalar ttl1 = (dt > 1e-4f) ? dt : 1e-4f; // one-frame life (redrawn while on)
static int s_beamStateLog = 0;
int energyOrdinal = -1; // Nth energy weapon (port assignment)
for (int wi = 0; wi < GetSubsystemCount(); ++wi)
{
Subsystem *ws = GetSubsystem(wi);
if (ws == 0)
continue;
// EXACT class filter: Emitter (0xBC8=3016) or PPC (0xBD4=3028).
// The derivation check matched too broadly here (the recon
// derivation chains are shared stubs for some subsystems --
// a Sensor and the MissileLauncher passed and drew garbage
// from misinterpreted offsets).
const int wcid = (int)ws->GetClassID();
if (wcid != 3016 && wcid != 3028)
continue;
++energyOrdinal;
Emitter *em = (Emitter *)ws;
if (!em->BeamOn())
continue;
Point3D mz;
em->MuzzlePoint(mz); // LIVE muzzle (tracks the gun)
// MOUNT FALLBACK: when the weapon's mount segment doesn't
// resolve, GetMuzzlePoint returns the mech ORIGIN (feet).
// Assign this weapon its own gun-port segment by roster
// ordinal (the same port set the old visual used) so each
// energy weapon keeps a stable muzzle on the arms.
if (mz.y - localOrigin.linearPosition.y < 1.0f)
{
static const char *const kGunPorts[] =
{ "siterugunport", "sitelugunport", "siterdgunport",
"siteldgunport", "siterbgunport", "sitelbgunport" };
static EntitySegment *s_portCache[64];
static int s_portTried[64];
if (energyOrdinal >= 0 && energyOrdinal < 64)
{
if (!s_portTried[energyOrdinal])
{
s_portTried[energyOrdinal] = 1;
s_portCache[energyOrdinal] = GetSegment(
CString(kGunPorts[energyOrdinal % 6]));
}
if (s_portCache[energyOrdinal] != 0)
{
AffineMatrix mw;
mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(),
localToWorld);
mz = mw; // Point3D = matrix translation
}
}
}
const Point3D &bend = em->BeamEndpoint(); // the fire's world hit point
// authored per-weapon colour; unset (-1) -> the ER-laser red
RGBColor pc = em->PipColor();
float r = (float)pc.Red, g = (float)pc.Green, b = (float)pc.Blue;
if (r < 0.0f || g < 0.0f || b < 0.0f) { r = 0.78f; g = 0.08f; b = 0.02f; }
// PPC (classID 0xBD4 = 3028): a thicker, brighter bolt than a laser tube.
const int isPPC = ((int)em->GetClassID() == 3028);
// ONE draw per beam, at the model's NATURAL width: the weapon's
// own tube (ERMLASER radius 0.22u, PPC bolt 0.62u) IS the beam
// -- the old inflated two-layer glow/core (3.0x + 0.9x widths)
// drew fat cartoon cylinders 13x the authored size. The tint
// modulates the scrolling grit; thin natural tubes stay under
// saturation without a hand-dimmed core.
extern void BTPushBeamKind(float,float,float, float,float,float,
unsigned, float, float, int);
unsigned tint =
(((unsigned)(40.0f + r * 215.0f) & 0xFF) << 16) |
(((unsigned)(40.0f + g * 215.0f) & 0xFF) << 8) |
((unsigned)(40.0f + b * 215.0f) & 0xFF);
BTPushBeamKind(mz.x, mz.y, mz.z, bend.x, bend.y, bend.z,
tint, ttl1, 1.0f /* natural model width */,
isPPC ? 1 : 0 /* ppc.bgf : ermlaser.bgf */);
if (getenv("BT_BEAM_LOG") && (s_beamStateLog++ % 30) == 0)
DEBUG_STREAM << "[beam] " << (isPPC ? "PPC" : "laser") << " #" << wi
<< " mz=(" << mz.x << "," << mz.y << "," << mz.z
<< ") end=(" << bend.x << "," << bend.y << "," << bend.z
<< ") rgb=(" << r << "," << g << "," << b << ")\n" << std::flush;
}
}
}
// --- TARGETING (task #36 -- the AUTHENTIC Reticle acquisition,
// engine/MUNGA/RETICLE.h [T0]): the pilot slews the crosshair
// (gBTAimX/Y); a pick ray through it tests the enemy's collision
// volume; the mech UNDER the crosshair becomes the designated target
// (sticky -- it persists when the crosshair drifts off, marked by
// the HUD designator; re-hover to refresh, burial clears it). The
// designation feeds the engine-Entity target slots the whole weapon
// path reads (target position @0x37c, target entity @0x388, zone
// @0x38c). While the crosshair is ON the target ("hot"), the aim
// point is the PICKED HULL POINT -- the STEP-6 cylinder lookup then
// resolves damage to the zone under the crosshair (aimed fire).
Entity *hotTarget = 0; // mech under the crosshair NOW
Point3D hotPoint; // picked world point on its hull
static int gAimNoRay = 0, gAimNoPick = 0, gAimHits = 0; // 1Hz diagnostics
{
extern int BTGetAimRay(float rx, float ry, float outStart[3], float outDir[3]);
float rs[3], rd[3];
if (!BTGetAimRay(gBTAimX, gBTAimY, rs, rd))
{
++gAimNoRay;
}
else if (gEnemyMech != 0)
{
Point3D rayStart(rs[0], rs[1], rs[2]);
Vector3D rayDir(rd[0], rd[1], rd[2]);
if (((Mech *)gEnemyMech)->PickRayHit(rayStart, rayDir, 4000.0f, &hotPoint))
{
hotTarget = gEnemyMech;
++gAimHits;
}
else
++gAimNoPick;
}
// The Reticle struct (the mech's TargetReticle attribute): position,
// pick result. targetDamageZone stays -1 -- the zone ROLL happens at
// damage delivery (the authentic percent-table roll, STEP 6).
targetReticle.reticlePosition.x = gBTAimX;
targetReticle.reticlePosition.y = gBTAimY;
targetReticle.targetEntity = hotTarget;
targetReticle.targetDamageZone = -1;
if (hotTarget != 0)
targetReticle.rayIntersection = hotPoint;
// The target slots MIRROR the pick each frame: you are locked while
// the crosshair is ON the mech, and not otherwise. (A first cut
// made the designation STICKY -- once hovered, locked forever -- but
// that made aim irrelevant after the first acquisition, and the
// binary's own code treats no-target as a FREQUENT live state: the
// fire path re-checks 0x388!=0 at every step (part_013.c:7689/7727)
// and the HUD range feed has a permanent no-target default of 1200
// (part_013.c:5636). Keeping the crosshair on the enemy IS the
// gunnery.)
if (hotTarget != 0)
{
MECH_TARGET_ENTITY(this) = hotTarget;
MECH_TARGET_SUBIDX(this) = -1;
MECH_TARGET_POS(this) = hotPoint; // the aimed hull point
}
else
{
MECH_TARGET_ENTITY(this) = 0; // crosshair off -> no lock
MECH_TARGET_SUBIDX(this) = -1;
}
}
// HUD feeds: the range caret + the designator box (world point + state).
{
extern void BTSetHudTargetRange(Scalar range);
Entity *des = MECH_TARGET_ENTITY(this);
if (des != 0)
{
Point3D dp = ((Mech *)des)->localOrigin.linearPosition;
float hddx = dp.x - localOrigin.linearPosition.x;
float hddz = dp.z - localOrigin.linearPosition.z;
BTSetHudTargetRange((Scalar)sqrtf(hddx*hddx + hddz*hddz));
gBTHudLockState = 2; // locked == crosshair on it
gBTHudLockWorld[0] = dp.x;
gBTHudLockWorld[1] = dp.y + kMuzzleHeight;
gBTHudLockWorld[2] = dp.z;
}
else
{
BTSetHudTargetRange(1200.0f); // no target: the binary default
gBTHudLockState = 0; // (0x44960000 @part_013.c:5637)
}
}
if (gEnemyMech != 0)
{
Point3D enemyPos = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
const int designated = (MECH_TARGET_ENTITY(this) != 0);
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] aim=(" << gBTAimX << "," << gBTAimY << ")"
<< (hotTarget != 0 ? " HOT (crosshair on target)"
: (designated ? " designated (off-crosshair)" : " no target"))
<< " range=" << range
<< (range <= kWeaponRange ? " IN RANGE" : "")
<< " [hits=" << gAimHits << " noRay=" << gAimNoRay
<< " noPick=" << gAimNoPick << "]"
<< "\n" << std::flush;
gAimHits = 0; gAimNoRay = 0; gAimNoPick = 0;
}
// --- 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. AUTHENTIC gating: the trigger passes through and the
// weapon's OWN HasActiveTarget gate decides (Emitter::FireWeapon fires only
// with a designated target -- part_013.c:7758; no aim/arc test exists).
// targetInArc is the explicit BT_FIRE_ARC presentation clamp (default true).
extern int gBTWeaponTrigger;
gBTWeaponTrigger = (fireWanted && targetInArc) ? (gBTWeaponTrigger ? 0 : 1) : 0;
// the missile channel: CTRL / RMB (or the autofire harness) -- same edge pulse
extern int gBTMissileTrigger;
const int missileWanted = gBTDrive.fireForced || gBTMissileKey;
gBTMissileTrigger = (missileWanted && targetInArc) ? (gBTMissileTrigger ? 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 && designated && targetInArc && gFireCooldown <= 0.0f
&& range <= kWeaponRange && gExplodeReady == 1)
{
gFireCooldown = kFireCooldown;
++gShotCount;
// Impact point (task #36): while the crosshair is ON the target the
// shot lands at the PICKED HULL POINT -- the STEP-6 cylinder lookup
// resolves damage to the zone under the crosshair (aimed fire: leg
// shots hit legs, torso shots hit the torso). Off-crosshair (the
// sticky designation converging), the shot enters on the surface
// toward the shooter at chest height (the unaimed synth point --
// exactly the axis point would be angularly degenerate for the
// cylinder sector lookup).
Point3D impact;
if (hotTarget != 0)
{
impact = hotPoint;
}
else
{
impact = enemyPos;
if (range > 1e-3f)
{
impact.x -= (ddx / range) * 3.0f; // ~torso radius toward shooter
impact.z -= (ddz / range) * 3.0f;
}
impact.y += kMuzzleHeight; // chest height (beam aim height)
}
Origin exp_origin = ((Mech *)gEnemyMech)->localOrigin;
exp_origin.linearPosition = impact; // at the hit point
// IMPACT FRAME: the .PFX hit effects are authored with local -Z =
// "out of the struck armor" (DAFC offsets/velocities spray -Z).
// That is the IMPACT normal -- toward the SHOOTER -- not the
// victim's body front: with the victim's own quat a rear/side hit
// flashed on the FAR (front) side of the mech. Build the frame as
// a yaw with -Z aimed from the victim at the shooter. Engine yaw
// convention (MATRIX.cpp:196-209): forward -Z = (-sin y, 0, -cos y)
// -> yaw = atan2(ddx, ddz) points -Z at the shooter. [T0]
exp_origin.angularPosition =
EulerAngles(0.0f, (Scalar)atan2((double)ddx, (double)ddz), 0.0f);
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, STEP 6): dispatch UNAIMED (zone == -1) with the
// beam's world entry point; Mech::TakeDamageMessageHandler resolves
// the zone from the cylinder hit-location table (the STEP-6
// reconstruction) and the base handler routes it to
// Mech__DamageZone::TakeDamage (the real armor/structure model).
// Hits therefore land on the EXTERIOR zone facing the shooter
// (arm/leg/torso -- zones with visible segments that wreck), and
// internal vitals die only through the authentic destruction
// cascade (RecurseSegmentTable / SendSubsystemDamage) -- no more
// invisible one-shot kills on the soft internal vital zone.
if (gEnemyMech->damageZoneCount > 0)
{
// (impact computed above -- shared with the hit-explosion origin)
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 = impact; // world impact point
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
GetEntityID(), // inflicting = this (shooter)
-1, // UNAIMED -> receiver's cylinder resolves
dmg);
gEnemyMech->Dispatch(&take_damage);
// gauge scoring wave (Step 6): credit the local player for damage
// dealt -> SCORE climbs per hit (currentScore += tonnageRatio*award).
// No score for pounding the wreck (damage still applies -- zones
// clamp at 1.0, further segments wreck visibly).
if (!gEnemyDestroyed)
BTPostDamageScore(gEnemyMech, kShotDamage);
// The handler wrote the resolved zone back into the message.
int zone = take_damage.damageZone;
if (zone >= 0 && zone < gEnemyMech->damageZoneCount)
{
Scalar s = ((Mech *)gEnemyMech)->Zone(zone)->damageLevel; // [0,1], 1.0=destroyed
DEBUG_STREAM << "[damage] hit zone " << zone << "/"
<< gEnemyMech->damageZoneCount
<< " 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: fire the victim's AUTHENTIC per-mech death
// ModelList -- resources 22-25 are 'blhdead'/'lokdead'/'owndead'/
// 'thrdead' (BTL4.RES); its authored video objects carry the death
// effect numbers (the burning-WRECK script chain, ExplosionScripts
// case 4 loads <mech>dbr.bgf + flames in the 1996 binary). Resolve
// "<prefix>dead" by name (prefix = the anim/model prefix); fall back
// to the generic explode resource if the mech has no dead list.
Origin death_origin = ((Mech *)gEnemyMech)->localOrigin;
death_origin.linearPosition.y += kMuzzleHeight;
ResourceDescription::ResourceID dead_res = gExplodeRes;
if (application != 0 && application->GetResourceFile() != 0)
{
ResourceDescription *dr =
application->GetResourceFile()->FindResourceDescription(
"blhdead", (ResourceDescription::ResourceType)1, -1);
if (dr != 0)
{
dead_res = dr->resourceID;
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] firing authentic death list 'blhdead' id="
<< (long)dead_res << "\n" << std::flush;
}
}
Explosion::MakeMessage death_exp(
Explosion::MakeMessageID,
sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID,
EntityID::Null,
dead_res,
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;
// KEEP the wreck targeted (do NOT null gEnemyMech): the wreck
// STAYS in the world (removal = the P5 teardown crash), so the
// beam convergence must keep terminating ON it -- nulling the
// lock here made every later beam a "free" ray that visibly
// passed through the standing wreck. Damage + kill scoring are
// latched off above via gEnemyDestroyed.
}
}
}
}
// --- 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;
// DEV: BT_ROSTER=1 dumps the LOADOUT -- every roster subsystem's class
// name + id (settles "is this mech supposed to mount weapon X?" from the
// shipped subsystem stream, not assumptions).
if (getenv("BT_ROSTER"))
{
for (int ri = 0; ri < subsystemCount; ++ri)
{
Subsystem *rs = GetSubsystem(ri);
if (rs == 0)
continue;
Derivation *rd = rs->GetDerivation();
DEBUG_STREAM << "[roster] " << ri
<< " classID=" << (int)rs->GetClassID()
<< " " << (rd ? rd->className : "?") << "\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.
//===========================================================================//