Files
BT412/game/reconstructed/mech4.cpp
T
arcattackandClaude Opus 4.8 247e51e1e1 Reset-based respawn: reuse+heal the mech, not create-a-new-one (task #52)
The respawn glitches (2 mechs, on-fire respawn, camera-inside, can't-control,
wreck-never-disappears) all traced to one architectural divergence: our respawn
SEVERED playerVehicle on death and CREATED a new mech, leaving the old as a
permanent wreck and building a duplicate viewpoint whose old render tree was
never torn down.

The authentic engine (FUN_0049fb74 + RPPlayer) REUSES the same mech entity: on
respawn Mech::Reset heals it and moves it in place. Implemented faithfully,
adapted to our layout (the 1995 raw offsets map to different 2007 engine fields,
so reset the equivalent named members, not the offsets):
- Mech::Reset (real, was a reposition-only stub): reposition + kill dead-reckon
  (projectedOrigin/projectedVelocity/updateVelocity + our relocated gait
  accumulators) so the replicant stops lerping to the death spot; clear the
  death latch (movementMode=1, graphicAlarm=0); Heal every damage zone
  (new Mech__DamageZone::Heal: full structure, intact skin); DeathReset
  (vtable+0x28) every subsystem; ForceUpdate to broadcast.
- btplayer.cpp: VehicleDead no longer severs playerVehicle; the respawn re-post
  gates on the mech still being dead; DropZoneReply resets the EXISTING mech in
  place (heal+move) instead of creating a new one, then fires the warp. Warp
  moved to the shared placement (initial drop-in + respawn).

Verified 2-node: mech entity ID stays 3:22 across 3 deaths (reused, not a new
3:32); each Reset logs alive=1, 20 zones healed, 33 subsystems reset; A sees ONE
mech (no wreck+new pair). Warp fires each respawn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:13:44 -05:00

3709 lines
172 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(PLAYER_HPP)
# include <player.hpp> // Player::VehicleDeadMessage -- the death->respawn notification (task #52)
#endif
#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
// (kWeaponRange removed: the damage gate now reads the AUTHENTIC per-weapon
// targetWithinRange -- effectiveRange @0x328 = (1-damage) x the authored
// WeaponRange: BLH lasers 500 / missiles 800 / PPCs 900 m.)
// 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 locked target's
// HOTBOX point (its top-centre; the recovered Execute frames x+-4 around it,
// +1 above / -11.5 below) + the lock state (0 = none, 2 = locked).
int gBTHudLockState = 0;
float gBTHudLockWorld[3] = { 0, 0, 0 };
// Recovered-Execute instrument feeds (task #37): the compass heading, the
// torso-twist tape (deflection over the per-mech twist limit), and the
// weapon-group display mask (the Reticle element mask's low nibble).
float gBTHudHeading = 0.0f;
float gBTHudTwist = 0.0f;
float gBTHudTwistLimit = 0.0f;
int gBTHudGroupMask = 0xF;
int gBTHudPrimary = 1; // PrimaryHudOn (element mask 0x20): full HUD vs simple X
// 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 triggers (bring-up): pulsed per player frame; read by the weapon
// sims so CheckFireEdge sees rising edges. Non-static (the weapons extern
// them). WEAPON GROUPS (task #43): three KEYBOARD fire channels -- an interim
// pod-like split standing in for the authentic ConfigureMappables/ChooseButton
// mapper channels: 1/SPACE = lasers, 2 = PPCs, 3/CTRL = missiles.
int gBTWeaponTrigger = 0; // channel 1: lasers (key 1 / SPACE)
int gBTPPCTrigger = 0; // channel 2: PPCs (key 2)
int gBTMissileTrigger = 0; // channel 3: missiles (key 3 / CTRL)
static int gBTLaserKey = 0; // raw key states (set by the keyboard poll)
static int gBTPPCKey = 0;
static int gBTMissileKey = 0;
// Damage: each shot dispatches a REAL Entity::TakeDamageMessage to the target. Now
// that the damage zones are constructed (mech.cpp Pass-3 zone build), the engine base
// Entity::TakeDamageMessageHandler routes damageZones[zone]->TakeDamage -> the
// reconstructed Mech__DamageZone::TakeDamage (the real armor/structure model). We aim
// a VALID zone index (the Mech cylinder-lookup resolver for an unaimed/-1 hit is the
// STEP-6 reconstruction). Death is driven by the REAL model (Mech::IsDisabled, the
// vital-zone/leg/torso destroyed query FUN_0049fb54), NOT a hit counter.
static const Scalar kShotDamage = 12.0f; // per-burst damage amount (units = armor points)
static int gEnemyDestroyed = 0;
// BRING-UP: the spawned target/enemy mech (defined in btplayer.cpp). The player
// mech locks onto it as its current target each frame (see targeting step below).
extern Entity *gEnemyMech;
// gauge scoring wave: producers (btplayer.cpp) -- feed the scoreboard from combat.
extern void BTPostDamageScore(Entity *victim, Scalar damage); // per-hit SCORE (ScoreInflicted)
extern void BTPostKillScore(Entity *victim, Scalar damage); // KILL (+ MP death)
// Mech target slots (verified vs the binary's weapon/fire path, part_013.c):
// mech+0x37c Point3D current target world position (range/aim source)
// mech+0x388 Entity* current target entity (HasActiveTarget gate)
// mech+0x38c int targeted subsystem index (-1 = whole mech / none)
// These are undocumented in the reconstructed mech.hpp member map; the fire path
// (Emitter/MechWeapon) reads exactly these, so we write them directly.
#define MECH_TARGET_POS(m) (*(Point3D *)((char *)(m) + 0x37c))
#define MECH_TARGET_ENTITY(m) (*(Entity **)((char *)(m) + 0x388))
#define MECH_TARGET_SUBIDX(m) (*(int *)((char *)(m) + 0x38c))
// Bring-up body-animation player (file scope so OnBodyAnimFinished can re-arm it).
// The engine AnimationInstance walks the mech's joint subsystem from a baked .ani
// clip in btl4.res; we advance it each moving frame so the legs CYCLE.
static AnimationInstance *gBodyAnim = 0;
static int gBodyAnimReady = 0; // 0=untried 1=ok -1=failed
static Scalar gBodyAnimLog = 0.0f;
static int gBodyAnimLoops = 0; // count completed cycles (sanity)
static int gCurrentGait = -1; // P3 STEP 2: -1=none 0=walk 1=run (bound clip)
static const Scalar kRunThrottle = 0.5f; // |throttle| >= this -> run clip, else walk clip
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Mech::OnBodyAnimFinished -- AnimationInstance finished-callback.
// AnimationInstance::Animate invokes (moverToAnimate->*finishedCallback) when a
// clip runs off its last keyframe (JMOVER.cpp ~1592). The real game uses this to
// chain/loop gaits; for bring-up we LOOP the same clip by re-arming it at frame 0.
// Dropping the sub-frame carryover is imperceptible. Returns extra root movement
// contributed this call (0 -- the next frame's Animate carries the gait forward).
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Scalar
Mech::OnBodyAnimFinished(
ResourceDescription::ResourceID animation_number,
Scalar /*carryover*/, Logical /*animate_joints*/)
{
++gBodyAnimLoops;
if (gBodyAnim)
{
gBodyAnim->SetAnimation(
animation_number,
reinterpret_cast<JointedMover::AnimationCallback>(&Mech::OnBodyAnimFinished));
}
return 0.0f;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// BTResolveWeaponMuzzle -- the faithful FUN_004b9948 (MechWeapon::GetMuzzlePoint)
// muzzle resolve: look up the weapon's mount segment (index, from the subsystem's
// inherited this+0xdc slot) in the owner Mech's segment table and transform it to
// world. Lives here (a complete-Mech TU with the segment API); mechweap.cpp treats
// `owner` as a raw pointer so it calls this via a void* bridge instead of the Mech API.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
BTResolveWeaponMuzzle(void *ownerMech, int segIndex, Point3D &out)
{
Mech *m = (Mech *)ownerMech;
if (m == 0) { out = Point3D(0.0f, 0.0f, 0.0f); return; }
EntitySegment *seg = m->GetSegment(segIndex); // owner+0x300 table, GetNth(index)
if (seg != 0)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld); // segment -> world (== mech4 gun-port path)
out = mw; // Point3D = matrix W_Axis translation
}
else
out = m->localOrigin.linearPosition; // safe non-garbage fallback (owner origin)
}
// First vital damage-zone index (Mech__DamageZone::vitalDamageZone is protected; Mech has access).
int
Mech::FirstVitalZone() const
{
for (int k = 0; k < damageZoneCount; ++k)
if (Zone(k)->vitalDamageZone)
return k;
return 0;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Port-side tracked-projectile service (WAVE 7 Phase B -- flying missiles/rounds).
// The 1995 Projectile/Missile WORLD-ENTITY classes cannot be revived byte-exact:
// the 2007 engine Entity base is 0x1BC bytes vs the 1995 binary's 0x300, so the
// reconstruction's raw base-offset reads (velocity@0x1dc, roster@0x124, motion@
// 0x250) read garbage on the engine (the same 0x638-vs-0x854 gap the Mech has,
// but the entity integrator depends on those offsets). So a fired projectile is
// a PORT reconstruction (like the beam renderer): seeded from the launcher's fire
// with the decomp's real muzzle / launch velocity / per-shot damage, it flies
// toward the target (tracer via BTPushBeam) and delivers the weapon's damage on
// impact through the SAME Entity::TakeDamage path as the beam. The byte-exact
// world-entity Missile (Projectile : Mover, MP-replicable) is the deeper follow-up.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
struct BTProjectile {
Point3D pos;
Vector3D dir;
Scalar speed;
Scalar traveled;
Scalar range;
Entity *target;
Point3D targetPos;
Scalar damage;
int active;
};
static BTProjectile gProjectiles[64];
extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float);
// Called from ProjectileWeapon / MissileLauncher::FireWeapon (via the extern below)
// with the live muzzle, the SHOOTER mech (to resolve the real launch port), the owner's
// locked target entity + point, the launch speed (|muzzleVelocity|), and per-shot damage.
void
BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos,
Scalar speed, Scalar damage)
{
// The weapon's GetMuzzlePoint falls back to the mech ORIGIN (feet) when its mount
// segment doesn't resolve, so a projectile appeared to launch from the mech's feet.
// Resolve the real launch port by NAME off the shooter (same mechanism the visible
// laser beams use for the gun ports), alternating the left/right missile ports for a
// natural salvo look; fall back to a raised (torso-height) muzzle, then the passed one.
Point3D mz = muzzle;
if (shooter != 0)
{
Mech *sm = (Mech *)shooter;
static const char *const kPorts[] =
{ "sitermissleport", "sitelmissleport", "siterutorsoport", "sitelutorsoport",
"siterugunport", "sitelugunport" };
static int s_portRot = 0;
EntitySegment *seg = 0;
int n = (int)(sizeof(kPorts)/sizeof(kPorts[0]));
for (int k = 0; k < n && seg == 0; ++k)
{
s_portRot = (s_portRot + 1) % n;
seg = sm->GetSegment(CString(kPorts[s_portRot]));
}
if (seg != 0)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), sm->localToWorld); // port -> world
mz = mw; // W_Axis translation
}
else
{
mz = sm->localOrigin.linearPosition; // raise to torso height
mz.y += 12.0f;
}
}
// 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 to the projectile's target mech -- the launcher set p.target
// from the shooter's 0x388 slot (the picked victim; any peer mech in
// MP, task #46). A replicant target reroutes cross-pod via Dispatch.
extern int BTIsRegisteredMech(Entity *e);
if (tgt != 0 && BTIsRegisteredMech(tgt) && 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()
{
// AUTHENTIC LATCH first: IsDestroyed == (movementMode 2 || 9) -- the death
// modes (FUN_004ab1c8 zeroes locomotion for exactly these) [T1]. The death
// modes never revert, so a wreck STAYS destroyed.
//
// graphicAlarm >= 9 is only the vital-kill TRIGGER that enters the death
// transition (mechdmg.cpp:407/426). It is a STATUS indicator, not a latch:
// a later leg hit on the wreck legitimately rewrites it to 4/3
// (mechdmg.cpp:417/419), which -- when this predicate was the alarm alone --
// "resurrected" the wreck (movementMode reverted to 1) and let the next
// vital hit run the WHOLE death transition again: double kill score, and a
// Score dispatched into the respawn window's severed playerVehicle -> the
// engine's Check(playerVehicle) abort (task #52 cdb catch).
if (movementMode == 2 || movementMode == 9)
return True;
return graphicAlarm.GetLevel() >= 9 ? True : False;
}
//###########################################################################
// Mech::Reset (@0049fb74) -- the respawn RESET. The authentic respawn REUSES
// the same mech entity: it moves the mech to the new drop-zone origin AND heals
// it back to a clean, alive state. It does NOT create a new mech (our old
// sever-and-create respawn left the wreck behind as a second entity and built a
// duplicate viewpoint -- the source of the "2 mechs / camera-inside / on-fire
// respawn" glitches).
//
// Faithful reproduction of FUN_0049fb74, adapted to OUR layout: the 1995 raw
// offsets the decomp writes (+0x260/+0x298/+0x12c/...) land on DIFFERENT engine
// fields in the 2007 base (projectedOrigin/projectedVelocity/updateOrigin) and
// on our RELOCATED gait accumulators, so we reset the equivalent NAMED members
// rather than the raw offsets (per the databinding rule). Steps mirror the
// decomp: reposition + kill dead-reckon; clear the death latch; heal every
// damage zone; DeathReset (vtable+0x28) every subsystem; ForceUpdate to
// broadcast the reset state to replicants.
//###########################################################################
void
Mech::Reset(const Origin &origin, int mode)
{
Check(this);
// --- reposition (localOrigin @0x100, localToWorld @0xd0 rebuilt) ---
localOrigin = origin;
localToWorld = origin;
// --- kill dead-reckon / replication motion so the reset mech SNAPS to the
// drop zone and its replicant stops lerping back toward the death spot
// (the 2007 Mover-base fields at the decomp's +0x260/+0x298/+0x12c) ---
projectedOrigin = origin;
previousOrigin = origin;
projectedVelocity = Motion::Identity;
updateVelocity = Motion::Identity;
updateAcceleration = Motion::Identity;
// --- our RELOCATED gait/motion accumulators -> identity (the 1995 offsets
// the decomp zeroes map to these named members in our layout) ---
ReconQuatIdentity(&motionDelta, &kIdentityQuat);
ReconQuatIdentity(&worldPose, &kIdentityQuat);
ReconQuatIdentity(&worldPoseBase, &kIdentityQuat);
ReconQuatIdentity(&angularAccum, &kIdentityQuat);
ReconQuatIdentity(&aimRate, &kIdentityQuat);
// --- CLEAR THE DEATH LATCH: alive gait mode + no going-down alarm, so
// IsMechDestroyed() is false and the drive/render treat it as alive ---
movementMode = 1; // ground/alive (death settled it to 9)
graphicAlarm.SetLevel(0); // clear >=9 (the vital-kill trigger)
// --- HEAL every damage zone: full structure, intact skin, no burning ---
for (int z = 0; z < damageZoneCount; ++z)
{
Mech__DamageZone *zone = Zone(z);
if (zone != 0)
zone->Heal();
}
// --- RESET every subsystem (the decomp's loop-2 vtable+0x28 = DeathReset,
// the base virtual symmetric with the DeathShutdown we run on death):
// restores each subsystem's heat/power/ammo/charge to the initial state ---
for (int i = 0; i < GetSubsystemCount(); ++i)
{
Subsystem *s = GetSubsystem(i);
if (s != 0)
s->DeathReset(mode);
}
// --- locomotion pre-run + interest gates (a reset master must tick) ---
SetPreRunFlag();
if (interestCount == 0) interestCount = 1;
// --- broadcast the reset state to replicants (FUN_004a4c54(this, 0x1f)) ---
ForceUpdate();
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[respawn] Mech::Reset " << GetEntityID()
<< " healed+moved to (" << localOrigin.linearPosition.x << ","
<< localOrigin.linearPosition.y << "," << localOrigin.linearPosition.z
<< ") alive=" << (int)(!IsMechDestroyed())
<< " zones=" << damageZoneCount
<< " subsys=" << GetSubsystemCount() << "\n" << std::flush;
Check_Fpu();
}
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;
// --- DEATH EFFECTS (task #42): dispatched HERE, at the VICTIM's own
// once-per-death transition, so they fire regardless of WHAT killed it
// (laser fire block, missile impact frames later, collision damage).
// They used to live in the shooter's laser fire block, whose kill
// check a missile killing blow never reached -- and post-#41 the
// boresight pick skips a dead mech, so that block never ran against
// it again: internally dead, smoking, standing, invulnerable.
{
// KILL score (once; the ownerless dummy yields no death for us)
BTPostKillScore((Entity *)this, kShotDamage);
if ((Entity *)this == gEnemyMech)
gEnemyDestroyed = 1; // latches off damage score
// The victim's AUTHENTIC per-mech death ModelList -- 'blhdead' (RES 22;
// the burning-wreck script chain: effect 104 wreck swap + 1007 dnboom +
// 1001 ddthsmk + 3/4/5/15). Falls back to the generic explode effect.
if (gExplodeReady == 0 && application != 0
&& application->GetResourceFile() != 0)
{
ResourceDescription *exp =
application->GetResourceFile()->FindResourceDescription(
"explode", (ResourceDescription::ResourceType)1, -1);
if (exp != 0) { gExplodeRes = exp->resourceID; gExplodeReady = 1; }
else gExplodeReady = -1;
}
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;
}
}
if (gExplodeReady == 1 || dead_res != gExplodeRes)
{
Origin death_origin = localOrigin;
death_origin.linearPosition.y += kMuzzleHeight;
Explosion::MakeMessage death_exp(
Explosion::MakeMessageID,
sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID,
EntityID::Null,
dead_res,
Explosion::DefaultFlags,
death_origin,
GetEntityID(), // the victim
lastInflictingID); // the killer (task #31 bookkeeping)
Explosion *boom = Explosion::Make(&death_exp);
if (boom) Register_Object(boom);
}
DEBUG_STREAM << "[damage] *** " << GetEntityID()
<< " DESTROYED (death effects dispatched from the death transition) ***\n"
<< std::flush;
}
//
// --- RESPAWN CYCLE (task #52): notify the owning player of the death. ---
// The BTPlayer VehicleDead handler @004c05c4 receives this with
// deathCount == -1 (the ctor default = "immediate death notification"),
// does the death bookkeeping, severs playerVehicle (this wreck entity
// STAYS in the world) and re-posts the message to itself at +5 seconds
// (5.0f @004c0830) -> the engine drop-zone hunt -> DropZoneReply ->
// CreatePlayerVehicle (a NEW mech). Only the owner pod's master has a
// live playerLink, which is exactly where the cycle must run; replicant
// wrecks just mirror the master's death. [T1 the handler @004c05c4;
// T3 this dispatch site -- the binary's exact sender is undecoded, but
// the once-per-death transition is the only death edge and the message
// ctor's deathCount=-1 default exists for precisely this notification.]
//
{
Player *owner = GetPlayerLink();
if (owner != 0)
{
Player::VehicleDeadMessage
vehicle_dead(
Player::VehicleDeadMessageID,
sizeof(Player::VehicleDeadMessage)
);
owner->Dispatch(&vehicle_dead);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] VehicleDead(-1) dispatched to the owning player\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;
// REPLICANT GAIT (task #50): animate the peer mech's legs at the
// REPLICATED speed -- without this the replicant slides around as a
// frozen statue. The master publishes worldLinearVelocity in every
// update record (Mover::WriteUpdateRecord, MOVER.cpp:759); the
// replicant stores it in updateVelocity.linearMotion (:712). Derive
// the signed speed demand from it (forward = local -Z; a negative
// forward dot = the master is reversing), feed the LEG channel's live
// demand source, and advance the leg state machine for its JOINT
// writes only -- TRAVEL stays with DeadReckon (the returned distance
// is discarded), so position always follows the master and the clip
// cadence matches the replicated speed (residual foot-slip is the
// inherent dead-reckoning artifact). The state machine self-arms
// stand->walk / winds down from the demand exactly as on the master.
if (!IsMechDestroyed() && controlsMapper != 0)
{
const Vector3D &wv = updateVelocity.linearMotion;
float spd = sqrtf((float)(wv.x * wv.x + wv.z * wv.z));
UnitVector zAxR;
localToWorld.GetFromAxis(Z_Axis, &zAxR);
const float fdot = -((float)wv.x * (float)zAxR.x
+ (float)wv.z * (float)zAxR.z); // mech faces -Z
controlsMapper->speedDemand = (fdot < 0.0f) ? -spd : spd;
// Prime the same clip-advance scalars the master's gait block sets
// each frame -- uninitialized on a replicant they read 0, freezing
// the clip at advance-time dt*0 (observed: legState engaged at 11,
// legCycle stuck 0). Same forwardCycleRate floor as the master
// (the model-record decode reads ~1 u/s^2 -- a 55s ramp; floor 25).
globalTimeScale = 1.0f;
idleStrideScale = 1.0f;
if (forwardCycleRate < 25.0f) forwardCycleRate = 25.0f;
// reverseSpeedMax2@0x7a0: the run-cycle rise-CLAMP (leg case 12/13);
// LoadLocomotionClips doesn't set it, so a replicant reads 0xCDCD
// (-4.3e8) and the clamp CLOBBERS legCycleSpeed the moment the run
// cycle engages (observed live: legCycle=-4.31602e+08, state 13
// stuck). Same heal as the master's drive block.
reverseSpeedMax2 = reverseStrideLength;
// legCycleSpeed itself is also debug-heap garbage until a walk
// state first writes it (Standing never touches it); sane-band it
// once so a direct run-state entry can't slew from -4.3e8.
if (legCycleSpeed < -100.0f || legCycleSpeed > 200.0f)
legCycleSpeed = 0.0f;
(void)AdvanceLegAnimation(dt); // joints only; travel = DeadReckon
// REPLICANT BEAMS (task #51): the emitters carry live replicated
// discharge state (Emitter::ReadUpdateRecord); draw them with the
// same per-weapon walk the master uses.
DrawWeaponBeams(dt);
}
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 << ")"
<< " vel=(" << updateVelocity.linearMotion.x << ","
<< updateVelocity.linearMotion.z << ")"
<< " legState=" << (int)legStateAlarm.GetLevel()
<< " legCycle=" << legCycleSpeed << "\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;
// WEAPON GROUPS (task #43, KEYBOARD only per user): three fire
// channels like three pod buttons -- 1/SPACE = lasers, 2 = PPCs,
// 3/CTRL = missiles. (Interim; the authentic system is the
// ConfigureMappables/ChooseButton mapper channels.)
gBTLaserKey = focused && ((pAsync('1') | pAsync(0x20 /*VK_SPACE*/)) & dn) ? 1 : 0;
gBTPPCKey = focused && (pAsync('2') & dn) ? 1 : 0;
gBTMissileKey = focused && ((pAsync('3') | pAsync(0x11 /*VK_CONTROL*/)) & dn) ? 1 : 0;
// gBTDrive.fire = "any weapon trigger down" (feeds the bring-up
// damage dispatcher + the beam-visual keepalive)
gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey) ? 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 = TORSO BORESIGHT (task #39 correction): the pod had
// NO free-aim cursor -- the binary's HudSimulation computes
// reticlePosition from the mech's POSE quaternions
// (part_013.c:5652+, a rate-limited quat->euler of the torso
// aim), i.e. the crosshair marks where the TORSO GUNS point
// relative to the view, and you aim by steering the mech /
// twisting the torso (the pod's right stick). The earlier
// mouse-cursor slew was a mis-sourced stand-in and is
// REMOVED. Our cockpit view is body-mounted, so the
// crosshair deflection = the torso twist projected to screen
// -- identically ZERO on the fixed-torso BLH (dead-centre
// boresight). BT_AIM="x y" remains as the headless harness.
{
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
{
// boresight = torso twist vs the body-mounted view,
// projected through the live per-axis projection.
extern float BTTwistToReticleX(float twist_rad);
gBTAimX = BTTwistToReticleX(gBTHudTwist); // BLH: 0
gBTAimY = 0.0f;
}
}
}
}
// 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;
static int sGotoDrive = -1;
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;
const char *gv = getenv("BT_GOTO");
sGotoDrive = (gv && *gv) ? 1 : 0;
}
gBTDrive.fireForced = sAutoFire;
if (sAutoDrive > 0.0f)
{
gBTDrive.forced = 1;
gBTDrive.forcedThrottle = sAutoDrive;
}
else if (sGotoDrive)
{
// BT_GOTO is self-driving: enable forced mode so the goto beeline
// block (gated on gBTDrive.forced, below) runs on its own without
// requiring BT_AUTODRIVE. That block sets the actual throttle
// (drive toward the target, 0 inside the stop radius).
gBTDrive.forced = 1;
gBTDrive.forcedThrottle = 0.8f;
}
}
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;
}
// DEATH GATE (task #52): a DESTROYED mech takes no pilot input -- kill
// throttle/turn/fire and the forced harnesses at the source so the
// wreck can't be steered around invisibly ("ghost mode", user-reported
// from 2-window play). Deliberately INPUT-only: the gait/animation
// machinery below keeps running so the death collapse plays out and the
// wind-down settles; respawn is the recovery/drop cycle (task #52 open).
if (IsMechDestroyed())
{
gBTDrive.throttle = 0.0f;
gBTDrive.turn = 0.0f;
gBTDrive.fire = 0;
gBTDrive.fireForced = 0;
gBTDrive.forced = 0;
gBTLaserKey = gBTPPCKey = gBTMissileKey = 0;
}
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 && !stricmp(gv, "enemy")) s_goto = 2; // chase nearest replicant
else if (gv && sscanf(gv, "%f %f", &s_gx, &s_gz) == 2) s_goto = 1;
else s_goto = 0;
}
if (s_goto == 2)
{
// DYNAMIC target (test harness): beeline toward the CLOSEST live
// peer replicant wherever it currently is -- MP spawn positions
// vary per run, so a fixed "x z" can't reliably face the enemy.
extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut);
Entity *ec[32];
const int enc = BTGetTargetCandidates((Entity *)this, ec, 32);
float best = 1e30f; int found = 0;
for (int ei = 0; ei < enc; ++ei)
{
Mech *em = (Mech *)ec[ei];
if (em == 0 || em->IsMechDestroyed()) continue; // any peer (solo dummy OR replicant)
Point3D ep = em->localOrigin.linearPosition;
float edx = (float)ep.x - (float)localOrigin.linearPosition.x;
float edz = (float)ep.z - (float)localOrigin.linearPosition.z;
float ed2 = edx*edx + edz*edz;
if (ed2 < best) { best = ed2; s_gx = (float)ep.x; s_gz = (float)ep.z; found = 1; }
}
if (found) s_goto = 2; else { gBTGotoActive = 0; }
}
if (s_goto == 1 || (s_goto == 2 && (s_gx != 0.0f || s_gz != 0.0f)))
{
float ddx = s_gx - (float)localOrigin.linearPosition.x;
float ddz = s_gz - (float)localOrigin.linearPosition.z;
float dist2 = ddx * ddx + ddz * ddz;
// Heading error from the mech's ACTUAL forward (localToWorld's -Z
// axis), NOT the gDriveHeading scalar mirror: that mirror is seeded
// to 0, but MP pilots (and any non-zero spawn pose) start facing a
// different way, so `want - gDriveHeading` steered the beeline the
// WRONG way. err = signed angle (about +Y) from forward to target.
UnitVector zAxisG;
localToWorld.GetFromAxis(Z_Axis, &zAxisG);
float fwdX = -(float)zAxisG.x, fwdZ = -(float)zAxisG.z; // faces -Z
float err = 0.0f;
float tlen = (float)sqrt((double)dist2);
if (tlen > 1e-3f)
{
float tX = ddx / tlen, tZ = ddz / tlen;
float dot = fwdX * tX + fwdZ * tZ;
float crs = fwdZ * tX - fwdX * tZ; // signed; flip if it steers away
err = (float)atan2((double)crs, (double)dot);
}
// STOP radius: "enemy" holds at weapon range so it can shoot
// instead of ramming; a fixed point drives right up to the spot.
const float stopDist = (s_goto == 2) ? 300.0f : 5.0f;
const float stopDist2 = stopDist * stopDist;
const int arrived = (dist2 < stopDist2);
// 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 (arrived) 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;
if (getenv("BT_GOTO_LOG"))
{
static float s_gl = 0.0f; s_gl += dt;
if (s_gl >= 1.0f) { s_gl = 0.0f;
DEBUG_STREAM << "[goto] dist=" << (float)sqrt((double)dist2)
<< " err=" << err << " turn=" << gBTGotoTurn
<< " thr=" << gBTGotoThrottle << " arr=" << arrived
<< " fwd=(" << fwdX << "," << fwdZ << ")"
<< " tgt=(" << s_gx << "," << s_gz << ")\n" << std::flush; }
}
// SELF-DRIVE (task #48): a "beeline" harness must supply its OWN
// forward throttle, not only steering -- otherwise it just turns
// in place and never closes the distance (the "drive-to-range
// stall": BT_GOTO alone advanced nothing because the throttle came
// only from BT_AUTODRIVE). Drive forward toward the target and cut
// the throttle inside the stop radius so it holds at firing range.
gBTDrive.forced = 1;
gBTDrive.forcedThrottle = arrived ? 0.0f : 0.8f;
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;
// BT_GOTO steering must reach the orientation integration DIRECTLY: the
// mapper round-trip (stickPosition -> turnDemand) zeroes out in -net mode
// (the key-bridge only shapes the local viewpoint mech there), which froze
// the beeline's heading (it drove straight past the target). The goto turn
// is a plain yaw demand -- apply it here, after the mapper read, so it works
// identically solo and cross-net.
extern int gBTGotoActive; extern float gBTGotoTurn;
if (gBTGotoActive) turn = gBTGotoTurn;
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
<< " mapper=" << (void*)controlsMapper
<< " &mode=" << (void*)&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)
{
// CHANNEL ROLES (task #49, disasm-corrected [T1]): in the binary
// - the MASTER PERF (0x4a9b5c region) advances the LEG channel
// (call @0x4aa399, airborne @0x4aa388) and stores -dist/dt into
// localVelocity (+0x1cc) -- the LEG drives the LOCAL mech's
// travel AND (writing last, mj=1) the displayed pose;
// - IntegrateMotion (0x4ab1c8, body advance @0x4ab312) stores
// -dist/dt into the PROJECTED velocity (+0x2a0) -- the BODY
// channel is the dead-reckoning/replication PROJECTOR, whose
// phase drift is locally INVISIBLE.
// The previous arrangement here (v4: display+travel from the BODY)
// had the roles swapped: whenever the two state machines phase-split
// (the leg-only trn pivot seed, or a demand change landing between
// the channels' end-of-clip callbacks -> divergent transitions), the
// out-of-phase LEG pose showed through on every frame the body
// didn't write (Standing/wind-down) = the visible leg stutter.
// Body first (projection; writes get overwritten), leg LAST (the
// displayed pose): display == travel through the LEG channel, by
// construction -- body-channel drift can no longer show.
adv = AdvanceBodyAnimation(dt, 1); // channel B: replication projection
legAdv = AdvanceLegAnimation(dt); // channel A: local sim -- pose + travel
}
else
{
adv = AdvanceBodyAnimation(dt, 1); // single-channel: body does both
}
// [syncF] per-frame divergence probe (task #49): log every frame
// where the two channels return different distances or either
// state machine changed state -- pinpoints WHICH frames the
// advSum/legSum drift and the visible pose pops come from.
if (getenv("BT_SYNC_LOG"))
{
static int s_lastBS = -1, s_lastLS = -1;
const int bs = (int)bodyStateAlarm.GetLevel();
const int ls = (int)legStateAlarm.GetLevel();
const float dD = (float)(adv - legAdv);
if (bs != s_lastBS || ls != s_lastLS
|| dD > 0.001f || dD < -0.001f)
{
DEBUG_STREAM << "[syncF] dt=" << dt
<< " bs=" << bs << " ls=" << ls
<< " adv=" << adv << " legAdv=" << legAdv
<< " bCyc=" << bodyCycleSpeed << " lCyc=" << legCycleSpeed
<< " bFrm=" << bodyAnimation.currentFrame
<< " bT=" << bodyAnimation.currentTime
<< " lFrm=" << legAnimation.currentFrame
<< " lT=" << legAnimation.currentTime
<< "\n" << std::flush;
s_lastBS = bs; s_lastLS = ls;
}
}
// 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 (v5 -- task #49, disasm-corrected):
// display AND travel BOTH come from the LEG channel (the binary's
// master perf advances the leg and writes -dist/dt into
// localVelocity @+0x1cc; the leg writes the pose last). The v4
// reading ("raw FUN_004ab430:15076 takes the travel from the body
// advance") was WRONG -- 0x4ab312's IntegrateMotion stores its
// -dist/dt into the PROJECTED velocity (+0x2a0), the dead-reckoning
// feed, not local travel. v3 (travel=leg, display=body) foot-slipped
// because display and travel were DIFFERENT channels; v4 unified on
// the body, which planted feet but let the out-of-phase LEG pose show
// through wherever the body didn't write (the visible stutter). v5
// unifies on the leg exactly as the binary does: display == travel by
// construction, live-demand channel authoritative, body drift local-
// invisible. (Knockdown still staggers BOTH channels, so a hard
// impact freezes travel as before.)
const Scalar travelAdv = s_realControls ? legAdv : adv;
const Scalar localAdv = travelAdv * 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=" << travelAdv << " (proj=" << 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; then add the render-only
// VISUAL lift at each probe (task #49b): the quad is drawn on
// the VISIBLE terrain (btvisgnd conform), which runs 0..~2u
// above the collision solid by DIFFERENT amounts across a
// slope -- the VISUAL gradient, not the collision gradient,
// is the surface the quad must hug. Lift 0 (no data/gated
// off) degrades to the collision gradient.
extern float BTVisualGroundLift(float x, float y, float z);
Scalar yC = baseY - hC, yX = baseY - hX, yZ = baseY - hZ;
yC += BTVisualGroundLift((float)cx, (float)yC, (float)cz);
yX += BTVisualGroundLift((float)(cx+D), (float)yX, (float)cz);
yZ += BTVisualGroundLift((float)cx, (float)yZ, (float)(cz+D));
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 -yaw about Y. Use the TRUE
// heading from localToWorld (engine convention MATRIX.cpp:196:
// Z basis = (sin y, 0, cos y) -> yaw = atan2(z.x, z.z)) -- NOT
// the gDriveHeading scalar mirror, which is seeded to 0 and
// drifts from the real pose (the task-#48 goto-steering bug
// class; a wrong yaw here swings the tilt as the mech turns).
UnitVector zAxisS;
localToWorld.GetFromAxis(Z_Axis, &zAxisS);
const Scalar shYaw = (Scalar)atan2f((float)zAxisS.x, (float)zAxisS.z);
const Scalar cth = (Scalar)cosf((float)shYaw);
const Scalar sth = (Scalar)sinf((float)shYaw);
shadowNormal.x = wn.x * cth - wn.z * sth;
shadowNormal.y = wn.y;
shadowNormal.z = wn.x * sth + wn.z * cth;
if (getenv("BT_SHADOW_LOG"))
{
static float s_shLog = 0.0f; s_shLog += dt;
if (s_shLog >= 1.0f) { s_shLog = 0.0f;
DEBUG_STREAM << "[shtilt] wn=(" << wn.x << "," << wn.y << "," << wn.z
<< ") local=(" << shadowNormal.x << "," << shadowNormal.y
<< "," << shadowNormal.z << ") yaw=" << shYaw << "\n" << std::flush; }
}
}
}
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.)
DrawWeaponBeams(dt); // task #51: extracted (runs for replicants too)
}
// --- TARGETING: the WORLD-PICK model (task #41, the reconciliation of
// ALL the evidence). The boresight ray picks whatever is DOWNRANGE:
// 1. the enemy mech's collision volume -> aimed target (hull point
// -> STEP-6 zone under the boresight; hotbox + lock ring);
// 2. else the TERRAIN (BTGroundRayHit) -> the ground point becomes
// the target -- the beam fires at the scenery ("firing at
// nothing", seen in the pod demo videos), no damage;
// 3. else (sky, nothing within range) -> NO target, and the
// weapon's own double gate (FUN_004baa88:7689 + FUN_004bace8:
// 7727 -- both require mech+0x388 != 0) refuses the discharge.
// Binary evidence for non-mech targets: HudSimulation :5620
// explicitly handles a target WITHOUT damage zones (target->0x120
// == 0) -- dead code if only mechs were ever targeted. The pick is
// AUTOMATIC every frame (0x388 has 11 reads / 0 direct stores in
// CODE -- written indirectly, never a manual player lock).
Entity *hotTarget = 0; // the enemy mech under the boresight
Point3D hotPoint; // picked world point on its hull
Entity *pickTarget = 0; // what the boresight ray hit (mech/terrain)
Point3D pickPoint; // where it hit
static int gAimNoRay = 0, gAimGround = 0, gAimHits = 0; // 1Hz diagnostics
{
extern int BTGetAimRay(float rx, float ry, float outStart[3], float outDir[3]);
extern Entity *gBTTerrainEntity; // captured by MakeEntityRenderables
extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut);
Entity *cand[32];
const int nc = BTGetTargetCandidates((Entity *)this, cand, 32);
// Candidate diagnostics + the cross-pod PROOF hook run INDEPENDENT of
// the aim ray (they don't need screen geometry -- and in -net mode the
// aim projection may not be live, gap-map item 5).
static float s_candLog = 0.0f; s_candLog += dt;
const int candLog = (getenv("BT_MP_LOG") && s_candLog >= 1.0f);
if (candLog) s_candLog = 0.0f;
if (candLog)
DEBUG_STREAM << "[mp-self] MY mech entityID=" << GetEntityID()
<< " inst=" << (int)GetInstance() << " (" << nc << " candidates)\n"
<< std::flush;
if (candLog)
for (int ci = 0; ci < nc; ++ci)
{
Mech *m = (Mech *)cand[ci];
if (m == 0) continue;
Point3D mp = m->localOrigin.linearPosition;
DEBUG_STREAM << "[mp-cand] " << (void*)m
<< " entityID=" << m->GetEntityID()
<< " ownerID=" << (int)m->GetOwnerID()
<< " inst=" << (int)m->GetInstance()
<< " classID=" << (int)m->GetClassID()
<< " pos=(" << mp.x << "," << mp.y << "," << mp.z << ")"
<< " zones=" << m->damageZoneCount << "\n" << std::flush;
}
// CROSS-POD DAMAGE TEST HOOK (BT_MP_FORCE_DMG, task #47): once a second,
// dispatch an unaimed TakeDamage at the first live REPLICANT through the
// SAME virtual Entity::Dispatch path a beam hit uses -- Entity::Dispatch
// reroutes a replicant's message to the owning master over the wire, the
// master's now-valid mech resolves + applies it (cylinder lookup). Lets a
// 2-node test exercise cross-pod delivery without lining up a boresight.
// Off by default; the interactive beam path needs no such hook.
if (getenv("BT_MP_FORCE_DMG"))
{
static float s_fd = 0.0f; s_fd += dt;
if (s_fd >= 1.0f)
{
s_fd = 0.0f;
for (int ci = 0; ci < nc; ++ci)
{
Mech *m = (Mech *)cand[ci];
if (m == 0 || m->GetInstance() != ReplicantInstance
|| m->IsMechDestroyed() || m->damageZoneCount <= 0)
continue;
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = kShotDamage;
dmg.burstCount = 1;
dmg.impactPoint = m->localOrigin.linearPosition;
Entity::TakeDamageMessage td(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
GetEntityID(), -1, dmg);
if (getenv("BT_MP_LOG"))
DEBUG_STREAM << "[mp-force] " << (void*)m
<< " cross-pod TakeDamage id=" << m->GetEntityID()
<< " -> ownerID=" << (int)m->GetOwnerID() << "\n" << std::flush;
m->Dispatch(&td);
break;
}
}
}
float rs[3], rd[3];
if (!BTGetAimRay(gBTAimX, gBTAimY, rs, rd))
{
++gAimNoRay;
}
else
{
Point3D rayStart(rs[0], rs[1], rs[2]);
Vector3D rayDir(rd[0], rd[1], rd[2]);
// MP TARGETING (task #46): walk EVERY other living mech (the solo
// dummy AND every peer replicant, from the live-mech registry) and
// pick the CLOSEST one the boresight ray strikes -- generalising
// the solo gEnemyMech.
float bestDist = 1e30f;
for (int ci = 0; ci < nc; ++ci)
{
Mech *m = (Mech *)cand[ci];
if (m == 0 || m->IsMechDestroyed())
continue;
Point3D hp;
if (!m->PickRayHit(rayStart, rayDir, 4000.0f, &hp))
continue;
float dx = hp.x - rs[0], dy = hp.y - rs[1], dz = hp.z - rs[2];
float d = dx*dx + dy*dy + dz*dz;
if (d < bestDist)
{
bestDist = d;
hotTarget = cand[ci];
hotPoint = hp;
}
}
if (hotTarget != 0)
{
pickTarget = hotTarget;
pickPoint = hotPoint;
++gAimHits;
}
else
{
// the ground downrange of the guns (max = the pod's HUD
// range scale; past it the shot is a sky shot -> no target)
extern bool BTGroundRayHit(float,float,float, float,float,float,
float, float*,float*,float*);
float hx, hy, hz;
if (gBTTerrainEntity != 0 &&
BTGroundRayHit(rs[0], rs[1], rs[2], rd[0], rd[1], rd[2],
1200.0f, &hx, &hy, &hz))
{
pickTarget = gBTTerrainEntity;
pickPoint.x = hx; pickPoint.y = hy; pickPoint.z = hz;
++gAimGround;
}
}
}
// 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 = pickTarget;
targetReticle.targetDamageZone = -1;
if (pickTarget != 0)
targetReticle.rayIntersection = pickPoint;
// the engine-Entity target slots the whole weapon path reads
if (pickTarget != 0)
{
MECH_TARGET_ENTITY(this) = pickTarget;
MECH_TARGET_SUBIDX(this) = -1;
MECH_TARGET_POS(this) = pickPoint; // beam endpoint = the pick
}
else
{
MECH_TARGET_ENTITY(this) = 0; // sky: no target, no discharge
MECH_TARGET_SUBIDX(this) = -1;
}
}
// HUD feeds: the range caret + the hotbox (world point + state) + the
// recovered-Execute instruments (compass, twist tape, group mask).
// The range caret tracks the PICK (authentic: :5639 computes it from
// mech+0x37c whatever the target is -- a terrain pick reads the ground
// distance); the hotbox + lock exist ONLY for a mech target (:5620 --
// a target with no damage zones gets neither).
{
extern void BTSetHudTargetRange(Scalar range);
// RANGE RATE LIMIT (HudSimulation :5652-5670 [T1]): the DISPLAYED
// range slides toward the true pick range at 500 m/s -- shown +=
// clamp(true - shown, +-dt*500) -- so the caret sweeps smoothly as
// the boresight crosses near/far ground instead of teleporting.
// (Applies to the no-target 1200 default too.)
static float sShownRange = 1200.0f;
float trueRange = 1200.0f; // no target: the binary default
Entity *des = MECH_TARGET_ENTITY(this);
if (des != 0 && des != hotTarget)
{
// terrain pick: range to the ground point; no hotbox, no ring.
Point3D tp = MECH_TARGET_POS(this);
float hddx = tp.x - localOrigin.linearPosition.x;
float hddz = tp.z - localOrigin.linearPosition.z;
trueRange = sqrtf(hddx*hddx + hddz*hddz);
gBTHudLockState = 0;
}
else if (des != 0)
{
Mech *dm = (Mech *)des;
Point3D dp = dm->localOrigin.linearPosition;
float hddx = dp.x - localOrigin.linearPosition.x;
float hddz = dp.z - localOrigin.linearPosition.z;
trueRange = sqrtf(hddx*hddx + hddz*hddz);
gBTHudLockWorld[0] = dp.x; // the HOTBOX point: top-centre
gBTHudLockWorld[1] = dp.y + (float)dm->CylinderReferenceHeight();
gBTHudLockWorld[2] = dp.z;
// AUTHENTIC LOCK (HudSimulation, part_013.c:5619-5634 [T1]):
// the fire-control LOCK needs (a) a working targeting computer
// -- your own HUD's host zone below 75% damage -- and (b) a
// live targeted zone -- its damage below 1.0 (a whole-mech
// target checks zone 0). So a shot-up cockpit loses the lock
// light, and a destroyed wreck's dead zone can't be re-locked.
int lock = 1;
{
MechSubsystem *hud = (MechSubsystem *)GetHudSubsystem();
if (hud != 0 && hud->GetDamageZoneProxy() != 0)
{
Mech__DamageZone *hz =
(Mech__DamageZone *)hud->GetDamageZoneProxy();
if (hz->damageLevel >= 0.75f) // _DAT_004b7ec4
lock = 0;
}
int tz = MECH_TARGET_SUBIDX(this);
if (tz < 0) tz = 0; // whole-mech -> zone 0
if (lock && tz < dm->damageZoneCount && dm->Zone(tz) != 0
&& dm->Zone(tz)->damageLevel >= 1.0f) // _DAT_004b7ec8
lock = 0;
}
// 1 = target held (hotbox draws); 2 = LOCKED (box + spin ring).
// The binary keeps these separate: the box follows HotBoxVector,
// the ring follows the Lock attr.
gBTHudLockState = lock ? 2 : 1;
}
else
{
gBTHudLockState = 0; // sky: no target (trueRange stays 1200,
} // the binary default @part_013.c:5637)
// the 500 m/s slide toward trueRange (see the banner above)
{
float maxStep = (float)dt * 500.0f;
if (maxStep < 0.0f) maxStep = -maxStep;
float step = trueRange - sShownRange;
if (step > maxStep) step = maxStep;
if (step < -maxStep) step = -maxStep;
sShownRange += step;
BTSetHudTargetRange((Scalar)sShownRange);
}
gBTHudHeading = gDriveHeading; // CompassHeading (attr 0xD)
gBTHudTwist = (float)TorsoHeading(); // RotationOfTorsoHorizontal (attr 4)
gBTHudTwistLimit = (float)GetHorizontalFiringReach();// HorizontalTorsoLimit (attrs 5/6)
gBTHudGroupMask = (int)targetReticle.reticleElementMask & 0xF;
gBTHudPrimary = ((int)targetReticle.reticleElementMask & 0x20) != 0;
}
// E8: pulse the three fire channels per frame (1,0,1,0...) so each
// weapon sim's CheckFireEdge sees clean rising edges. UNCONDITIONAL --
// NOT inside the enemy block: firing needs only the world PICK (task
// #41: the beam goes to the scenery downrange with no enemy alive; the
// weapon's OWN HasActiveTarget gate refuses only a true sky shot).
// Leaving these inside `if (gEnemyMech)` froze the channels after the
// wreck buried -> no trigger edges -> "can't fire after the kill"
// (user-reported regression). targetInArc is the explicit BT_FIRE_ARC
// presentation clamp (default true). BT_AUTOFIRE holds all three.
{
const int laserWanted = gBTDrive.fireForced || gBTLaserKey;
const int ppcWanted = gBTDrive.fireForced || gBTPPCKey;
const int missileWanted = gBTDrive.fireForced || gBTMissileKey;
gBTWeaponTrigger = (laserWanted && targetInArc) ? (gBTWeaponTrigger ? 0 : 1) : 0;
gBTPPCTrigger = (ppcWanted && targetInArc) ? (gBTPPCTrigger ? 0 : 1) : 0;
gBTMissileTrigger = (missileWanted && targetInArc) ? (gBTMissileTrigger ? 0 : 1) : 0;
}
{
// The VICTIM under the boresight (task #46: any peer mech, not just
// the solo gEnemyMech). Range/log/fire all key off THIS mech.
Entity *victim = hotTarget;
Point3D victimPos = (victim != 0)
? ((Mech *)victim)->localOrigin.linearPosition
: localOrigin.linearPosition;
float ddx = victimPos.x - localOrigin.linearPosition.x;
float ddy = victimPos.y - localOrigin.linearPosition.y;
float ddz = victimPos.z - localOrigin.linearPosition.z;
float range = (float)Sqrt((double)(ddx*ddx + ddy*ddy + ddz*ddz));
// THE AUTHENTIC RANGE GATE (FireWeapon @004bace8 :7758 [T1]): damage
// applies when dist <= the weapon's EFFECTIVE range = (1 - host-zone
// damage) x its AUTHORED WeaponRange (BLH: lasers 500, missiles 800,
// PPCs 900 m -- the [hud] pip dump) -- the per-weapon targetWithinRange
// flag UpdateTargeting computes each frame. Any live weapon in reach
// lands the aggregate shot.
int anyWeaponInRange = 0;
for (int wi = 0; wi < GetSubsystemCount(); ++wi)
{
Subsystem *ws = GetSubsystem(wi);
if (ws == 0 || !ws->IsDerivedFrom(MechWeapon::ClassDerivations))
continue;
if (*(Logical *)((MechWeapon *)ws)->WithinRangePtr())
{
anyWeaponInRange = 1;
break;
}
}
gTargetLogAccum += dt;
if (gTargetLogAccum >= 1.0f)
{
gTargetLogAccum = 0.0f;
DEBUG_STREAM << "[target] aim=(" << gBTAimX << "," << gBTAimY << ")"
<< (victim != 0 ? " MECH under boresight (aimed)"
: (pickTarget != 0 ? " terrain downrange (beam at scenery)"
: " sky (no target, no discharge)"))
<< " range=" << range
<< (anyWeaponInRange ? " IN WEAPON RANGE" : "")
<< " [mechPicks=" << gAimHits << " groundPicks=" << gAimGround
<< " noRay=" << gAimNoRay << "]"
<< "\n" << std::flush;
gAimHits = 0; gAimNoRay = 0; gAimGround = 0;
}
if (gFireCooldown > 0.0f)
gFireCooldown -= dt;
const int fireWanted = gBTDrive.fireForced || gBTDrive.fire;
// 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;
}
}
}
// FIRE + DAMAGE: only vs a MECH under the boresight (terrain/sky picks
// draw beams but deal no damage). Dispatch routes to the picked victim
// -- for a REPLICANT this is a cross-pod hit: Entity::Dispatch reroutes
// the TakeDamageMessage to the owning master over the wire (task #46).
if (fireWanted && victim != 0 && targetInArc && gFireCooldown <= 0.0f
&& anyWeaponInRange && gExplodeReady == 1)
{
gFireCooldown = kFireCooldown;
++gShotCount;
// Impact = the PICKED HULL POINT -> the STEP-6 cylinder lookup
// resolves the zone under the boresight (aimed fire).
Point3D impact = hotPoint;
Origin exp_origin = ((Mech *)victim)->localOrigin;
exp_origin.linearPosition = impact; // at the hit point
// IMPACT FRAME: -Z aimed from the victim at the shooter (the .PFX
// hit spray convention; yaw = atan2(ddx, ddz)). [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,
victim->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;
}
// --- DAMAGE (real, STEP 6): dispatch UNAIMED (zone == -1) with the
// beam's world entry point. Mech::TakeDamageMessageHandler
// resolves the zone from the cylinder table; the base handler
// routes it to Mech__DamageZone::TakeDamage. Dispatch on a
// REPLICANT is rerouted by Entity::Dispatch to the owning
// master over the wire -- the cross-pod damage path.
if (((Mech *)victim)->damageZoneCount > 0)
{
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = kShotDamage;
dmg.burstCount = 1;
dmg.impactPoint = impact;
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
GetEntityID(), // inflicting = this (shooter)
-1, // UNAIMED -> receiver's cylinder resolves
dmg);
victim->Dispatch(&take_damage);
// SCORE the damage (skip once the victim is dead).
if (!((Mech *)victim)->IsMechDestroyed())
BTPostDamageScore(victim, kShotDamage);
int zone = take_damage.damageZone;
if (zone >= 0 && zone < ((Mech *)victim)->damageZoneCount)
{
Scalar s = ((Mech *)victim)->Zone(zone)->damageLevel;
DEBUG_STREAM << "[damage] " << (void*)victim << " zone " << zone
<< "/" << ((Mech *)victim)->damageZoneCount
<< " structure=" << s
<< (((Mech *)victim)->GetInstance() == ReplicantInstance
? " (REPLICANT -> cross-pod)" : "")
<< "\n" << std::flush;
}
}
// DEATH effects fire at the VICTIM's own death transition
// (UpdateDeathState, task #42) -- MP-correct: the master runs it.
}
}
}
// --- 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.
//===========================================================================//
//
// DrawWeaponBeams (task #51 extraction) -- the per-weapon beam render walk
// (task #33), extracted from the player-only drive block so it runs for
// EVERY mech: the local player, the solo dummy, and MP REPLICANTS (whose
// emitters now carry live replicated discharge state via the Emitter update
// records; without this the peer's beams applied but never drew).
//
void
Mech::DrawWeaponBeams(Scalar dt)
{
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" };
// PER-MECH cache (task #51): the walk now runs for EVERY mech
// (player + replicants); the old process-wide statics would serve
// the PLAYER's segment pointers as the replicant's muzzles. Key
// the slot by owner and re-resolve on mismatch.
static EntitySegment *s_portCache[64];
static int s_portTried[64];
static Mech *s_portOwner[64];
if (energyOrdinal >= 0 && energyOrdinal < 64)
{
if (!s_portTried[energyOrdinal]
|| s_portOwner[energyOrdinal] != this)
{
s_portTried[energyOrdinal] = 1;
s_portOwner[energyOrdinal] = this;
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++ % 31) == 0) // 31: coprime with the 5-beam volley (a %30 sampler aliased to one weapon)
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;
}
}