Files
BT411/game/reconstructed/mech4.cpp
T
arcattackandClaude Fable 5 63b168cb92 SeekVoltageGraph: full reconstruction -- the eng-page POWER graph + top-box eraser (Gitea #11)
The #10 audit's one WRONG: the port Execute was a bring-up no-op, so the
emitter/myomer engineering pages never erased their top data box -> stale
sibling-page ghosts on the shared Eng bit-plane (SYSTEM 10 PPC showing the
Streak ammo box, etc).  Full faithful widget landed:

- btl4gau2: ctor/BecameActive/Execute/clear/ticks/cursor recovered from the
  capstone disasm of @004c6798/@004c6920/@004c6934/@004c6be4/@004c6c30/
  @004c6c6c (Ghidra dropped every x87 arg).  Plot: v=0..12000 step 1200,
  x=Round(response(v)*230), y=Round(v*(1/12000)*187) (ld80 @004c6bd0/@004c6d74
  = exactly 1/12000); change-test samples the response at 12000V vs the 9999
  activation sentinel; XOR op for tick/cursor move-by-redraw; destroyed branch
  centres edestryd.pcc and revives via own vtbl+0xC (BecameActive, slot 3 of
  PTR_0051a1fc -- vtable-dump verified).
- The vtbl+0x3C sampler identified from the binary vtables: Emitter slot 15
  @004bb42c = sqrt(P(v)/2.0e8), P @004bb3f4 = damageFraction*v^2*0.5*
  energyCoefficient; Myomers slot 15 @004b8f94 = sqrt(AvailableOutput(v)*3.6/
  350).  FUN_004dd138 == sqrt (part_015.c:4026): myomers' fabs reading
  corrected, GetSpeedReading renamed SeekVoltageResponse.  Port dispatch via
  complete-type bridges BTSeekVoltageSample/BTMyomersSeekSample +
  BTSubsystemDestroyed (databinding rule).
- Emitter's AUTHENTIC attribute table recovered (binary @0x511dd4, ids
  0x1D-0x25) and published: Laser*/Seek*/OutputVoltage@0x414 (currentLevel,
  RAW volts -- the live-cursor feed).  Field renames per the table:
  0x3F8 minSeekVoltageIndex / 0x3FC maxSeekVoltageIndex (static_assert-locked).
  The MechWeapon 0x1D OutputVoltage PORT ALIAS retired (binary table ends
  0x1C; Find walks lowest-id-first, the alias shadowed the authentic row).
  PPC::DefaultData -> Emitter::GetAttributeIndex().
- mech4: BT_PRESET_HOLD=<n> (freeze the #9 preset cycler after n pulses) for
  steady-state pixel verification; BT_SEEK_LOG diag.

Verified live (BLH, autofire): both #10 repro pairs held ghost-free for
minutes (SYS09->SYS10 PPC, SYS02->SYS04 ERMed, SYS05->SYS06 Myomers); curves
draw with live cursors; one replot per activation; quad panels/J-K-L/sec
panel un-regressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:27:39 -05:00

6651 lines
308 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//===========================================================================//
// 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>
// LBE4ControlsManager (the fire buttonGroup push, task #5) -- needs the
// btl4app/btl4mode forward-decl order btl4mppr.cpp uses.
#if !defined(BTL4APP_HPP)
# include <btl4app.hpp>
#endif
#if !defined(BTL4MODE_HPP)
# include <btl4mode.hpp>
#endif
#if !defined(L4CTRL_HPP)
# include <l4ctrl.hpp>
#endif
#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)
#include <messmgr.hpp> // SubsystemMessageManager (task #7 consolidated delivery)
#include <mechweap.hpp> // MechWeapon::GetExplosionResourceID (per-round detonation)
#include <mislanch.hpp> // MissileLauncher::ClassDerivations (splash-radius gate)
#include "btinput.hpp" // the CONTROLS.MAP + XInput binding engine
#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
// (FootStep pulse decay moved to PerformAndWatch -- this integrator does not
// run on the current gait path, so a decay here never fired.)
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;
static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire button (Pinky 0x45)
int gBTModeCycle = 0; // 'M' edge: cycle the control mode (mapper consumes)
int gBTDisplayCycle = 0; // 'N' edge: cycle the secondary schematic (Gitea #6, mapper consumes)
int gBTPresetCycle[3] = {0,0,0}; // J/K/L edges: cycle an upper-MFD preset page (Gitea #9, L4 mapper consumes)
int gBTLookBehind = 0; // 'V' held: the pod's rear-view button (task #68)
float gBTTwistAxis = 0.0f; // Q/E torso-twist deflection (assisted-mode stick X)
float gBTElevAxis = 0.0f; // R/F torso-elevation (pitch aim, stick Y)
int gBTElevRecenter = 0; // X edge: zero the pitch axis
int gBTTorsoRecenter = 0; // 'X' edge: pulse the authentic torso recenter (mapper consumes)
static int gBTConfigKey = 0; // task #6: HOLD 'G' = the weapon-configure button
static int gBTGenSelKey = 0; // task #12: F5..F8 = SelectGeneratorA..D, F9 = mode toggle
// (0 idle; else the message id 4..8)
static int gBTValveKey = 0; // task #13: 'C' = MoveValve on the selected condenser
static int gBTFlushKey = 0; // Gitea #7: 'H' HELD = InjectCoolant (the coolant flush)
// 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))
// task #8 bridge: the weapon-side damage submission (mechweap.cpp) reads the
// owner's designated-zone slot; the raw target-slot block lives in this TU.
int BTMechTargetZone(void *mech)
{
return MECH_TARGET_SUBIDX(mech);
}
// 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 vel; // world velocity (authored MuzzleVelocity, steered per frame)
Scalar speed; // |vel| held constant through the steer
Scalar traveled;
Scalar range;
Entity *target;
Point3D targetPos;
Scalar aimOffsetY; // vertical aim offset vs the target's origin (live re-lead)
Scalar damage; // <= 0 -> VISUAL-ONLY round (replicant-side salvo mirror)
int guided; // 1 = missile (seeker loft + steering); 0 = ballistic (straight)
Entity *shooter; // the firing mech (messmgr explosion bundling at impact)
int weaponSubsys; // firing weapon's roster index (-1 = unthreaded) --
// resolves the weapon's ExplosionModelFile (mslhit/
// acanhit) in the messmgr, task #7 bundling
int splashBurst; // SALVO-LEAD cluster count for splash (task #62 fix):
// >0 ONLY on the first round of a missile salvo -- the
// baseBurst for ONE splash event = the whole cluster
// (matches the ONE arcade cluster missile). 0 on every
// other round (a straight tracer / non-lead volley round)
// so splash is NOT re-applied + floored-at-1 per round.
int active;
};
static BTProjectile gProjectiles[64];
extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float);
//###########################################################################
// Zone-effect bridge (mechdmg has no `application` access): route the damage-
// band effect through the AUTHENTIC RendererManager::StartEntityEffect chain
// (the @004d097c dispatcher; the AudioRenderer hears the same broadcast).
// Returns 0 when the manager isn't up so the caller can fall back.
//###########################################################################
int
BTStartZoneEffect(Mech *mech, void *zone, int resource)
{
if (mech == 0 || zone == 0 || resource <= 0
|| application == 0 || application->GetRendererManager() == 0)
return 0;
application->GetRendererManager()->StartEntityEffect(
(Entity *)mech, (DamageZone *)zone,
(ResourceDescription::ResourceID)resource);
return 1;
}
//###########################################################################
// Segment world-transform bridge (the @004d097c per-zone effect dispatcher +
// the attached-emitter follow in L4VIDEO's PFX layer). Resolves the entity's
// segment (by index) to its world position + 3x3 basis rows; falls back to
// the mech origin at torso height when the segment doesn't resolve. Returns
// 0 for a non-mech / unregistered entity (the follow then keeps its last
// frame -- and the emitter dies with its authored window anyway).
//###########################################################################
int
BTResolveSegmentWorld(void *entity, int seg_index, float *pos3, float *rows9)
{
extern int BTIsRegisteredMech(Entity *e);
if (entity == 0 || !BTIsRegisteredMech((Entity *)entity))
return 0;
Mech *m = (Mech *)entity;
Point3D p = m->localOrigin.linearPosition;
p.y += kMuzzleHeight; // fallback: torso height
if (seg_index >= 0)
{
EntitySegment::SegmentTableIterator it(m->segmentTable);
EntitySegment *seg;
while ((seg = it.ReadAndNext()) != NULL)
{
if (seg->GetIndex() == seg_index)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld);
p = mw; // Point3D = matrix translation
break;
}
}
}
pos3[0] = (float)p.x; pos3[1] = (float)p.y; pos3[2] = (float)p.z;
UnitVector ax, ay, az;
m->localToWorld.GetFromAxis(X_Axis, &ax);
m->localToWorld.GetFromAxis(Y_Axis, &ay);
m->localToWorld.GetFromAxis(Z_Axis, &az);
rows9[0] = (float)ax.x; rows9[1] = (float)ax.y; rows9[2] = (float)ax.z;
rows9[3] = (float)ay.x; rows9[4] = (float)ay.y; rows9[5] = (float)ay.z;
rows9[6] = (float)az.x; rows9[7] = (float)az.y; rows9[8] = (float)az.z;
return 1;
}
//###########################################################################
// MUZZLE FLASH (task #61) -- the GENUINE shipped projectile-gun muzzle effect.
//
// BTDPL.INI documents psfx 6 (external) / 14 (internal) = DAFC.PFX as "the
// effect used on all projectile guns": an orange fire-smoke blast
// (btfx:firesmoke1, maxIssue 25 / releasePeriod ~0.2s -> the emitter auto-
// expires after one ~0.2s burst = a per-shot muzzle flash). The AC is the
// only weapon that gets it -- lasers show their beam, missiles their launch.
// NOT the cut MUZFLASH.BGF card (that asset is orphaned; the shipped flash is
// this particle). Disasm of ProjectileWeapon::FireWeapon (@0x4bc104) shows no
// direct spawn there, so the fire edge is the faithful hook; the effect,
// asset, and gun-port attach point are all confirmed shipped [T1 asset/INI].
//
// Attached to the gun-port SEGMENT (BTResolveSegmentWorld re-resolves the
// muzzle position + mech body frame each frame): DAFC sprays -Z, and the mech
// faces -Z, so the fire-smoke blows forward out the barrel.
//###########################################################################
void
BTFlashMuzzle(void *ownerMech, int seg_index, float mx, float my, float mz)
{
if (ownerMech == 0)
return;
float pos[3], rows[9];
if (!BTResolveSegmentWorld(ownerMech, seg_index, pos, rows))
return;
extern void BTStartPfxAttached(int, void *, int, float, float, float, const float *);
// psfx 6 = DAFC (external); the day table maps both 6 and 14 to DAFC.PFX,
// so the external slot serves every viewer.
BTStartPfxAttached(6, ownerMech, seg_index, mx, my, mz, rows);
if (getenv("BT_MUZZLE_LOG"))
DEBUG_STREAM << "[muzzle] DAFC flash seg=" << seg_index
<< " at(" << mx << "," << my << "," << mz << ")\n" << std::flush;
}
//###########################################################################
// COOLANT-FLUSH CONDENSATION CLOUD (Gitea #7) -- the flush visual.
//
// BTDPL.INI psfx 19 = FLUSH.PFX, commented "Coolant flush" (both day and
// night tables): a bluish (0.7/0.9/1.6 tint) firesmoke burst, ~3.5 s window.
// The binary drives it with the mode-1 "coolant/reservoir" effect renderable
// (FUN_00456a68, alloc 0x138), built for every classID-0xBC0 subsystem on
// its "ReservoirState" attribute (BTL4VID.CPP build loop, part_014.c:5439;
// missing attr = Verify "Reservoir did not have a ReservoirState...") and
// DCS-parented to the mech; its per-frame tick (part_007.c:8780) starts the
// effect exactly when the watched state CHANGES to 1. Manual p24: "other
// pilots will see steam rise from your 'Mech on the battlefield".
//
// The port consolidates the BT effect renderables in the psfx layer, so this
// helper is called on the same 0->1 alarm edge (Reservoir::
// InjectCoolantMessageHandler) and rides the mech via the attached-emitter
// follower (seg -1 = the torso-height entity fallback in
// BTResolveSegmentWorld -- the cloud rises FROM the mech and trails it).
//###########################################################################
void
BTSpawnFlushCloud(void *ownerMech)
{
if (ownerMech == 0)
return;
float pos[3], rows[9];
if (!BTResolveSegmentWorld(ownerMech, -1, pos, rows))
return;
extern void BTStartPfxAttached(int, void *, int, float, float, float, const float *);
BTStartPfxAttached(19, ownerMech, -1, pos[0], pos[1], pos[2], rows);
if (getenv("BT_FLUSH_LOG"))
DEBUG_STREAM << "[flush] condensation cloud (psfx 19 FLUSH.PFX) at("
<< pos[0] << "," << pos[1] << "," << pos[2] << ")\n" << std::flush;
}
//###########################################################################
// PER-ROUND DETONATION (the binary's Missile::MoveAndCollide @004bef78: every
// round spawns ITS OWN ExplosionModelFile at its impact point, resource
// missile+0x33c -- a volley RIPPLES fireballs across its arrival frames, the
// demo look). Resolved from the firing weapon's roster entry (weapon+0x3E4);
// runs on BOTH nodes (the replicant visual salvo passes its launcher's index
// too). The messmgr's one bundled explosion remains (direct-fire semantics +
// the cross-pod damage stream); among a rippled volley it is invisible.
//###########################################################################
static void
BTSpawnRoundDetonation(Entity *shooter, int weapon_subsys, const Point3D &at)
{
extern int BTIsRegisteredMech(Entity *e);
if (shooter == 0 || weapon_subsys < 0 || !BTIsRegisteredMech(shooter))
return;
Mech *sm = (Mech *)shooter;
if (weapon_subsys >= sm->GetSubsystemCount())
return;
Subsystem *w = sm->GetSubsystem(weapon_subsys);
if (w == 0 || !w->IsDerivedFrom(*MechWeapon::GetClassDerivations()))
return;
ResourceDescription::ResourceID res = ((MechWeapon *)w)->GetExplosionResourceID();
if (res <= 0)
return;
Origin o;
o.linearPosition = at;
o.angularPosition = EulerAngles(Radian(0.0f), Radian(0.0f), Radian(0.0f));
Explosion::MakeMessage m(
Explosion::MakeMessageID, sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID, EntityID::Null,
res, Explosion::DefaultFlags, o,
sm->GetEntityID(), sm->GetEntityID());
Explosion *e = Explosion::Make(&m);
if (e)
Register_Object(e);
}
//###########################################################################
// SPLASH DAMAGE (task #62) -- Explosion::SplashDamage @0042fad0 / engine
// EXPLODE.cpp:50-254 [T0]. A detonating projectile's blast damages every mech
// within its authored SplashRadius. Damage MODEL (T0 EXPLODE.cpp:209-246,
// decomp part_004.c:856-911): the per-burst AMOUNT + damage TYPE pass through
// UNCHANGED; the distance falloff is applied to the BURST COUNT --
// bursts = round( baseBurst / dist^1.25 ), floored at 1
// (arcade exponent 1.25 -- part_004.c:865 = 0x3ff40000; the WinTesla source
// EXPLODE.cpp:209 drifted to 1.2f -- we use the arcade value). So a mech
// inside the radius takes the round's damage; a point-blank one takes extra
// bursts. Base burst = the incoming Damage's burstCount (Damage+0x2c).
//
// The SplashRadius is the ROUND's GameModel resource +0x50. ONLY missiles
// splash: in the arcade a MissileLauncher spawns a Missile ENTITY whose Perform
// (part_013.c:9887) is the sole SplashDamage caller (@10097); the AC's tracer is
// not a Missile and never splashes. The Missile's model id comes from the
// launcher's linked AmmoBin (ammoModelFile @0x1e8, part_013.c:8778), NOT the
// launcher's ExplosionModelFile; the Missile ctor reads SplashRadius from that
// model's type-0xf record +0x50 (@10184). So the port gates on MissileLauncher
// and resolves radius via launcher -> AmmoBin -> round model -> +0x50.
//
// PORT: the binary enumerates interest-zone movers; reconstructed mechs aren't
// reliably registered there, so we walk the live-mech registry
// (BTGetTargetCandidates, which already excludes the shooter -- no self-splash)
// -- guaranteed populated + cheaper. The DIRECT victim is excluded (it already
// took the direct hit; matches EXPLODE.cpp:179 excluding entityHit) so it is
// not double-damaged. Delivery reuses the direct-hit path (messmgr bundle /
// Dispatch), so replicant victims reroute cross-pod.
//###########################################################################
static Scalar
BTResolveSplashRadius(Entity *shooter, int weapon_subsys)
{
extern int BTIsRegisteredMech(Entity *e);
if (shooter == 0 || weapon_subsys < 0 || application == 0
|| application->GetResourceFile() == 0 || !BTIsRegisteredMech(shooter))
return 0.0f;
Mech *sm = (Mech *)shooter;
if (weapon_subsys >= sm->GetSubsystemCount())
return 0.0f;
Subsystem *w = sm->GetSubsystem(weapon_subsys);
// ONLY missile launchers spawn Missile entities that carry splash (see banner).
if (w == 0 || !w->IsDerivedFrom(MissileLauncher::ClassDerivations))
return 0.0f;
// The round's model id lives on the launcher's linked AmmoBin (+0x1e8),
// resolved through the same ammoBinLink the fire path uses.
extern void *BTWeaponAmmoBin(void *weapon);
extern int BTAmmoRoundModelResource(void *bin);
void *bin = BTWeaponAmmoBin(w);
if (bin == 0)
return 0.0f;
ResourceDescription::ResourceID res =
(ResourceDescription::ResourceID)BTAmmoRoundModelResource(bin);
if (res <= 0)
return 0.0f;
// The round's GameModel record (type 0xf): SplashRadius @ +0x50 (binary
// part_013.c:10184 reads resource_data+0x50; the MissileThruster parser
// FUN_004bf8ec writes "SplashRadius" to +0x50 of the type-0xf/size-0x54 record).
ResourceDescription *rd = application->GetResourceFile()->SearchList(
res, ResourceDescription::GameModelResourceType);
if (rd == 0)
return 0.0f;
rd->Lock();
Scalar radius = *(const Scalar *)((const unsigned char *)rd->resourceAddress + 0x50);
rd->Unlock();
if (!(radius > 0.0f) || radius > 1.0e4f) // NaN / garbage / no-splash guard
radius = 0.0f;
return radius;
}
void
BTApplySplashDamage(Entity *shooter, int weapon_subsys, const Point3D &center,
Entity *directVictim, const Damage &dmgTemplate)
{
Scalar radius = BTResolveSplashRadius(shooter, weapon_subsys);
static const int s_log = getenv("BT_SPLASH_LOG") ? 1 : 0;
if (s_log)
DEBUG_STREAM << "[splash] HOOK weapon=" << weapon_subsys
<< " radius=" << radius << "\n" << std::flush;
if (radius <= 0.0f)
return; // this weapon authors no splash
extern int BTIsRegisteredMech(Entity *e);
extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut);
Entity *cand[32];
int nc = BTGetTargetCandidates(shooter, cand, 32); // excludes the shooter
const Scalar baseBurst = (Scalar)dmgTemplate.burstCount;
if (s_log)
DEBUG_STREAM << "[splash] detonation radius=" << radius << " candidates="
<< nc << " directVictim=" << (void *)directVictim << "\n" << std::flush;
for (int ci = 0; ci < nc; ++ci)
{
Entity *e = cand[ci];
if (e == 0 || e == directVictim || !BTIsRegisteredMech(e))
continue;
Mech *m = (Mech *)e;
if (m->IsMechDestroyed() || m->damageZoneCount <= 0)
continue;
Point3D p = m->localOrigin.linearPosition;
Scalar dx = p.x - center.x, dy = p.y - center.y, dz = p.z - center.z;
Scalar dist = (Scalar)sqrtf((float)(dx*dx + dy*dy + dz*dz));
if (dist > radius) // RADIUS GATE -- required: the
continue; // falloff floors at 1, so without
// this every mech would take a burst
// EXPLODE.cpp:209 -- burstCount = baseBurst / dist^exp, floored at 1.
// Exponent is the ARCADE 1.25 (decomp 0x3ff40000); the WinTesla source
// drifted to 1.2f. Point-blank AMPLIFIES (dist<1 -> >baseBurst bursts) --
// authentic; a real splash victim is never at dist~0 (the mech AT the
// blast is the excluded direct victim), so the div-guard epsilon (mechs
// have body size) only ever fires in the synthetic BT_SPLASH_TEST.
Scalar d = (dist > 0.01f) ? dist : 0.01f; // div-guard (no arcade floor)
int bursts = (int)(0.5f + baseBurst / powf((float)d, 1.25f)); // arcade ROUND
if (bursts < 1) bursts = 1; // EXPLODE.cpp:210 Min_Clamp
Damage dmg = dmgTemplate; // amount + type pass through
dmg.burstCount = bursts;
dmg.impactPoint = center;
dmg.damageForce = Vector3D(dx, dy, dz); // EXPLODE.cpp:216 radial knockback
// DIRECT dispatch to EACH victim (T0 EXPLODE.cpp:246 -- target->Dispatch).
// NOT the shooter's SubsystemMessageManager: AddDamageMessage CONSOLIDATES
// every damage message of the frame onto ONE common entity (the first hit,
// messmgr.cpp:279) -- so routing splash through it delivered the bystander's
// blast onto the DIRECT victim instead (double-hitting the excluded mech).
// Dispatch reroutes cross-pod for a replicant victim on its own.
Entity::TakeDamageMessage td(
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, dmg);
e->Dispatch(&td);
if (s_log)
DEBUG_STREAM << "[splash] victim=" << e->GetEntityID() << " dist=" << dist
<< " radius=" << radius << " bursts=" << bursts
<< " amount=" << dmg.damageAmount << " baseBurst=" << (int)baseBurst
<< "\n" << std::flush;
}
}
// 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, const Vector3D *launch_velocity, int guided,
int weapon_subsys, int splash_burst, int muzzle_seg)
{
// MUZZLE (muzzle wave, 2026-07-12): the passed muzzle is now the weapon's
// AUTHENTIC mount segment (GetMuzzlePoint reads the real segmentIndex --
// the old raw-offset garbage that made it resolve feet/heads is fixed), so
// the port-name rotation hack that compensated for it is RETIRED. Keep
// one safety: a degenerate resolve at/below origin height (an unposed
// segment) launches from torso height instead of underground.
Point3D mz = muzzle;
if (shooter != 0)
{
Mech *sm = (Mech *)shooter;
if (mz.y - sm->localOrigin.linearPosition.y < 1.0f)
{
mz = sm->localOrigin.linearPosition;
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;
// A VISUAL round (damage <= 0, the replicant-side salvo mirror) flies on the
// replicated aim POINT alone; a live round still requires the designation.
if (target == 0 && damage > 0.0f)
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"))
{
Scalar relY = (shooter != 0)
? (mz.y - ((Mech *)shooter)->localOrigin.linearPosition.y) : mz.y;
DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target
<< " len=" << len << " speed=" << speed << " dmg=" << damage
<< " mz=(" << mz.x << "," << mz.y << "," << mz.z << ") relY=" << relY
<< " lv=" << (launch_velocity
? "(auth)" : "(fallback)") << "\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.speed = (speed > 1.0f) ? speed : 120.0f; // |launchVelocity|; sane fallback
// AUTHENTIC LAUNCH (missile-arc wave): the launcher's authored
// MuzzleVelocity is a VECTOR in the shooter's frame (typically
// up-tilted); rotate it into world by the shooter's pose so the round
// leaves the rack climbing -- the front half of the pod's missile arc.
// (The old code collapsed it to |v| along the straight line to the
// target: dead-flat flight.) Fall back to the straight line when no
// authored vector reaches us (non-missile callers).
int haveLaunch = 0;
if (launch_velocity != 0 && shooter != 0)
{
// FRAME CONVENTION (telemetry-verified 2026-07-12): the authored
// MuzzleVelocity is (0,0,+100) with a NEGATED z when composed --
// the binary's own fire builder does `local_40 -= MV.z`
// (FUN_004bcc60 @part_013.c:8761) [T1].
// TORSO-TWIST FIX (task #67, user-reported "missiles fire out of
// his back"): rotate through the WEAPON MOUNT SEGMENT's world
// frame, not the body basis. The MadCat's racks ride the TORSO:
// the muzzle POINT tracked the twist (segment-resolved) while the
// launch DIRECTION followed the LEGS -- twist far enough and
// rounds left backward. The binary spawns the missile with the
// full muzzle-segment frame (GetMuzzlePoint fills an AffineMatrix
// into the spawn descriptor, @0x4bcc60:8762-8764) and the dumb
// seeker aims 100u ahead of the MISSILE's own frame -- segment
// orientation IS the authentic launch direction.
Mech *sm = (Mech *)shooter;
UnitVector ax, ay, az;
int haveFrame = 0;
if (muzzle_seg >= 0)
{
EntitySegment *seg = sm->GetSegment(muzzle_seg);
if (seg != 0)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), sm->localToWorld);
mw.GetFromAxis(X_Axis, &ax);
mw.GetFromAxis(Y_Axis, &ay);
mw.GetFromAxis(Z_Axis, &az);
haveFrame = 1;
}
}
if (!haveFrame)
{
sm->localToWorld.GetFromAxis(X_Axis, &ax);
sm->localToWorld.GetFromAxis(Y_Axis, &ay);
sm->localToWorld.GetFromAxis(Z_Axis, &az);
}
p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y - az.x*launch_velocity->z;
p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y - az.y*launch_velocity->z;
p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y - az.z*launch_velocity->z;
// (task #67) inherit the shooter's motion: the binary adds the
// mech's localVelocity into the spawn velocity (FUN_004b9cbc =
// owner+0x1c4 read, composed @0x4bcc60:8758-8761) [T1].
p.vel.x += sm->worldLinearVelocity.x;
p.vel.y += sm->worldLinearVelocity.y;
p.vel.z += sm->worldLinearVelocity.z;
Scalar lv = (Scalar)sqrtf(p.vel.x*p.vel.x + p.vel.y*p.vel.y + p.vel.z*p.vel.z);
if (lv > 1.0f) { p.speed = lv; haveLaunch = 1; }
}
if (!haveLaunch)
{
p.vel.x = d.x/len*p.speed; p.vel.y = d.y/len*p.speed; p.vel.z = d.z/len*p.speed;
}
p.traveled = 0.0f;
p.range = len * 1.3f + 60.0f; // arc margin; expire past the target
p.target = (Entity *)target;
p.targetPos = tpos; // resolved target position (fallback-aware)
// live re-lead (authentic: the Seeker re-leads the MOVING target every
// slice): remember the aim's height above the target's origin so the
// per-frame refresh keeps striking the same body height.
extern int BTIsRegisteredMech(Entity *e);
p.aimOffsetY = (target != 0 && BTIsRegisteredMech((Entity *)target))
? (tpos.y - ((Mech *)target)->localOrigin.linearPosition.y) : 0.0f;
p.damage = damage;
p.guided = guided; // autocannon shells fly straight (no seeker in the binary's plain Projectile)
p.shooter = (Entity *)shooter;
p.weaponSubsys = weapon_subsys;
p.splashBurst = splash_burst; // >0 only on a salvo-lead round
// RACK-TUBE SPREAD [T3, physically grounded]: the binary's Missile
// entities each launch from their own rack tube (per-tube authored
// MuzzlePosition); our single muzzle superimposed a whole salvo onto
// ONE trajectory -- 12 rounds read as one round, and their 12
// detonations stacked into one frame. Approximate the tube offsets
// with a small deterministic per-slot cone (+-~2.5 deg) + lateral
// muzzle offset; the seeker re-converges them onto the target so the
// volley arrives as the demo's RIPPLE of impacts.
// GUIDED ROUNDS ONLY: a ballistic shell has no seeker to re-converge
// it -- the slot-0 pattern deflected every AFC100 shell a fixed
// 3.4deg left / 2.5deg down (the phantom "4th gold beam" burying its
// tracer beside the target, user-reported). The binary's plain
// Projectile flies straight at the pick.
if (guided)
{
float ja = ((i % 5) - 2) * 0.030f; // yaw +-0.06 rad
float jb = (((i / 5) % 5) - 2) * 0.022f; // pitch +-0.044 rad
float vx = p.vel.x, vy = p.vel.y, vz = p.vel.z;
float ca = cosf(ja), sa = sinf(ja);
p.vel.x = vx * ca - vz * sa;
p.vel.z = vx * sa + vz * ca;
p.vel.y = vy + jb * p.speed;
p.pos.x += ((i % 3) - 1) * 0.6f;
p.pos.z += (((i / 3) % 3) - 1) * 0.6f;
}
p.active = 1;
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] vel=(" << p.vel.x << "," << p.vel.y
<< "," << p.vel.z << ")"
<< (launch_velocity
? " authored=(" : " none=(")
<< (launch_velocity ? launch_velocity->x : 0.0f) << ","
<< (launch_velocity ? launch_velocity->y : 0.0f) << ","
<< (launch_velocity ? launch_velocity->z : 0.0f) << ")" << std::endl;
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;
// AUTHENTIC GUIDANCE (missile-arc wave; Seeker::LeadTarget @004beae4 +
// the @004bef78 steering [T1]). The seeker LOFTS the aim point while
// the round is far out -- beyond 200 m the aim rises by
// 0.1 x clamp(range-200, 0, 300) (up to +30 m above the target) -- so
// the missile climbs at range and dives as it closes: the pod's
// "gravity arc". Steering is the port shape of the binary's
// squared-error guidance: rotate the velocity toward the lofted aim at
// the decomp turn gain (MissileTurnGain = 4.0, _DAT_004bf5a4), speed
// held at the authored launch speed.
if (p.guided)
{
// LIVE RE-LEAD (authentic: Seeker::LeadTarget runs every slice on
// the MOVING target): refresh the aim from the target's current
// position, preserving the launch aim's body height.
extern int BTIsRegisteredMech(Entity *e);
if (p.target != 0 && BTIsRegisteredMech(p.target)
&& !((Mech *)p.target)->IsMechDestroyed())
{
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY;
}
Point3D aim = p.targetPos;
Scalar rx = aim.x - p.pos.x, ry = aim.y - p.pos.y, rz = aim.z - p.pos.z;
Scalar range = (Scalar)sqrtf(rx*rx + ry*ry + rz*rz);
if (range > 200.0f) // SeekerLeadMinRange @004bec18
{
Scalar lead = range - 200.0f;
if (lead > 300.0f) lead = 300.0f; // SeekerLeadMaxClamp @004bec24
aim.y += 0.1f * lead; // SeekerLeadCoef @004bec28
ry = aim.y - p.pos.y;
}
if (range > 0.001f)
{
Scalar inv = 1.0f / range;
Scalar cx = p.vel.x / p.speed, cy = p.vel.y / p.speed, cz = p.vel.z / p.speed;
// MissileTurnGain (4.0, _DAT_004bf5a4) far out; DOUBLED inside
// the no-loft radius so the dive converges onto the contact
// sphere (the port shape of the binary's squared-error terminal
// aggressiveness -- error^2 steering climbs steeply near boresight).
Scalar k = ((range < 200.0f) ? 8.0f : 4.0f) * dt;
cx += (rx*inv - cx) * k; cy += (ry*inv - cy) * k; cz += (rz*inv - cz) * k;
Scalar cl = (Scalar)sqrtf(cx*cx + cy*cy + cz*cz);
if (cl > 0.001f)
{
p.vel.x = cx/cl*p.speed; p.vel.y = cy/cl*p.speed; p.vel.z = cz/cl*p.speed;
}
}
}
p.pos.x += p.vel.x*dt; p.pos.y += p.vel.y*dt; p.pos.z += p.vel.z*dt;
p.traveled += p.speed * dt;
// WORLD IMPACT (authentic: the binary missile runs a world collision
// query every frame, FUN_0042291c, and DETONATES on geometry). Ray the
// flight step against the terrain/cave solids -- a lofted round in a
// low cavern bursts on the CEILING instead of punching through it.
{
extern bool BTGroundRayHit(float,float,float, float,float,float,
float, float*,float*,float*);
extern Entity *gBTTerrainEntity; // captured by MakeEntityRenderables
Scalar step = p.speed * dt;
if (gBTTerrainEntity != 0 && step > 0.0001f)
{
Vector3D rd;
rd.x = p.vel.x/p.speed; rd.y = p.vel.y/p.speed; rd.z = p.vel.z/p.speed;
float hx, hy, hz;
if (BTGroundRayHit(prev.x, prev.y, prev.z, rd.x, rd.y, rd.z,
step + 1.0f, &hx, &hy, &hz))
{
// burst on the rock: the round's own DETONATION (the binary
// missile detonates on ANY geometry, @004bef78) + a tight
// puff cluster, no damage
{
Point3D hp; hp.x = hx; hp.y = hy; hp.z = hz;
BTSpawnRoundDetonation(p.shooter, p.weaponSubsys, hp);
// SPLASH (task #62): the binary missile detonates on ANY
// geometry and still runs SplashDamage (the +0x360 gate sits
// inside the collision branch @part_013.c:10045, which fires
// for a world hit as well as a mover hit). A missile that
// bursts on terrain near a mech still catches it in the blast.
// ONCE per salvo (splashBurst>0), baseBurst = the cluster count;
// no direct victim here. No-op for non-missile rounds.
if (p.damage > 0.0f && p.splashBurst > 0)
{
Damage sdmg;
sdmg.damageType = Damage::ExplosiveDamageType;
sdmg.damageAmount = p.damage;
sdmg.burstCount = p.splashBurst; // cluster count as baseBurst
sdmg.impactPoint = hp;
extern void BTApplySplashDamage(Entity *, int, const Point3D &,
Entity *, const Damage &);
BTApplySplashDamage(p.shooter, p.weaponSubsys, hp, 0, sdmg);
}
}
extern void BTPfxTrailPuff(int, float, float, float, float, float, float, int);
for (int pf = 0; pf < 4; ++pf)
BTPfxTrailPuff(0, hx, hy, hz, -rd.x, -rd.y, -rd.z, 2);
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] WORLD burst at(" << hx << ","
<< hy << "," << hz << ")" << std::endl;
p.active = 0;
continue;
}
}
}
// 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;
bd.x = p.vel.x/p.speed; bd.y = p.vel.y/p.speed; bd.z = p.vel.z/p.speed;
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);
}
// CONTACT-ONLY damage (user-verified bug: an arcing round that ran out
// its flight cap while still descending "applied damage without making
// contact"). Proximity = the hit; the flight-cap expiry is a FIZZLE --
// no damage, matching the binary (a missile that dies mid-air detonates
// nothing; only the world-collision hit spawns the Damage entity).
Scalar dx = p.targetPos.x - p.pos.x, dy = p.targetPos.y - p.pos.y, dz = p.targetPos.z - p.pos.z;
const int contact = (dx*dx + dy*dy + dz*dz < (10.0f*10.0f));
if (!contact && p.traveled >= p.range)
{
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] FIZZLE (flight cap, no contact)\n" << std::flush;
p.active = 0;
continue;
}
if (contact)
{
BTSpawnRoundDetonation(p.shooter, p.weaponSubsys, p.pos);
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;
// Route through the SHOOTER's SubsystemMessageManager with the
// firing launcher's roster index (task #7 bundling): the
// consolidation resolves roster[id]+0x3E4 = the weapon's
// ExplosionModelFile (mslhit/acanhit) and fires it AT the
// impact point -- the missing missile-hit explosion (the
// laser path always did this via SendDamageMessage; the
// projectile path bypassed the manager entirely).
SubsystemMessageManager *mgr = 0;
if (p.shooter != 0 && p.weaponSubsys >= 0 && BTIsRegisteredMech(p.shooter))
mgr = (SubsystemMessageManager *)((Mech *)p.shooter)->GetMessageManager();
if (mgr != 0)
{
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
p.shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/,
dmg, p.weaponSubsys);
mgr->AddDamageMessage(tgt, &take_damage);
}
else
{
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
<< " subsys=" << p.weaponSubsys
<< (mgr ? " (msgmgr bundled)" : " (direct)")
<< " (zone cyl-resolved)\n" << std::flush;
// SPLASH (task #62): a detonating missile SALVO damages every
// OTHER mech in the blast (tgt excluded -- it took the direct hit
// above). Fires ONCE per salvo, on the salvo-LEAD round only
// (splashBurst>0), with baseBurst = the whole cluster count -- the
// ONE arcade cluster missile. The other visual rounds of the
// salvo carry splashBurst=0 so the falloff floor-at-1 is applied
// once, not N times (the missileCount-x over-splash bug).
if (p.splashBurst > 0)
{
Damage sdmg = dmg;
sdmg.burstCount = p.splashBurst; // cluster count as baseBurst
extern void BTApplySplashDamage(Entity *, int, const Point3D &,
Entity *, const Damage &);
BTApplySplashDamage(p.shooter, p.weaponSubsys, p.pos, tgt, sdmg);
}
}
}
else if (tgt != 0 && !BTIsRegisteredMech(tgt) && p.damage > 0.0f
&& tgt->damageZoneCount > 0)
{
// NON-MECH victim with damage zones (a CulturalIcon truck/prop
// designated by the world pick -- issue #3, item b). The binary
// missile dispatches a zone=-1 TakeDamageMessage DIRECTLY at
// whatever entity's solid the round struck, with NO class test
// (Missile::MoveAndCollide contact branch -> @004be078: msg id
// 100, zone 0xffffffff, invalid-zone flag 1 -> struck->Dispatch).
// The icon's own handler maps -1 -> 0 and runs the death
// transition (-> the FULL 'trkdead' explosion, res 32).
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),
(p.shooter != 0) ? p.shooter->GetEntityID() : EntityID::Null,
-1 /*unaimed; icon handler maps -1 -> zone 0*/, dmg);
tgt->Dispatch(&take_damage);
if (getenv("BT_PROJ_LOG") || getenv("BT_CULT_LOG"))
DEBUG_STREAM << "[projectile] IMPACT (non-mech entity class="
<< (int)tgt->GetClassID() << ") damage=" << p.damage
<< " (@004be078 direct dispatch)\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;
// The Mover motion scalars too (the binary Reset zeroes the whole motion
// block): a respawn is a TELEPORT -- stale death-frame velocity must never
// survive into the first post-respawn collision/publish.
worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f);
localVelocity = Motion::Identity;
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f);
ramLastVictim = 0;
ramContactLinger = 0.0f;
// StopAllEntityEffects (@004d0c14): a respawned mech must not trail its
// corpse's attached zone effects -- the authentic per-entity effect
// cleanup, broadcast to every renderer.
if (application != 0 && application->GetRendererManager() != 0)
application->GetRendererManager()->StopAllEntityEffects((Entity *)this);
// --- 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 (binary FUN_0049fb74: FUN_0041bbd8(+0x2c, 0)
// + the motion/replication scalars zeroed) ---
SetMovementMode(0); // simulationState 0 = init/reset; the
// drive re-selects 1 next frame [T1]
legCycleSpeed = 0.0f; // @0x348 (decomp Reset zeroes these)
bodyCycleSpeed = 0.0f; // @0x6b8
bodyTargetSpeed = 0.0f; // @0x6b4
poseSyncLatch = 0; // @0x77c
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;
// --- RENDER un-wreck: drop the sunk dbr hulk + restore the intact body ---
// (the wreck swap is one-way on the render side; Reset heals the sim but
// the mech keeps rendering as the sunk hulk without this).
{
extern void BTRebuildMechModel(Entity *entity);
BTRebuildMechModel((Entity *)this);
}
// --- broadcast the reset state to replicants: the binary Reset tail is
// ForceUpdate(0x1f) -- record types 0-4 in one shot (pose + damage
// zones + speed + leg-state + orientation resync), replicant-gated on
// (flags+0x28 & 0xc) != 4. Bit 1 re-broadcasts the healed damage
// zones: the falling edge MechDeathHandler::Tick uses to un-wreck +
// warp the peer on the observer's screen. [T1 @0x49fdfc]
if (GetInstance() != ReplicantInstance)
ForceUpdate(0x1f);
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);
}
// Request the DEATH record BEFORE entering the disabled state (the
// ForceUpdate filter masks types 2..8 once IsDisabled) -- the binary
// death sender is Force(1) + Force(0x40) (@0x4aab2f/@0x4aab3a). The
// record emits on the next update pump with simulationState 9 in its
// header; that flips the replicant's MovementMode, whose own
// UpdateDeathState then runs the wreck sink/burial on the OBSERVER's
// screen (this function runs for every mech, replicants included).
ForceUpdate(Simulation::DefaultUpdateModelFlag
| (1 << MechDeathUpdateModelBit));
SetMovementMode(9); // disabled: IsDisabled() true, locomotion frozen
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] mech " << GetEntityID()
<< " destroyed -> subsystem shutdown + frozen wreck (IsDisabled="
<< (int)IsDisabled() << ")" << std::endl;
// --- 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).
// task #60: pass the REAL killing-blow magnitude (latched in the damage
// handler), not the flat bring-up kShotDamage -- the score handler
// @0x4c02e4 derives the whole kill award from this damageAmount, so a
// flat 12 made every kill score identically regardless of weapon.
BTPostKillScore((Entity *)this, lastInflictingDamage);
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;
// OBSERVED-DEATH tally (MP DEATHS fix): a REPLICANT's death observed
// here counts onto its owning player's LOCAL score copy -- the
// symmetric half of the cross-pod KILLS credit (see btplayer.cpp
// BTPlayerCountObservedDeath). Masters count through their own
// VehicleDead(-1) path; counting them here too would double-tally.
if (GetInstance() == ReplicantInstance)
{
Player *owning_player = GetOwningPlayer(); // entity+0x190
if (owning_player != 0)
{
extern void BTPlayerCountObservedDeath(void *);
BTPlayerCountObservedDeath(owning_player);
}
}
}
//
// --- 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;
}
}
}
// Peer heading published each sim update, sampled by the render loop (BT_RENDHDG).
volatile float gBTReplRenderYaw = -999.0f;
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());
// task #59: keep this mech on the authentic gait clip set for its role --
// INTERIOR (level cockpit) if it's the mech you pilot, EXTERIOR (the -8deg
// walk lean) for every other mech. One-time flip per viewpoint change; the
// ctor lands everyone on interior because the port never sets the copy bit.
MaintainViewClipSet();
// 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 << ")" << std::endl;
}
}
// --- 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)
{
// AUTHENTIC PEER HEADING (decomp FUN_004ab188/FUN_00409f58 via FUN_004ab1c8):
// the original replicant integrates its heading INCREMENTALLY from the
// CURRENT pose -- compose an exact rotation of (replicated yaw rate * dt)
// onto the rendered heading each frame -- and re-anchors it when a type-4
// resync lands. It does NOT slerp toward a projected angular target: the
// engine Mover::DeadReckon lerp uses the shared lastUpdate/nextUpdate
// timebase, which the DENSE type-0 pose stream resets every frame while
// walking while RESTORING a stale orientation (the authentic case-0 strip)
// -- so the angular projection barely advances from a stale base and the
// slerp DRAGS the heading back each frame. Measured: walking+turning, the
// peer yaw advanced at ~40% rate with half the frames stepping BACKWARD
// (the keyboard-steering skip); pure spin/walk never showed it (one stream).
// Save the heading, let DeadReckon own the LINEAR channel, then override
// the angular result with the original's incremental integration.
// ROOT-MOTION peer POSITION (authentic FUN_004ab1c8: the clip's travel
// drives the body between records; pose records re-anchor). Save the
// position so the engine velocity-lerp result can be discarded below
// (BT_DR_POS=1 restores the velocity dead-reckon for A/B).
// DEFAULT: engine velocity dead-reckon (best-measured: ratio 1.0022). The
// coupled root-motion experiment (BT_ROOT_POS=1) needs the MASTER-side
// gait mirror to stop the authority tug-of-war (measured ratio 1.86) --
// the completion path is mapped in the session notes.
static const int s_drPos = getenv("BT_DR_POS") ? 1 : 0; // default 0 = authentic gait-coupled linear (BT_DR_POS=1 restores velocity)
Point3D replPrevPos = localOrigin.linearPosition;
Quaternion replPrevHeading = localOrigin.angularPosition;
DeadReckon(dt); // engine reckoner: LINEAR position/velocity
if (angSyncLatch)
{
// type-4 arrived: re-anchor on the authoritative orientation
localOrigin.angularPosition = updateOrigin.angularPosition;
angSyncLatch = 0;
}
else
{
Vector3D replAngStep;
replAngStep.Multiply(updateVelocity.angularMotion, dt);
ReconQuatIntegrate(&localOrigin.angularPosition,
&replPrevHeading, &replAngStep); // exact FUN_00409f58
}
localOrigin.angularPosition.Normalize();
if (!s_drPos)
localOrigin.linearPosition = replPrevPos; // root-motion mode: gait applies travel below
localToWorld = localOrigin;
// RENDER-vs-SIM decoupling probe: publish the heading the RENDER will
// actually draw (localToWorld == localOrigin here). The render loop
// (L4VIDEO.cpp) logs this PER RENDERED FRAME; if it repeats the same
// value across render frames, the peer sim updates slower than the
// render draws -> the rotation stutters even though each sim update is
// smooth (which the per-update [replhdg] metric could not see).
{
extern volatile float gBTReplRenderYaw;
YawPitchRoll _rry; _rry = localOrigin.angularPosition;
gBTReplRenderYaw = (float)_rry.yaw;
}
// Lightweight dead-reckon smoothness probe (one flush/sec, cheap).
// PATH length (sum of |per-frame step|) vs NET displacement reveals
// jitter: a smooth walk has path==net; oscillation inflates path while
// net stays small. avgHorizon is the lerp target distance in time.
if (getenv("BT_JIT"))
{
static bool s_jinit = false;
static Point3D s_jprev, s_jnetA;
static float s_jacc = 0.0f, s_jpath = 0.0f, s_jmax = 0.0f, s_jhzn = 0.0f;
static int s_jfr = 0;
if (s_jinit)
{
const float dx = (float)(localOrigin.linearPosition.x - s_jprev.x);
const float dz = (float)(localOrigin.linearPosition.z - s_jprev.z);
const float step = sqrtf(dx * dx + dz * dz);
s_jpath += step;
if (step > s_jmax) s_jmax = step;
}
else { s_jnetA = localOrigin.linearPosition; s_jinit = true; }
s_jprev = localOrigin.linearPosition;
s_jhzn += (float)(nextUpdate - lastPerformance);
s_jfr++; s_jacc += dt;
if (s_jacc >= 1.0f)
{
const float ndx = (float)(localOrigin.linearPosition.x - s_jnetA.x);
const float ndz = (float)(localOrigin.linearPosition.z - s_jnetA.z);
const float net = sqrtf(ndx * ndx + ndz * ndz);
DEBUG_STREAM << "[repljit] frames=" << s_jfr
<< " path=" << s_jpath << " net=" << net
<< " ratio=" << (net > 0.001f ? s_jpath / net : 0.0f)
<< " maxStep=" << s_jmax << " avgStep=" << (s_jpath / s_jfr)
<< " avgHorizon=" << (s_jhzn / s_jfr)
<< "\n" << std::flush;
s_jacc = 0.0f; s_jpath = 0.0f; s_jmax = 0.0f; s_jhzn = 0.0f;
s_jfr = 0; s_jnetA = localOrigin.linearPosition;
}
}
// 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.
MechControlsMapper *replMppr = MappingMapper(); // roster slot 0 (task #7)
if (!IsMechDestroyed() && replMppr != 0)
{
// AUTHENTIC LEG-CHANNEL DEMAND (decomp-verified, workflow wv1km7lvc D3):
// the leg channel FUN_004a5028 reads its speed demand from
// mapper->speedDemand == **(mech+0x128)+0x128 (part_012.c:11947/11975/
// 12028) and slews cadence + selects the stand->walk gate from it. On a
// peer this MUST be the REPLICATED COMMANDED speed bodyTargetSpeed@0x6b4 --
// the master's own throttle-commanded speedDemand (mechmppr.cpp:749),
// stamped into every record (mech.cpp:2036, record->speedDemand) and read
// back on the peer (mech.cpp type-0 @1833, type-2 @1841/1848, type-3 @1871).
// It is the IDENTICAL value the master's leg channel consumes, so the peer
// clears the stand->walk gate exactly when the master does (the commanded
// speed is what makes the master walk, so it clears standSpeed by
// construction) and ramps cadence CONTINUOUSLY through accel/decel --
// cadence tracks travel like the master. bodyTargetSpeed is signed
// (negative == reverse); it is 0 only while COASTING (thr=0), which
// authentically winds the gait down to stand, matching the master.
//
// The prior default DERIVED a noisy speed from the dead-reckoned velocity
// and PINNED it flat with a standSpeed*1.05 floor across the walk threshold
// -- the CONFIRMED leg-stutter/skip + cadence-vs-travel desync. Retained
// behind BT_REPL_VEL for A/B comparison only.
static const int s_replVel = getenv("BT_REPL_VEL") ? 1 : 0;
if (s_replVel)
{
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
Scalar sd = (fdot < 0.0f) ? -spd : spd;
if (sd > standSpeed * 0.5f && sd < standSpeed * 1.05f)
sd = standSpeed * 1.05f;
replMppr->speedDemand = sd;
}
else
{
replMppr->speedDemand = bodyTargetSpeed; // authentic replicated commanded speed
}
// AUTHENTIC PEER TURNING is NET-DRIVEN (decomp-verified D8): the angular
// slerp inside DeadReckon (MOVER.cpp:521-525) rotates
// localOrigin.angularPosition toward projectedOrigin.angularPosition from
// the replicated yaw rate (decomp angular path part_012.c:15001/15010).
// The leg SM's turn-in-place 'trn' clip is a MASTER-ONLY construct fed by
// the LIVE controls mapper; the old synthetic yawRate->turnDemand
// (quantized +-0.02) flickered the trn clip in/out as the noisy replicated
// rate crossed the band -> the jerky pivot / statue-rotate. Do NOT
// synthesize it -- let the whole body rotate via the slerp.
// TURN-STEP (presentation of the authentic pivot): the authentic peer
// shows a stepping pivot because its BODY channel replays the master's
// replicated turn body-STATE (type-3 SetBodyAnimation, mech.cpp:1869).
// We run the LEG channel (D2 not switched), which has no replicated turn
// state, so we reproduce the visible turn-step by arming the leg SM's
// 'trn' clip from the replicated yaw rate. The old feed used a bare
// +-0.02 threshold that CHATTERED the trn clip in/out as the noisy rate
// wandered near zero (the "jerky pivot", decomp-noted D8). HYSTERESIS
// fixes that: enter the turn only above +-0.08 rad/s, hold it (the
// mapper's turnDemand persists frame-to-frame -- we are its only writer on
// a peer) until the rate clearly collapses below 0.03. A steady turn sits
// well above both, so the clip never flickers. (BT_REPL_NOTURN =
// decomp-strict net-driven rotation with NO step.)
const float yawRate = (float)updateVelocity.angularMotion.y;
static const int s_noTurn = getenv("BT_REPL_NOTURN") ? 1 : 0;
if (s_noTurn)
{
replMppr->turnDemand = 0.0f;
}
else
{
const float prevTd = (float)replMppr->turnDemand; // last frame (our own write)
if (prevTd != 0.0f) // turning: hold until the rate collapses
replMppr->turnDemand = (fabsf(yawRate) < 0.03f) ? 0.0f : prevTd;
else // idle: start only on a clear turn
replMppr->turnDemand = (yawRate > 0.08f) ? 1.0f
: (yawRate < -0.08f) ? -1.0f : 0.0f;
}
// 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). (The old forwardCycleRate floor is retired:
// the ctor reads the authentic MaxAcceleration since the task #4
// record-layout fix.)
globalTimeScale = 1.0f;
idleStrideScale = 1.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;
// PEER POSE CHANNEL (decomp workflow w1s9ou02o -- the authentic fix): the
// 1995 peer posed the WHOLE skeleton from the BODY channel (FUN_004a5678)
// FOLLOWING the master's replicated body-anim state (bodyStateAlarm@0x728,
// set by the type-3 reader's SetBodyAnimation, mech.cpp:1913) + replicated
// bodyTargetSpeed@0x6b4. The LEG channel (FUN_004a5028) is DEAD CODE. Our
// port resurrected the leg SM as the peer's poser fed a LOCALLY re-derived
// commanded speed, and the leg SM's phase-independent pre-switch wind-down
// (mech2.cpp:560-568, force-jump {6,7,8,9}->stand when legCycleSpeed<=0) makes
// the peer SKIP the master's decel state 8 and snap to stand while the body
// coasts (the 'slid forward after legs stopped' bug). The body channel has
// NO such wind-down and follows the replicated decel state, playing the
// master's exact clips. Swapping the peer to the body channel RESTORES the
// original single-channel design (no new netcode). BT_PEER_LEGCH=1 = old.
static const int s_peerLegCh = getenv("BT_PEER_LEGCH") ? 1 : 0;
// TRUE-MIRROR CADENCE: drive the body clip from the ACTUAL replicated ground
// speed so the animation phase tracks the master exactly instead of drifting
// via the local bodyCycleSpeed slew (the accel/decel hesitation). Consumed in
// AdvanceBodyAnimation walk/reverse. -1 disables -> authentic slew.
if (!s_peerLegCh && !getenv("BT_NO_MIRROR_CAD"))
{
const Vector3D &mcv = updateVelocity.linearMotion;
peerMirrorSpeed = (Scalar)sqrtf((float)(mcv.x * mcv.x + mcv.z * mcv.z));
}
else
peerMirrorSpeed = -1.0f;
// TURN-STEP ARMING for the body channel: the body Standing case only enters
// walk/reverse/stand -- it never arms the turn-in-place clip (state 4); on the
// master that arming comes from the LEG Standing case cross-arming both
// channels (mech2.cpp:937 note), which the peer no longer runs. Arm/exit the
// body turn state here from the replicated turn (replMppr->turnDemand, from the
// replicated yaw rate + hysteresis above): standing + turning -> state 4 (body
// case 4 then advances the trn clip -> the peer STEPS through the pivot); turn
// stops -> back to Standing. Walk/reverse transitions still own their exits.
if (!s_peerLegCh)
{
const int bs = (int)bodyStateAlarm.GetLevel();
const bool turning = (replMppr->turnDemand > 0.05f
|| replMppr->turnDemand < -0.05f);
if (turning && bs == 0) // standing + turning -> enter turn
SetBodyAnimation(4);
else if (!turning && bs == 4) // turn stopped -> back to stand
SetBodyAnimation(0);
}
const Scalar replLegAdv = s_peerLegCh
? AdvanceLegAnimation(dt) // old: re-derived leg SM
: AdvanceBodyAnimation(dt, 1); // authentic: body channel, mj=1 poses skeleton
// AUTHENTIC coupled position (mirror of the master's world-step at
// mech4.cpp:3325-3336 == Mech::IntegrateMotion tail @004ab1c8): between
// records the body advances by the CLIP'S OWN travel rotated by the
// heading -- feet and ground are locked BY CONSTRUCTION, so cadence-vs-
// travel mismatch (the speed-change glitch) is structurally impossible.
// A pose record re-anchors onto the authoritative position
// (poseSyncLatch, armed by every type-0 read). adv is SIGNED by the SM
// (reverse clips return negative travel; master dir==1.0 in SM mode).
if (!s_drPos)
{
// POSE-SYNC ERROR-PROPORTIONAL ABSORPTION: track the error to the
// received authority (updateOrigin) and bleed it into localOrigin at a
// rate that scales with the error, so a small steady offset is absorbed
// gently (no foot-pop) while a large speed-change drift is caught fast.
// The residual snap came from two sources: (a) a fixed slow ~1/3s decay
// let drift reach 3-11u during accel/decel; (b) a hard ground snap popped
// because records are not perfectly dense on this relay. Error-scaled
// absorption is the stable middle: k grows from ~0.15/frame at small
// error toward ~0.6 at large error. (Records re-measure the error every
// frame they arrive, so this converges to authority within a few frames.)
{
Vector3D rmErr;
rmErr.Subtract(updateOrigin.linearPosition, localOrigin.linearPosition);
poseSyncLatch = 0;
const float eMag = (float)rmErr.Length();
float k = 0.15f + eMag * 0.15f; // error-proportional catch-up
if (k > 0.6f) k = 0.6f;
// DIAG (BT_SNAPLOG, task #50): the peer drift = |authority - dead-reckon|
// absorbed this frame -- the "snap" magnitude. Log > 0.5u so an accel/decel
// soak (BT_DRIVE_SWEEP0) measures the residual.
static const int s_snapLog = getenv("BT_SNAPLOG") ? 1 : 0;
if (s_snapLog && eMag > 0.5f)
DEBUG_STREAM << "[snap] drift=" << eMag << " k=" << k
<< " legAdv=" << replLegAdv << "\n" << std::flush;
// AT-REST snap (user "gliding stops"): when the legs have wound down
// (replLegAdv ~0), gliding the body to absorb the residual offset is
// VISIBLE because no leg motion masks it. With the feet planted a
// sub-unit correction is invisible, so resolve it immediately instead
// of gliding it in over several frames. Only when the offset is small
// (a large offset at rest = a real teleport that should still ease).
const Logical atRest = ((replLegAdv < 0.05f && replLegAdv > -0.05f)
&& eMag < 1.0f);
if (atRest) k = 1.0f;
localOrigin.linearPosition.x += rmErr.x * k;
localOrigin.linearPosition.y += rmErr.y * k;
localOrigin.linearPosition.z += rmErr.z * k;
}
if (replLegAdv != 0.0f)
{
Vector3D rmLocal(0.0f, 0.0f, -replLegAdv); // forward = -Z, per-frame DISTANCE
Matrix34 rmOrient;
Matrix34::FromQuaternion(&rmOrient, &localOrigin.angularPosition);
Vector3D rmWorld;
FUN_00408744(&rmWorld, (Scalar *)&rmLocal, &rmOrient);
localOrigin.linearPosition.x += rmWorld.x;
localOrigin.linearPosition.y += rmWorld.y;
localOrigin.linearPosition.z += rmWorld.z;
}
localToWorld = localOrigin; // refresh with the coupled position
}
// SLIDE probe (BT_SLIDE): per-frame, log when the peer position MOVES
// while its legs are in STAND (legState 0) -- the user's "sliding after
// legs stopped". Reports move magnitude, offset to authority, leg state.
if (getenv("BT_SLIDE"))
{
static Point3D s_slPrev; static int s_slInit = 0;
const int ls = getenv("BT_PEER_LEGCH") ? (int)legStateAlarm.GetLevel() : (int)bodyStateAlarm.GetLevel();
if (s_slInit)
{
const float dx = (float)(localOrigin.linearPosition.x - s_slPrev.x);
const float dz = (float)(localOrigin.linearPosition.z - s_slPrev.z);
const float mv = sqrtf(dx * dx + dz * dz);
if (ls == 0 && mv > 0.02f) // standing but moving = slide
{
Vector3D off; off.Subtract(updateOrigin.linearPosition, localOrigin.linearPosition);
DEBUG_STREAM << "[slide] move=" << mv
<< " legAdv=" << replLegAdv
<< " offToAuth=" << (float)off.Length()
<< " legState=" << ls << "\n" << std::flush;
}
}
s_slPrev = localOrigin.linearPosition; s_slInit = 1;
}
// PEER-DRIFT probe (BT_MIRDIV, 1s max): how far the peer's coupled
// position wanders from the last-received authority (updateOrigin)
// before a record re-anchors it -- the other end of the snap.
if (getenv("BT_MIRDIV"))
{
static float s_pdAcc = 0.0f, s_pdMax = 0.0f;
Vector3D d; d.Subtract(localOrigin.linearPosition, updateOrigin.linearPosition);
const float dl = (float)d.Length();
if (dl > s_pdMax) s_pdMax = dl;
s_pdAcc += dt;
if (s_pdAcc >= 1.0f)
{
DEBUG_STREAM << "[peerdrift] maxDriftFromAuthority=" << s_pdMax
<< " replLegAdv=" << replLegAdv << "\n" << std::flush;
s_pdAcc = 0.0f; s_pdMax = 0.0f;
}
}
// GAIT-EVENNESS probe (BT_GAITEV, 1s stats): the metric of the VISIBLE
// speed-change glitch -- per-frame leg-clip advance regularity + leg-state
// transition (flap) rate on the peer. Position dead-reckons smoothly
// regardless; the churn shows in the LEGS.
if (getenv("BT_GAITEV"))
{
static float s_gvPrevF = -1.0f, s_gvAcc = 0.0f, s_gvSum = 0.0f, s_gvMax = 0.0f;
static int s_gvN = 0, s_gvBack = 0, s_gvTrans = 0, s_gvPrevSt = -1;
static float s_gvDemPrev = 0.0f; static int s_gvDemChg = 0;
const int _pb = getenv("BT_PEER_LEGCH") ? 1 : 0;
const float gf = _pb ? (float)legAnimation.currentFrame : (float)bodyAnimation.currentFrame;
const int gs = _pb ? (int)legStateAlarm.GetLevel() : (int)bodyStateAlarm.GetLevel();
if (s_gvPrevF >= 0.0f)
{
float df = gf - s_gvPrevF;
if (df < -5.0f) df += 20.0f; // clip wrap heal (approx)
const float adf = (df < 0.0f) ? -df : df;
s_gvSum += adf; if (adf > s_gvMax) s_gvMax = adf;
if (df < 0.0f) s_gvBack++;
s_gvN++;
}
if (s_gvPrevSt >= 0 && gs != s_gvPrevSt) s_gvTrans++;
if ((float)bodyTargetSpeed != s_gvDemPrev) { s_gvDemChg++; s_gvDemPrev = (float)bodyTargetSpeed; }
s_gvPrevF = gf; s_gvPrevSt = gs; s_gvAcc += dt;
if (s_gvAcc >= 1.0f && s_gvN > 0)
{
DEBUG_STREAM << "[gaitev] frmAvg=" << (s_gvSum / s_gvN)
<< " frmMax=" << s_gvMax
<< " back=" << s_gvBack << "/" << s_gvN
<< " stateFlips/s=" << s_gvTrans
<< " demChanges/s=" << s_gvDemChg
<< " state=" << gs << "\n" << std::flush;
s_gvAcc = 0.0f; s_gvSum = 0.0f; s_gvMax = 0.0f; s_gvN = 0; s_gvBack = 0; s_gvTrans = 0; s_gvDemChg = 0;
}
}
// TURN-STEP rate probe (BT_TRNRATE, 0.5s): body rotation rate vs trn clip advance.
if (getenv("BT_TRNRATE"))
{
static bool s_tinit = false;
static float s_tYaw = 0.0f, s_tFrm = 0.0f, s_tAcc = 0.0f, s_tRot = 0.0f, s_tAdv = 0.0f;
YawPitchRoll _typ; _typ = localOrigin.angularPosition;
const float yaw = (float)_typ.yaw, frm = (float)legAnimation.currentFrame;
if (s_tinit)
{
float dy = yaw - s_tYaw; if (dy > 3.14159f) dy -= 6.28319f; if (dy < -3.14159f) dy += 6.28319f;
s_tRot += (dy < 0.0f) ? -dy : dy;
const float df = frm - s_tFrm;
if (df > 0.0f) s_tAdv += df;
}
s_tYaw = yaw; s_tFrm = frm; s_tinit = true;
s_tAcc += dt;
if (s_tAcc >= 0.5f)
{
DEBUG_STREAM << "[trnrate] bodyRot/s=" << (s_tRot / s_tAcc) << "rad"
<< " legFrm/s=" << (s_tAdv / s_tAcc)
<< " legState=" << (int)legStateAlarm.GetLevel()
<< " turnDemand=" << MappingMapper()->turnDemand << "\n" << std::flush;
s_tAcc = 0.0f; s_tRot = 0.0f; s_tAdv = 0.0f;
}
}
// Cadence-vs-travel sync probe (0.25s, ~4 flush/s -- cheap). Compares
// the leg CADENCE input (bodyTargetSpeed / legCycleSpeed) against the
// ACTUAL dead-reckoned ground speed; if the legs are in sync the two
// track together and legFrm advances monotonically.
if (getenv("BT_ANIM"))
{
static bool s_ainit = false;
static Point3D s_aprev;
static float s_aacc = 0.0f, s_apath = 0.0f;
if (s_ainit)
{
const float dx = (float)(localOrigin.linearPosition.x - s_aprev.x);
const float dz = (float)(localOrigin.linearPosition.z - s_aprev.z);
s_apath += sqrtf(dx * dx + dz * dz);
}
s_aprev = localOrigin.linearPosition; s_ainit = true;
s_aacc += dt;
if (s_aacc >= 0.25f)
{
DEBUG_STREAM << "[anim] cmd=" << bodyTargetSpeed
<< " drSpeed=" << (s_apath / s_aacc)
<< " legCyc=" << legCycleSpeed
<< " legState=" << (int)legStateAlarm.GetLevel()
<< " legFrm=" << legAnimation.currentFrame
<< "\n" << std::flush;
s_aacc = 0.0f; s_apath = 0.0f;
}
}
if (getenv("BT_REPL_TRN"))
{
static float s_rt = 0.0f; s_rt += dt;
if (s_rt >= 0.15f)
{
s_rt = 0.0f;
DEBUG_STREAM << "[repltrn] yawRate=" << yawRate
<< " turnDemand=" << replMppr->turnDemand
<< " legState=" << (int)legStateAlarm.GetLevel()
<< " bodyState=" << (int)bodyStateAlarm.GetLevel()
<< " legFrm=" << legAnimation.currentFrame
<< " bodyFrm=" << bodyAnimation.currentFrame
<< " spd=" << replMppr->speedDemand
<< " tgt=" << bodyTargetSpeed << "\n" << std::flush;
}
}
// PER-FRAME heading trace (BT_REPL_HDG, choppy-spin diagnosis): the
// replicant's rendered yaw each frame + dt. A smooth dead-reckoned
// spin advances ~yawRate*dt every frame; choppiness shows as heading
// STALLS followed by SNAPS (records arriving without integration
// between them).
if (getenv("BT_REPL_HDG") && yawRate != 0.0f)
{
YawPitchRoll rh, ph, uh;
rh = localOrigin.angularPosition;
ph = projectedOrigin.angularPosition;
uh = updateOrigin.angularPosition;
DEBUG_STREAM << "[replhdg] yaw=" << (Scalar)rh.yaw
<< " proj=" << (Scalar)ph.yaw
<< " upd=" << (Scalar)uh.yaw
<< " lp-lu=" << (lastPerformance.ticks - lastUpdate.ticks)
<< " nu-lp=" << (nextUpdate.ticks - lastPerformance.ticks)
<< " yawRate=" << yawRate << " dt=" << dt << "\n" << std::flush;
}
// PER-FRAME travel/animation coupling probe (BT_REPL_MOV, user:
// "slides forward before it steps"): position vs the dead-reckon target
// vs the derived speed vs the leg SM state, every frame there is
// replicated motion. Reveals travel-vs-animation lag + lerp-gap jitter.
if (getenv("BT_REPL_MOV"))
{
const Scalar uvx = (Scalar)updateVelocity.linearMotion.x;
const Scalar uvz = (Scalar)updateVelocity.linearMotion.z;
const Scalar umag = (Scalar)sqrtf(uvx*uvx + uvz*uvz);
if (umag > 0.01f || replMppr->speedDemand != 0.0f)
DEBUG_STREAM << "[replmov]"
<< " pos=(" << localOrigin.linearPosition.x << ","
<< localOrigin.linearPosition.z << ")"
<< " proj=(" << projectedOrigin.linearPosition.x << ","
<< projectedOrigin.linearPosition.z << ")"
<< " uvel=" << umag
<< " spd=" << replMppr->speedDemand
<< " legState=" << (int)legStateAlarm.GetLevel()
<< " legFrm=" << legAnimation.currentFrame
<< " standSp=" << standSpeed
<< " walkStr=" << walkStrideLength
<< " nu-lp=" << (nextUpdate.ticks - lastPerformance.ticks)
<< " dt=" << dt << "\n" << std::flush;
}
// 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;
// print host:local -- the (long) cast collapsed distinct
// hosts' mechs onto one number (EntityID::operator int
// returns localID only) and faked "missing replicants"
// during the first 4-pod session analysis (2026-07-18)
DEBUG_STREAM << "[repl] mech " << 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);
}
// SPEC AUDIT (BT_SPEC_LOG): once, after all subsystems are built + linked,
// dump each subsystem -> coolant loop (BTCoolingLoopFrame resolves the
// linked condenser's number, the manual's "Loop N"). Diffs the per-mech
// COOLANT LOOPS table vs the 1995 manual (structural, less tuning-prone).
if (isPlayerMech && getenv("BT_SPEC_LOG"))
{
static int s_specDumped = 0;
if (!s_specDumped && dt > 0.0001f)
{
s_specDumped = 1;
extern int BTCoolingLoopFrame(void *subsystem);
for (int i = 0; i < GetSubsystemCount(); ++i)
{
Subsystem *s = GetSubsystem(i);
if (s == 0) continue;
DEBUG_STREAM << "[spec] loop " << BTCoolingLoopFrame(s)
<< " <- " << (s->GetName() ? s->GetName() : "?")
<< "\n" << std::flush;
}
}
}
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 input 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 the binding engine polls GetAsyncKeyState (+ XInput); the
// foreground guard (BT_KEY_NOFOCUS override) lives inside it.
{
{
// INPUT REMAP (2026-07-18): the raw GetAsyncKeyState reads
// moved behind the binding engine (btinput.cpp) -- keys +
// XInput pad resolve through content/CONTROLS.MAP (built-in
// WASD-classic profile when the file is absent). RIO lamp
// buttons / keypads / authentic pckey hotkeys are emitted
// into the controls manager inside the poll; the fire
// channels, actions, and analog demands publish through
// gBTInput. Everything below the reads (the lever/stick/
// twist integrators, detents, edge latches) is UNCHANGED.
BTInputPoll(dt);
gBTDrive.keyFwd = gBTInput.leverRate > 0.0001f ? 1 : 0;
gBTDrive.keyBack = gBTInput.leverRate < -0.0001f ? 1 : 0;
gBTDrive.keyLeft = (gBTInput.turnActive && gBTInput.turnTarget < 0.0f) ? 1 : 0;
gBTDrive.keyRight = (gBTInput.turnActive && gBTInput.turnTarget > 0.0f) ? 1 : 0;
// WEAPON GROUPS (task #43): the four pod joystick fire
// buttons -> the bring-up fire channels (0x40 Main = laser
// group, 0x46 ThumbLow = PPCs, 0x47 ThumbHigh = missiles,
// 0x45 Pinky = the 4th group). (Interim; the authentic
// system is the ConfigureMappables/ChooseButton mapper.)
gBTLaserKey = gBTInput.fireTrigger;
gBTPPCKey = gBTInput.fireThumbLow;
gBTMissileKey = gBTInput.fireThumbHigh;
gBTPinkyKey = gBTInput.firePinky;
// task #6: HOLD the ConfigHold action opens the config
// session on the selected weapon; while held, the fire
// keys TOGGLE its group membership.
gBTConfigKey = gBTInput.configHold;
// task #12: Generator1-4 actions assign the selected weapon
// to Generator A..D; Reconnect toggles Manual/Auto.
gBTGenSelKey = gBTInput.genSel;
// task #13: the Valve action cycles the coolant valve.
gBTValveKey = gBTInput.valve;
// Gitea #7: the Flush action HELD = the coolant MFD's
// top-right button (manual p24) -- InjectCoolant while
// down, stop on release.
gBTFlushKey = gBTInput.flush;
// TORSO CONTROLS (2026-07-13): 'M' cycles the control mode
// (Basic -> Standard -> Veteran -- the pod console button,
// CycleControlModeMessageHandler); Q/E deflect the torso
// twist axis (the STICK in Standard/Veteran, where A/D
// become the pedals). Ramped like the turn stick.
{
static int sPrevM = 0;
const int mNow = gBTInput.modeCycle;
if (mNow && !sPrevM) gBTModeCycle = 1; // edge -> one cycle
sPrevM = mNow;
// Gitea #6: 'N' cycles the secondary screen's schematic
// (Damage -> Critical -> Heat) -- the pod's status-info-
// center button (manual p13), CycleDisplayModeNow.
{
static int sPrevN = 0;
const int nNow = gBTInput.displayCycle;
if (nNow && !sPrevN) gBTDisplayCycle = 1;
sPrevN = nNow;
}
// Gitea #9: J/K/L cycle an upper-MFD preset page
// (Quad -> populated Eng pages -> Quad) -- the desktop
// sender for the pod's per-MFD page-button banks
// (SetPresetMode, consumed by the L4 mapper).
{
static int sPrevMfd[3] = {0,0,0};
for (int mg = 0; mg < 3; ++mg)
{
const int gNow = gBTInput.mfdCycle[mg];
if (gNow && !sPrevMfd[mg]) gBTPresetCycle[mg] = 1;
sPrevMfd[mg] = gNow;
}
}
// (task #68) LookBehind action HELD = the pod's rear-view
// button (releases back to the forward view). Rear-mounted
// weapons (blackhawk/owens back racks) fire only in it.
gBTLookBehind = gBTInput.lookBehind;
static float sTwist = 0.0f;
if (gBTInput.twistAbsolute)
{
// pad stick: the axis IS the stick position (the
// spring is in the physical stick, not software)
sTwist = gBTInput.twistTarget;
}
else if (gBTInput.twistActive)
{
sTwist += gBTInput.twistTarget * kStickRate * dt;
if (sTwist > 1.0f) sTwist = 1.0f;
if (sTwist < -1.0f) sTwist = -1.0f;
}
else if (sTwist != 0.0f)
{
// SPRING-CENTER on release (user fix 2026-07-13: the
// old hold-deflection model left a residual axis ->
// the torso drifted forever with no way to stop).
// The pod stick is spring-centered: the axis is a
// twist-RATE demand, so release = rate 0 = the torso
// HOLDS where you aimed it (position is kept by
// Torso::currentTwist, not by the axis). Same model
// as the A/D turn stick (kStickCenterRate).
const float step = kStickCenterRate * dt;
if (sTwist > step) sTwist -= step;
else if (sTwist < -step) sTwist += step;
else sTwist = 0.0f;
}
// X = all-stop for the torso too: zero the axis NOW and
// pulse the AUTHENTIC recenter (torso centerCommand ->
// Recenter @004b6918 slews currentTwist back to 0; any
// new Q/E deflection cancels it, sim-side). Separate
// edge detector from the drive all-stop below -- both
// fire on the same press.
static int sPrevXT = 0;
const int xtNow = gBTInput.allStop;
if (xtNow && !sPrevXT)
{
sTwist = 0.0f;
gBTTorsoRecenter = 1; // mapper consumes -> CommandRecenter()
{
extern int gBTElevRecenter;
gBTElevRecenter = 1; // pitch axis zeroes below
}
}
sPrevXT = xtNow;
gBTTwistAxis = sTwist;
// TORSO ELEVATION (pitch aim -- the pod stick's Y
// axis; every 1995 control mode routes it to
// Torso::SetAnalogElevationAxis). Same integrator
// model as the twist: keys walk the axis, release
// springs the RATE to 0 (position holds, the sim's
// vertical limits clamp); a pad stick is absolute.
{
extern float gBTElevAxis;
static float sElev = 0.0f;
if (gBTInput.elevAbsolute)
{
sElev = gBTInput.elevTarget;
}
else if (gBTInput.elevActive)
{
sElev += gBTInput.elevTarget * kStickRate * dt;
if (sElev > 1.0f) sElev = 1.0f;
if (sElev < -1.0f) sElev = -1.0f;
}
else if (sElev != 0.0f)
{
const float estep = kStickCenterRate * dt;
if (sElev > estep) sElev -= estep;
else if (sElev < -estep) sElev += estep;
else sElev = 0.0f;
}
extern int gBTElevRecenter; // set by the X edge above
if (gBTElevRecenter)
{
gBTElevRecenter = 0;
sElev = 0.0f; // X recenters pitch too
}
gBTElevAxis = sElev;
// DIAG (BT_FORCE_ELEV=<-1..1>): pin the elevation
// axis for headless pitch verification (no focus).
{
static float s_fe = -2.0f;
if (s_fe < -1.5f)
{
const char *fe = getenv("BT_FORCE_ELEV");
s_fe = fe ? (float)atof(fe) : -3.0f;
}
if (s_fe >= -1.0f)
gBTElevAxis = s_fe;
}
if (getenv("BT_INPUT_LOG"))
{
static float s_eacc = 0.0f; s_eacc += dt;
if (s_eacc >= 1.0f) { s_eacc = 0.0f;
DEBUG_STREAM << "[input] elev tgt="
<< gBTInput.elevTarget << " act="
<< gBTInput.elevActive << " axis="
<< sElev << "\n" << std::flush; }
}
}
}
// gBTDrive.fire = "any weapon trigger down" (feeds the bring-up
// damage dispatcher + the beam-visual keepalive)
gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey || gBTPinkyKey) ? 1 : 0;
static int sPrevX = 0;
const int xNow = gBTInput.allStop;
if (xNow && !sPrevX) gBTDrive.allStop = 1; // edge -> one all-stop
sPrevX = xNow;
// ViewToggle action: the authentic COCKPIT eyepoint (the
// pod's only view) <-> the external chase camera. Split
// from LookBehind (2026-07-18): the old shim had BOTH on
// 'V', so holding rear-view also flipped the camera.
static int sPrevV = 0, sViewInside = 0;
const int vNow = gBTInput.viewToggle;
if (vNow && !sPrevV)
{
sViewInside = !sViewInside;
extern void BTSetViewInside(int inside);
BTSetViewInside(sViewInside);
}
sPrevV = vNow;
#ifdef BT_GLASS
// The glass backtick toggle (PadRIO edge; the V action
// stands down with the binding engine in glass mode).
{
extern int BTPadViewToggleEdge(void);
while (BTPadViewToggleEdge())
{
sViewInside = !sViewInside;
extern void BTSetViewInside(int inside);
BTSetViewInside(sViewInside);
}
}
#endif
// RETICLE = SCREEN CENTER (task #58 correction, supersedes the
// task #39 "body-mounted view" model): the CAMERA yaws with
// the torso (the cockpit sits on jointtorso -- see the
// gBTEyeTwist publisher), so screen center always IS the gun
// boresight and the crosshair stays centered while the WORLD
// rotates past it -- the twist reads on the bottom tape
// carets / compass / radar wedge, not the crosshair. [T1:
// jointtorso->jointeye->siteeyepoint chain + FUN_004c22c4
// inverse-chain view; crosshair-twist forensics 2026-07-13.]
// The old gBTAimX = tan(twist) slew was a port invention on
// the falsified body-mounted premise -- with the yawing eye
// it double-counts (crosshair pinned to HULL-forward, fire
// ray resolving body-forward: "twisting leaves the
// crosshairs behind"). Reticle mobility exists in the
// binary (Reticle::reticlePosition, RETICLE.h:42) but its
// twist-era use is the FIXED-TORSO free-aim channel
// (mech+0x36c, writer un-exported) -- deferred.
// 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 = screen center: the eye carries the
// twist (task #58), the pick ray inherits it from
// the published eye basis.
gBTAimX = 0.0f;
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;
// task #64 harness: BT_WALK_DELAY holds the FORCED throttle (the
// mapper's actual input -- the local `throttle` gate below never
// reached it) at 0 for n sim-secs (turn-in-place phase, with
// BT_FORCE_TURN active), then ramps it over 1.2s -- the manual
// "lever dwell through the walk threshold" repro.
{
static float s_wdD = -1.0f, s_wdC = 0.0f;
if (s_wdD < -0.5f)
{
const char *v = getenv("BT_WALK_DELAY");
s_wdD = v ? (float)atof(v) : 0.0f;
}
if (s_wdD > 0.0f)
{
s_wdC += dt;
float r = (s_wdC - s_wdD) / 1.2f;
gBTDrive.forcedThrottle = (r <= 0.0f) ? 0.0f
: (r < 1.0f ? sAutoDrive * r : sAutoDrive);
}
}
// BT_DRIVE_SWEEP=<period>: triangle-sweep the FORCED throttle
// 0.2..0.9 -- sweeps the ACTUAL drive velocity AND (via mechmppr's
// forced key_throttle) the mapper demand together, the true
// keyboard accel/decel regime. BT_FORCE_STEP=1 quantizes it to
// the RIO Ranger 0.05 grid (the authentic stepped input shape).
{
static float s_dsP = -1.0f, s_dsClock = 0.0f;
static int s_dsStep = -1;
if (s_dsP < 0.0f)
{
const char *v = getenv("BT_DRIVE_SWEEP");
s_dsP = v ? (float)atof(v) : 0.0f;
s_dsStep = getenv("BT_FORCE_STEP") ? 1 : 0;
}
if (s_dsP > 0.0f)
{
s_dsClock += dt;
float ph = fmodf(s_dsClock, 2.0f * s_dsP) / s_dsP;
float tri = (ph < 1.0f) ? ph : (2.0f - ph);
// BT_DRIVE_SWEEP0=1: sweep 0.0..0.7 (through STOP each cycle -- the
// stand<->walk gauntlet: walk-entry, came-to-rest sends, type-3 stomps).
static int s_dsZero = -1;
if (s_dsZero < 0) s_dsZero = getenv("BT_DRIVE_SWEEP0") ? 1 : 0;
float thr = s_dsZero ? (0.7f * tri) : (0.2f + 0.7f * tri);
if (s_dsStep)
thr = floorf(thr / 0.05f + 0.5f) * 0.05f;
gBTDrive.forcedThrottle = thr;
}
}
}
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
// GAIT DETENT (keyboard accommodation, 2026-07-16): the gait SM has NO
// stable state for a demand between the walk cycle's cap
// (walkStrideLength @0x534) and the run cycle's engage speed
// (reverseSpeedMax @0x538 = the walk->run transition clip's exit
// speed). A demand PARKED in that band hunts walk -> shift-up ->
// shift-down forever, firing the authored EngineShiftFwd/Rev sounds
// each swing (binary-verified: the BodyClipFinished thresholds at
// 0x4a6f11/0x4a6fc7 + the continuous FUN_004afd10 demand formula
// reproduce the hunt with all-authentic data). The pod's PHYSICAL
// throttle lever plausibly rested only at mechanical notches -- the
// software path is notch-free (pod-hardware.md), so the notches, if
// real, lived in the quadrant hardware [T4]. Reproduce that feel:
// when the keys REST, snap the lever out of the dead band to the
// nearer edge. Sweeping THROUGH the band while a key is held stays
// continuous -- that is an authentic moving lever, and the one
// transition it triggers is the authentic shift.
if (!fwd && !back && sLever > 0.0f
&& walkStrideLength > 0.0f
&& reverseSpeedMax > walkStrideLength
&& reverseStrideLength >= reverseSpeedMax)
{
const float max_demand = reverseStrideLength * forwardThrottleScale;
if (max_demand > 0.0f)
{
const float band_lo = walkStrideLength / max_demand;
// margin: the run cont-check is tgt >= cap, so land the demand
// a hair ABOVE the engage speed against float rounding
const float band_hi = reverseSpeedMax / max_demand + 0.002f;
if (sLever > band_lo && sLever < band_hi)
{
const float snapped =
(sLever - band_lo < (band_hi - band_lo) * 0.5f) ? band_lo : band_hi;
{ static int s_dLog = 0; if (getenv("BT_GAIT_TRACE") && s_dLog++ < 40)
DEBUG_STREAM << "[gaitdetent] lever " << sLever << " in dead band ["
<< band_lo << "," << band_hi << ") -> " << snapped << "\n" << std::flush; }
sLever = snapped;
}
}
}
// the configured rate (CONTROLS.MAP `axis Throttle rate n`) IS the
// sweep speed; kLeverRate above stays as the documented default
float sweep = gBTInput.leverRate * 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 = gBTInput.turnActive ? gBTInput.turnTarget : 0.0f;
if (gBTInput.turnAbsolute)
{
// pad stick: the axis IS the stick position (physically sprung)
sStick = want;
}
else 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;
}
// RIO-GRID PUBLISH (authentic input shaping; decomp workflow w0odszxro):
// the pod's analog lever/stick reached the mapper through the RIO Ranger
// (800 ADC counts, 0.05 deadband -- L4RIO.cpp:801), so the DEMAND the 1995
// machinery consumed moved in coarse, settled STEPS with silence between
// them -- one type-2 speed record per step, and each gait threshold
// (standSpeed etc., zero hysteresis) crossed ONCE per maneuver. Our
// keyboard integrator sweeps CONTINUOUSLY (kLeverRate every frame of a
// key-hold): ~86 records per accel, demand DWELLING on the knife edges ->
// transition-clip churn -- the speed-change peer glitch. Publish on the
// Ranger's own 0.05 grid: internal lever feel unchanged, published demand
// steps like the real hardware (a bucket holds ~4 frames at kLeverRate, so
// the send gate self-closes between steps). Also kills -epsilon reverse
// publishes from rest (bucket rounds to 0 until the lever passes -0.025;
// authentic reverse is a BUTTON sign-flip, never a sweep through zero).
// DEFAULT: CONTINUOUS publish (authentic -- the pod lever was analog,
// 800-count ADC ~= continuous; empirical A/B found NO gait/position
// difference between swept and stepped demand after the clock-guard +
// heading-integrator fixes, so the grid gains nothing and makes the
// DRIVE step unauthentically). BT_GRID_LEVER=1 keeps the 0.05-grid
// variant for experiments.
static const int s_gridLever = getenv("BT_GRID_LEVER") ? 1 : 0;
if (s_gridLever)
{
gBTDrive.throttle = floorf(sLever / 0.05f + 0.5f) * 0.05f;
gBTDrive.turn = floorf(sStick / 0.05f + 0.5f) * 0.05f;
}
else
{
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 = gBTPinkyKey = 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;
// FIX (user-reported: teleport embeds the mech in sloped terrain):
// BT_SPAWN_AT set only X/Z, keeping the OLD (dropzone) Y -- wrong for
// the new ground height. Lift the mech WELL ABOVE local terrain so the
// authentic per-frame ground SNAP (BoxTree FindBoundingBoxUnder, no
// gravity) settles it onto the surface next frame. The snap only
// lowers, so it must start above the surface, not below.
localOrigin.linearPosition.y += 500.0f;
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;
// Publish the forced turn to gBTDrive too: mechmppr derives turnDemand
// from gBTDrive.turn (NOT this local), so without this the headless rig
// never entered the trn state -- BT_FORCE_TURN was silently inert for
// the gait (task #64 harness fix).
gBTDrive.turn = s_forceTurn;
// BT_WALK_DELAY=<n> (stutter repro, task #64): hold throttle at 0 for the
// first n sim-seconds (TURN-IN-PLACE, turn stays active) then release the
// forced throttle -- reproduces "turn in place, then start walking BEFORE
// the turn stops" exactly. Turn (BT_FORCE_TURN) persists across the seam.
static float s_walkDelay = -1.0f, s_wdClock = 0.0f;
if (s_walkDelay < -0.5f)
{
const char *wd = getenv("BT_WALK_DELAY");
s_walkDelay = wd ? (float)atof(wd) : 0.0f;
}
if (s_walkDelay > 0.0f)
{
s_wdClock += dt;
if (s_wdClock < s_walkDelay)
throttle = 0.0f; // turn-in-place phase
else
{
// RAMP the throttle up over 1.2s (simulate the manual lever
// sweep DWELLING through standSpeed) with the turn still active
// -- the continuous-stutter repro.
float r = (s_wdClock - s_walkDelay) / 1.2f;
if (r < 1.0f) throttle *= r;
}
}
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 << ")" << std::endl; }
}
// 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)
MechControlsMapper *mppr = MappingMapper(); // roster slot 0 (task #7)
if (s_realControls && mppr != 0)
{
// STAND-DOWN (glass-cockpit step 2c): with a LIVE cockpit device
// (serial RIO on the pod, PadRIO on a glass build) the engine
// controls push owns these attributes via the streamed .CTL
// bindings -- the keyboard writes below would clobber the device
// every frame. BT_KEY_BRIDGE: unset = auto (bridge active only
// with no device), "0" = force off, anything else = force on.
extern int BTRIODevicePresent(void);
static const char *s_kbEnv = getenv("BT_KEY_BRIDGE");
int bridge_writes = s_kbEnv ? (*s_kbEnv != '0')
: (gBTDrive.forced || !BTRIODevicePresent());
// 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 = mppr->throttlePosition;
(void)preThrottle;
if (bridge_writes)
{
mppr->throttlePosition = (throttle >= 0.0f) ? throttle : -throttle;
mppr->reverseThrust = (throttle < 0.0f) ? 1 : 0; // ControlsButton: >=1 engaged
mppr->stickPosition.x = turn; // Basic mode: turn = stick yaw
mppr->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 = mppr->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=" << mppr->throttlePosition
<< " pre=" << preThrottle
<< " rev=" << mppr->reverseThrust
<< " stickX=" << mppr->stickPosition.x
<< " stickY=" << mppr->stickPosition.y
<< " -> speedDemand=" << mppr->speedDemand
<< " turnDemand=" << mppr->turnDemand
<< " mode=" << mppr->controlMode
<< " mapper=" << (void*)mppr
<< " &mode=" << (void*)&mppr->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; }
// --- AUTHENTIC turn rate (master-perf disasm 0x4aa3d3-0x4aa4ff [T1]) ---------
// The yaw rate is per-mech lerp(walkingTurnRate, runningTurnRate) by GROUND
// SPEED, not the old bring-up constant kDriveTurnRate. spd = horizontal speed
// (last frame's worldLinearVelocity); base = walkingTurnRate; above the low
// gate (reverseSpeedMax@0x538) lerp walk->run across [walkStride 0x534 ..
// topSpeed 0x34c], with an over-run falloff runningTurnRate/t^2 past topSpeed;
// clamp >=0. angularVelocity = turnDemand * turnRate, ZEROED in the death/
// limbo/airborne leg states (1/2/3) or when the death-anim latch is set.
// Falls back to the bring-up constant if the model supplied no rates.
Scalar authTurnRate = kDriveTurnRate;
if (walkingTurnRate > 0.0f)
{
const Scalar spd = (Scalar)sqrtf(worldLinearVelocity.x * worldLinearVelocity.x
+ worldLinearVelocity.z * worldLinearVelocity.z);
authTurnRate = walkingTurnRate; // base (walk / idle / turn-in-place)
if (spd >= reverseSpeedMax) // 0x538 low gate
{
const Scalar den = reverseStrideLength - walkStrideLength; // topSpeed - walkStride
const Scalar t = (den != 0.0f) ? (spd - walkStrideLength) / den : 0.0f;
if (t <= 1.0f)
authTurnRate = walkingTurnRate + (runningTurnRate - walkingTurnRate) * t; // walk->run
else
authTurnRate = runningTurnRate / (t * t); // over-run falloff
}
if (authTurnRate < 0.0f) authTurnRate = 0.0f;
const int ls = legAnimationState; // 0x3b0
if (deathAnimationLatched != 0 || ls == 1 || ls == 2 || ls == 3)
authTurnRate = 0.0f; // no yaw in death/limbo/airborne
}
if (getenv("BT_TURN_LOG"))
DEBUG_STREAM << "[turnrate] walkTR=" << walkingTurnRate
<< " runTR=" << runningTurnRate << " spd="
<< (Scalar)sqrtf(worldLinearVelocity.x*worldLinearVelocity.x
+ worldLinearVelocity.z*worldLinearVelocity.z)
<< " -> authTurnRate=" << authTurnRate
<< " (kDrive=" << kDriveTurnRate << ")\n" << std::flush;
// --- turn: integrate heading, write it onto the body orientation -----
gDriveHeading += turn * authTurnRate * 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 is now the AUTHENTIC per-mech
// turn rate (authTurnRate above) -- lerp(walkingTurnRate, runningTurnRate)
// by ground speed, master-perf 0x4aa3d3 [T1].
Vector3D angStep(0.0f, turn * authTurnRate * 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 authentic MaxAcceleration reads directly since the task #4
// record-layout fix (madcat: 30 u/s^2); the old floor-25 block
// and its one-shot log are retired.
if (!IsMechDestroyed()) // a dead mech keeps its death movementMode
SetMovementMode(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.
// BT_MASTER_GAITMIRROR: the invisible mj=0 body channel becomes the
// PEER's predictor, so it must slew toward the LAST-SENT commanded
// speed (which the record writers stamp into bodyTargetSpeed@0x6b4 on
// each send, mech.cpp), NOT the live mapper demand. Leaving
// bodyTargetSpeed at last-sent re-activates the authentic type-2
// deadband and makes the mirror model exactly what the peer computes.
// The master's VISIBLE motion uses the LEG channel reading live demand
// independently, so this does not change what the local player sees.
// (Default OFF: live overwrite preserved.)
static const int s_gaitMirror = getenv("BT_NO_MASTER_GAITMIRROR") ? 0 : 1; // default ON (authentic coupled)
if (s_gaitMirror)
{
/* leave bodyTargetSpeed at the last-sent value */
}
else if (s_realControls && MappingMapper() != 0)
bodyTargetSpeed = MappingMapper()->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.
// v5 ordered body-first/leg-last believing the leg's overwrite made
// body drift invisible -- WRONG (task #64): the leakage merely
// inverted (the body's pose flashed through instead). v6 (below)
// removes the body from the skeleton entirely (mj=0).
// task #64 RESOLUTION (user-verified): the body channel must NOT
// write joints. The v5 claim "body writes first, leg overwrites,
// so body drift can never show" was FALSE -- whenever the two
// channels phase-split (the turn-in-place entry armed only the
// leg), the body's out-of-phase pose leaked into the displayed
// skeleton: rhythmic leg skips + visibly reduced bob, 100% repro
// on turn-then-walk, while every leg-channel trace read clean
// (the leg data was fine -- the RENDERED pose wasn't the leg's).
// mj=0 advances + projects the body channel identically (records,
// cycle speeds, travel projection all unchanged -- same engine
// mode the replicant leg path uses) but structurally removes the
// second skeleton writer. This also matches the binary's own
// observable: "the body channel's phase drift is locally
// INVISIBLE in the binary" (locomotion.md) -- now it is here too.
// BT_BODY_MJ=1 restores the old double-writer for A/B.
static const int s_bodyMj = BTEnvOn("BT_BODY_MJ", 0);
adv = AdvanceBodyAnimation(dt, s_bodyMj); // channel B: replication projection (mj=0)
mirrorBodyAdv = adv; // stash for the send-mirror gait advance
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;
// BT_GAIT_TRACE=1 (task #64): log the gait state EVERY frame (not 1 Hz)
// so a frame-level stutter at a transition is visible.
static const int s_gtrace = getenv("BT_GAIT_TRACE") ? 1 : 0;
if (s_smlog >= 1.0f || s_gtrace) { 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;
MechControlsMapper *s_tm = MappingMapper(); // input demands (task #64)
DEBUG_STREAM << "[gaitSM] adv=" << adv << " legAdv=" << legAdv
<< " cycleSpeed=" << bodyCycleSpeed << " legCycle=" << legCycleSpeed
<< " state=" << bodyAnimationState << " legState=" << legAnimationState
<< " kfCur=" << bodyAnimation.currentFrame
<< " legFrm=" << legAnimation.currentFrame
<< " spd=" << (s_tm ? s_tm->speedDemand : 0.0f)
<< " turn=" << (s_tm ? s_tm->turnDemand : 0.0f)
<< " 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")
<< ")" << std::endl;
}
}
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;
// MASTER-SLIDE probe (BT_SLIDE): does the MASTER itself advance while its
// legs are in STAND (legState 0)? If yes -> authentic momentum coast, the
// peer correctly mirrors it. If the master travelAdv reaches 0 exactly
// when legState reaches 0 -> the master stops clean and the peer's stand-
// slide is a peer leg-SM-too-fast desync. Logs the decel profile.
if (getenv("BT_SLIDE"))
{
const int mls = (int)legStateAlarm.GetLevel();
const float ta = (float)(travelAdv < 0 ? -travelAdv : travelAdv);
if (ta > 0.005f) // master still moving
DEBUG_STREAM << "[mslide] travelAdv=" << travelAdv
<< " legState=" << mls
<< " legCycle=" << legCycleSpeed
<< " thr=" << throttle << "\n" << std::flush;
}
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 << ")" << std::endl;
}
}
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") << ")" << std::endl;
}
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 << ")" << std::endl;
}
}
} // 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);
// SMOOTH the PUBLISHED velocity (localVelocity feeds the audio speed
// triggers, the update records, and the impact gate -- NOT the physics
// worldLinearVelocity above). The raw per-frame derivative pulses per
// stride (gait acceleration + ground-snap bob): at walking speed the
// oscillation straddled the authored idle-hum band edge (speed 20) and
// Start/Stop-flapped the engine ambience on every footfall (heard as a
// static tick per step). ~0.25s exponential filter kills the stride
// ripple while tracking real speed changes within a couple of strides.
// AUTHENTIC [T1]: the binary's Mech::Execute (FUN_004ab430) runs the
// localVelocity components through AverageOf running-average filters
// (part_012.c:15169-15179) -- the original smoothed the published
// velocity the same way; this filter reconstructs that stage.
{
Scalar alpha = dt / (dt + 0.25f);
fwdSpeedFiltered += alpha * (fwdSpeed - fwdSpeedFiltered);
vertSpeedFiltered += alpha * (worldLinearVelocity.y - vertSpeedFiltered);
// (AUDIO_FIDELITY F19) publish localAcceleration EXACTLY as the
// binary does: FIVE 15-sample RING BUFFERS hold the RAW velocity
// components + dt (ctor part_012.c:9836-9840), and the accel is
// the frame-to-frame derivative of the ring MEAN over the mean
// dt (part_012.c:15169-15195). The ring mean keeps jittering
// as samples rotate, so the accel ATTRIBUTE changes on every
// watcher poll -- feeding the authored N=30 footstep smoother
// continuously (ctl100 |accel| [0,10] -> the per-stride kick on
// top of ctl101's 0.4 moving base). A first cut derived accel
// from the 0.25s-filtered velocity: its derivative goes
// epsilon-flat between strides, the watcher stopped firing, and
// the smoother took 10-20 s to warm from its fill=0 (the
// late-footsteps bug). NOTE: the RAW components feed only the
// rings; the PUBLISHED velocity keeps the 0.25s filter (a [T3]
// accommodation for our ground-snap ripple, see above).
// (the ring push itself lives in the PerformAndWatch tail so it
// provably runs EVERY frame -- this conditional path skips
// frames in some gait states, which starved the accel watcher)
}
localVelocity.linearMotion = Vector3D(0.0f, vertSpeedFiltered, -fwdSpeedFiltered);
localVelocity.angularMotion = Vector3D(0.0f, turn * authTurnRate, 0.0f);
// MASTER twin of [replmov]: the velocity this mech PUBLISHES vs its own
// leg state -- compare the master ramp to the replicant response.
if (getenv("BT_REPL_MOV") && (fwdSpeed > 0.01f || throttle != 0.0f))
DEBUG_STREAM << "[mastervel] fwdSpeed=" << fwdSpeed
<< " thr=" << throttle
<< " legState=" << (int)legStateAlarm.GetLevel()
<< " pos=(" << localOrigin.linearPosition.x << ","
<< localOrigin.linearPosition.z << ")"
<< " dt=" << dt << "\n" << std::flush;
// 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;
frameEntryWorldVelocity = worldLinearVelocity; // collision-damage guard (see mech.hpp)
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 << ")" << std::endl;
}
// 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 << ")" << std::endl;
}
}
}
}
// --- 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.)
// SEND-MIRROR advance: model where the PEER believes this mech is, so the
// deadband fires exactly when the peer would drift. Authentic (binary
// FUN_004a9b5c @0x4aab9c): advance projectedOrigin by IntegrateMotion(mj=0)
// -- the SAME gait predictor the peer runs -- fed the LAST-SENT commanded
// speed. Our default mirror uses the engine constant-velocity deadReckoner
// (AcceleratedDeadReckoner), which does NOT model a gait-driven peer -> the
// deadband under-sends and the coupled peer drifts (measured). With
// BT_MASTER_GAITMIRROR: increment projectedOrigin.linear by the mj=0 body
// channel's travel (mirrorBodyAdv, = the peer's predicted stride) rotated by
// the mirror heading, and integrate the heading by the last-sent angular
// velocity. Re-seeded to localOrigin on each send by the record writers'
// projectedOrigin re-base, so this is incremental-from-last-send, exactly the
// peer's IntegrateMotion. (Default OFF: constant-velocity deadReckoner.)
static const int s_gaitMirrorSend = getenv("BT_NO_MASTER_GAITMIRROR") ? 0 : 1; // default ON
if (s_gaitMirrorSend)
{
Vector3D mAng;
mAng.Multiply(updateVelocity.angularMotion, dt); // last-sent yaw rate
ReconQuatIntegrate(&projectedOrigin.angularPosition,
&projectedOrigin.angularPosition, &mAng);
projectedOrigin.angularPosition.Normalize();
if (mirrorBodyAdv != 0.0f)
{
Vector3D mLocal(0.0f, 0.0f, -mirrorBodyAdv); // forward = -Z, frame distance
Matrix34 mOrient;
Matrix34::FromQuaternion(&mOrient, &projectedOrigin.angularPosition);
Vector3D mWorld;
FUN_00408744(&mWorld, (Scalar *)&mLocal, &mOrient);
projectedOrigin.linearPosition.x += mWorld.x;
projectedOrigin.linearPosition.y += mWorld.y;
projectedOrigin.linearPosition.z += mWorld.z;
}
}
else
{
(this->*deadReckoner)();
}
Vector3D error;
error.Subtract(
projectedOrigin.linearPosition,
localOrigin.linearPosition);
// MIRROR-DIVERGENCE probe (BT_MIRDIV, 1s max): how far the master's
// send-mirror (projectedOrigin, body-channel gait) drifts from the true
// localOrigin (leg-channel gait) BEFORE a send fires. This is the source
// of the peer's residual snap -- quantifies the leg-vs-body channel shape
// mismatch. posDb (~0.55u) is the send threshold; anything the mirror
// over/under-shoots beyond it is the channel divergence.
if (getenv("BT_MIRDIV"))
{
static float s_mdAcc = 0.0f, s_mdMax = 0.0f, s_mdAdvB = 0.0f, s_mdAdvL = 0.0f;
const float e = (float)error.Length();
if (e > s_mdMax) s_mdMax = e;
s_mdAdvB += (float)(mirrorBodyAdv < 0 ? -mirrorBodyAdv : mirrorBodyAdv);
s_mdAcc += dt;
if (s_mdAcc >= 1.0f)
{
DEBUG_STREAM << "[mirdiv] maxErr=" << s_mdMax
<< " bodyTravel/s=" << s_mdAdvB
<< " bodyTgtSpd=" << bodyTargetSpeed << "\n" << std::flush;
s_mdAcc = 0.0f; s_mdMax = 0.0f; s_mdAdvB = 0.0f; s_mdAdvL = 0.0f;
}
}
Quaternion angular_deviation;
angular_deviation.Subtract(
projectedOrigin.angularPosition,
localOrigin.angularPosition);
// Position deadband (binary @0x4aad35: |pos - lastSent|^2 vs the
// model's UpdatePositionDiffrence @0x768) + the 2s heartbeat.
// Guard against an unstreamed/zero deadband with the old stand-in.
{
Scalar posDb = (updatePositionDeadband > 0.0f)
? updatePositionDeadband : 0.04f;
// LINEAR "came-to-rest" trigger -- the symmetric analog of the
// angular (live yaw-rate==0 && replicated yaw-rate!=0) resync
// trigger in the type-4 gate below. The position deadband alone
// does NOT catch a stop: once the master halts, its localOrigin is
// pinned and its OWN dead-reckon projection is re-based to it each
// frame, so error->0 and no pose record fires -- leaving the peer
// holding the last WALKING velocity (updateVelocity), which the
// replicant keeps dead-reckoning + animating as a phantom walk
// until the 2s heartbeat. Fire a pose record the frame the live
// horizontal speed collapses while the last-SENT speed was still
// non-zero, so the replicant gets velocity=0 immediately.
const Scalar liveSpdSq =
(Scalar)(localVelocity.linearMotion.x * localVelocity.linearMotion.x
+ localVelocity.linearMotion.z * localVelocity.linearMotion.z);
const Scalar sentSpdSq =
(Scalar)(updateVelocity.linearMotion.x * updateVelocity.linearMotion.x
+ updateVelocity.linearMotion.z * updateVelocity.linearMotion.z);
const Logical cameToRest = (liveSpdSq < 0.01f && sentSpdSq > 0.01f);
// DENSE MOTION SEND (replicant-smoothness): the position deadband
// fires IRREGULARLY (only after ~0.55u of drift), but the replicant's
// dead-reckoner predicts the next record one PAST-interval ahead and
// lerps its localOrigin toward that fixed horizon target -- so when the
// deadband cadence != the predicted cadence, the peer decelerates
// toward a stale target then lurches when a record finally lands
// (measured: dead-reckoned ground speed surging 5<->27 while the master
// walks a steady 6.13). Sending a pose record EVERY frame while moving
// makes the cadence regular + dense (records ~1 frame apart), so the
// horizon prediction matches and the lerp tracks smoothly. On a LAN
// this is a handful of small packets/frame -- negligible.
// Dense while moving: with the gait-driven peer, the master's velocity
// mirror no longer models the peer, so the deadband under-sends and the
// peer diverges (measured 11u snaps). Dense records keep the correction
// offset tiny; the peer absorbs it via the authentic pose-sync decay.
static const int s_denseTx = getenv("BT_NO_DENSE_TX") ? 0 : 1;
const Logical moving = (liveSpdSq > 0.25f);
if (
error.LengthSquared() > posDb
|| lastPerformance - lastUpdate > 2.0f
|| cameToRest
|| (s_denseTx && moving)
)
{
ForceUpdate(); // type 0: pose record
// RE-SEED the gait mirror to the just-sent pose: the peer will now
// extrapolate from THIS localOrigin, so the mirror must too. (The
// type-0 writer only re-bases projectedOrigin in the sim-state-2
// transition, not the normal walk case, so do it here.)
if (s_gaitMirrorSend)
{
projectedOrigin.linearPosition = localOrigin.linearPosition;
projectedOrigin.angularPosition = localOrigin.angularPosition;
}
if (getenv("BT_WIRE"))
DEBUG_STREAM << "[tx0] errSq=" << error.LengthSquared()
<< " posDb=" << posDb
<< " hb=" << (float)(lastPerformance - lastUpdate)
<< (cameToRest ? " REST" : "")
<< " lvLin=(" << localVelocity.linearMotion.x << ","
<< localVelocity.linearMotion.z << ")\n" << std::flush;
}
}
// ORIENTATION rides ONLY the type-4 resync record (the authentic
// case-0 writer/reader save-restore strips it from the pose
// record). Binary triggers (@0x4aac2b / @0x4aac6c):
// |localOrigin.angular.y - projectedOrigin.angular.y| > @0x770
// |localVelocity.angular.y - projectedVelocity.angular.y| > @0x76c
// or (live yaw-rate == 0 && replicated yaw-rate != 0)
// -- the quaternion Y component and the yaw rate, against the
// model's UpdateTurnDegreeDiffrence (deg->rad) and
// UpdateTurnVelocityDiffrence. [T1 expressions; zero-deadband
// guard falls back to the old quat-w stand-in]
{
// SCALAR PEER-YAW MIRROR (replaces the quaternion projectedOrigin mirror):
// the peer renders yaw ~= angMirrorYaw + angMirrorRate*(now - angMirrorTime)
// (re-based by the type-4 writer on every send, mech.cpp case 4). The old
// quaternion mirror was ALSO recomputed each frame by the master's own
// reckoner from lastUpdate timing it does not control, so it drifted in
// slow pi-waves and FLOODED angle resyncs in bursts (measured maxAng~=pi)
// -- the walk+turn record churn. Scalars: wrap-safe, self-timed, exact
// for the constant-rate spin the peer's exact integrator reproduces.
Scalar angDb = (updateTurnAngleDeadband > 0.0f) ? updateTurnAngleDeadband : -1.0f;
Scalar velDb = (updateTurnVelocityDeadband > 0.0f) ? updateTurnVelocityDeadband : -1.0f;
Logical resync = False;
int rzn = 0; // 1=angleDrift 2=velDrift 4=cameToRestAng 8=mirrorInvalid
Scalar angDrift = 0.0f;
if (angMirrorValid)
{
YawPitchRoll _yprL;
_yprL = localOrigin.angularPosition;
const Scalar mirrorYaw = angMirrorYaw
+ angMirrorRate * (Scalar)(lastPerformance - angMirrorTime);
Scalar _dyaw = (Scalar)_yprL.yaw - mirrorYaw;
while (_dyaw > 3.14159265f) _dyaw -= 6.28318531f;
while (_dyaw < -3.14159265f) _dyaw += 6.28318531f;
angDrift = (_dyaw < 0.0f) ? -_dyaw : _dyaw;
}
else
{
resync = True; rzn |= 8; // no baseline yet -> establish one
}
// Compare the live yaw rate against the LAST-SENT rate (updateVelocity,
// which the type-4 writer copies straight from localVelocity, mech.cpp:2107)
// -- NOT projectedVelocity, which the master's dead-reckoner recomputes in a
// different representation/sign so the scalar-.y compare always read the full
// 2x mismatch and FLOODED a resync every frame (measured: byVel=55/55,
// velDrift=2.618=2x the 1.309 spin rate). The flood reset the peer's
// dead-reckon horizon every frame, pinning its slerp at ~half rate -> the
// "rotates slow then jumps" spin. updateVelocity is the value the peer
// actually extrapolates with, in the same units, so a steady spin now drifts
// 0 (no resync) and the peer extrapolates smoothly; a real rate change still
// diverges past velDb and fires.
// NB: the Abs() macro (STYLE.H:118) is UNPARENTHESIZED --
// Abs(a-b) mis-expands to (a-b>0 ? a-b : -a-b) == -(a+b) on the false
// branch, NOT |a-b|. For a steady spin (localVy==updVy) that yields
// -(2*rate): on a reverse spin it is +2*rate and FLOODED a resync every
// frame (the confirmed root of the half-rate/freeze). Diff into a temp and
// take an explicit abs so the macro only ever sees a single token.
const Scalar velDiff = (Scalar)localVelocity.angularMotion.y - (Scalar)updateVelocity.angularMotion.y;
const Scalar velDrift = (velDiff < 0.0f) ? -velDiff : velDiff;
// ANGULAR SIGN hunt (BT_ANGSIGN, 0.2s): instantaneous gate values, to
// catch which of local/update/projected angular-Y carries the wrong sign.
if (getenv("BT_ANGSIGN"))
{
static float s_as = 0.0f; s_as += dt;
if (s_as >= 0.2f)
{
s_as = 0.0f;
DEBUG_STREAM << "[angsign] localVy=" << (Scalar)localVelocity.angularMotion.y
<< " updVy=" << (Scalar)updateVelocity.angularMotion.y
<< " projVy=" << (Scalar)projectedVelocity.angularMotion.y
<< " velDrift=" << velDrift << " angDrift=" << angDrift
<< " turn=" << turn << "\n" << std::flush;
}
}
if (angDb > 0.0f)
{
if (angDrift > angDb) { resync = True; rzn |= 1; }
if (velDrift > velDb) { resync = True; rzn |= 2; }
if ((Scalar)localVelocity.angularMotion.y == 0.0f
&& (Scalar)updateVelocity.angularMotion.y != 0.0f) { resync = True; rzn |= 4; }
}
else if (Abs(angular_deviation.w) < 0.997f)
{
resync = True; // stand-in when unstreamed
}
if (resync)
{
ForceUpdate(1 << MechResyncUpdateModelBit); // type 4
}
// SPIN diagnosis (BT_SPIN, once/sec): how DENSE are the orientation
// (type-4) records while turning? A stuttery peer spin == too sparse.
if (getenv("BT_SPIN"))
{
static float sAcc = 0.0f; static int sResync = 0, sFrames = 0, sAng = 0, sVel = 0, sRest = 0;
static float sMaxAng = 0.0f, sMaxVel = 0.0f;
sAcc += dt; sFrames++; if (resync) sResync++;
if (rzn & 1) sAng++; if (rzn & 2) sVel++; if (rzn & 4) sRest++;
if (angDrift > sMaxAng) sMaxAng = angDrift;
if (velDrift > sMaxVel) sMaxVel = velDrift;
if (sAcc >= 1.0f)
{
DEBUG_STREAM << "[spin-tx] resyncs=" << sResync << "/" << sFrames
<< " byAngle=" << sAng << " byVel=" << sVel << " byRest=" << sRest
<< " maxAng=" << sMaxAng << "(db" << angDb << ")"
<< " maxVel=" << sMaxVel << "(db" << velDb << ")"
<< " liveYaw=" << (Scalar)localVelocity.angularMotion.y
<< " updYaw=" << (Scalar)updateVelocity.angularMotion.y << "\n" << std::flush;
sAcc = 0.0f; sResync = 0; sFrames = 0; sAng = 0; sVel = 0; sRest = 0; sMaxAng = 0.0f; sMaxVel = 0.0f;
}
}
}
// Commanded-speed deadband (binary @0x4aac88): the mapper's live
// speedDemand vs the last-replicated bodyTargetSpeed -> the tiny
// type-2 record. This is the AUTHENTIC replicant-gait feed.
if (MappingMapper() != 0
&& MappingMapper()->speedDemand != bodyTargetSpeed)
{
ForceUpdate(1 << MechSpeedUpdateModelBit); // type 2
}
// Heat/impact-state change (binary @0x4aab75: heatAlarm level vs
// the frame-entry snapshot @0x780) -> the type-7 record.
if ((int)heatAlarm.GetLevel() != heatLevelSnapshot)
{
ForceUpdate(1 << MechImpactUpdateModelBit); // type 7
}
heatLevelSnapshot = (int)heatAlarm.GetLevel(); // @0x780 snapshot
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;
}
// CRIT-PROPAGATION PROBE (BT_CRIT_PROBE=<zone>, task #2): every 4s
// hammer ONE named zone of the LOCAL mech with a heavy hit until the
// zone is destroyed -- drives Zone::TakeDamage -> RecurseSegmentTable
// -> SendSubsystemDamage over the now-BOUND critical plugs, without
// waiting for combat to randomly destroy a non-vital zone. Diag
// only, off by default.
{
static int s_critZone = -2;
static float s_critT = 0.0f;
if (s_critZone == -2)
{
const char *cz = getenv("BT_CRIT_PROBE");
s_critZone = cz ? atoi(cz) : -1;
}
if (s_critZone >= 0 && s_critZone < damageZoneCount
&& isPlayerMech && !IsMechDestroyed())
{
s_critT += dt;
if (s_critT >= 4.0f)
{
s_critT = 0.0f;
Mech__DamageZone *z = Zone(s_critZone);
if (z != 0)
{
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = 40.0f;
dmg.burstCount = 1;
dmg.impactPoint = localOrigin.linearPosition;
DEBUG_STREAM << "[crit-probe] hitting zone "
<< s_critZone << "\n" << std::flush;
z->TakeDamage(dmg);
}
}
}
}
// 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;
}
}
}
// WORLD-STRUCTURE SWEEP PROBE (BT_WSWEEP, task #50 verification): once a
// second cast a horizontal fan of rays from the eyepoint against the
// STATIC collision solid tree (WorldStructurePick -> FindStaticSolidHitBy)
// and log the hit count + nearest range. Confirms arena1's structures
// (garages/walls) ARE in the collision tree the boresight now queries --
// independent of the render sentinel or interactive aim. Diag, off by default.
if (getenv("BT_WSWEEP"))
{
static float s_ws = 0.0f; s_ws += dt;
if (s_ws >= 1.0f)
{
s_ws = 0.0f;
Point3D eye = localOrigin.linearPosition; eye.y += 30.0f;
int hits = 0; float nearest = 1e30f; float ndir = -1.0f;
for (int a = 0; a < 24; ++a)
{
double th = (double)a * (6.2831853071795862 / 24.0);
Vector3D d((float)cos(th), 0.0f, (float)sin(th));
Point3D hp;
if (WorldStructurePick(eye, d, 2000.0f, &hp))
{
++hits;
float dx = hp.x-eye.x, dz = hp.z-eye.z;
float r = (float)Sqrt(dx*dx + dz*dz);
if (r < nearest) { nearest = r; ndir = (float)(th * 57.29578); }
}
}
DEBUG_STREAM << "[wsweep] eye=(" << eye.x << "," << eye.z
<< ") struct-hits=" << hits << "/24 nearest="
<< (hits ? nearest : -1.0f) << " dir=" << ndir << "deg\n" << std::flush;
}
}
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;
}
}
// WORLD-STRUCTURE pick (task #50): ray-test the STATIC collision
// solid tree (garages/walls/props -- the geometry that already
// BLOCKS the mech's walk) along the boresight. A structure CLOSER
// than the mech hit occludes it (the shot stops at the wall); with
// no mech, a structure hit IS the designation. This is the
// authentic non-mech world pick: Mech::WorldStructurePick ->
// Mover::FindStaticSolidHitBy, the "test against the static world"
// half of the engine's own FindBoxedSolidHitBy (MOVER.cpp) -- so a
// shot lands on exactly the geometry the walk collides against
// (the user's "structures block the mech, but weapons pass through").
Point3D structPoint;
float structDist = 1e30f;
bool haveStruct = false;
Entity *structOwner = 0; // the struck solid's owning map entity
if (gBTTerrainEntity != 0)
{
Point3D sp;
if (WorldStructurePick(rayStart, rayDir, 1200.0f, &sp, &structOwner))
{
float dx = sp.x-rs[0], dy = sp.y-rs[1], dz = sp.z-rs[2];
structDist = dx*dx + dy*dy + dz*dz;
structPoint = sp;
haveStruct = true;
static float s_wp = 0.0f; s_wp += dt;
if (getenv("BT_GROUND_LOG") && s_wp >= 1.0f)
{
s_wp = 0.0f;
DEBUG_STREAM << "[wpick] structure hit at ("
<< sp.x << "," << sp.y << "," << sp.z << ") dist="
<< (float)Sqrt(structDist)
<< " owner=" << (void *)structOwner
<< " ownerClass=" << (structOwner ? (int)structOwner->GetClassID() : -1)
<< (hotTarget ? " (mech also under boresight)" : "")
<< "\n" << std::flush;
}
}
}
if (hotTarget != 0 && (!haveStruct || bestDist <= structDist))
{
pickTarget = hotTarget; // mech is the closest hit
pickPoint = hotPoint;
++gAimHits;
}
else if (haveStruct)
{
// a world structure is the closest hit (garage/wall/prop) -- or
// it occludes a mech behind it. Designate the struck solid's
// OWNING ENTITY (issue #3, item b): the binary's target slot
// 0x388 holds the struck entity itself, and its weapon damage
// dispatch @004b9728 (part_013.c:6765-6794) sends a zone=-1
// TakeDamageMessage to ANY non-Mech target unconditionally --
// which is what makes CulturalIcons (trucks/props) weapon-
// killable (-> the FULL 'trkdead' explosion, res 32, not just
// the ram 'stephit' crunch). A plain terrain owner ignores it
// (Entity::TakeDamageMessageHandler returns on zone==-1 with no
// zones, ENTITY.cpp:885); an icon's own handler maps -1 -> 0.
// Fall back to the world sentinel only if the solid carried no
// owner. No lock ring either way (hotTarget stays mech-only;
// HudSim part_013.c:5620).
pickTarget = (structOwner != 0) ? structOwner : gBTTerrainEntity;
pickPoint = structPoint;
++gAimGround;
}
else
{
// no mech, no structure -> the flat 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 if (gBTTerrainEntity != 0)
{
// FIRE-AT-NOTHING (authentic, decomp part_013.c:7727): the pod fired
// freely at empty space -- the discharge gate is 0x388!=0 (the boresight
// always DESIGNATES a point, not a manual mech lock). When nothing pickable
// is hit (a level shot flies over the flat ground, or open sky), designate a
// MAX-RANGE point using the world sentinel so the weapon discharges to max
// range. Sentinel has no damage-zone table -> no aimed-zone damage, no lock
// ring (mechs only), per HudSimulation part_013.c:5620.
MECH_TARGET_ENTITY(this) = gBTTerrainEntity;
MECH_TARGET_SUBIDX(this) = -1;
Point3D farPt;
farPt.x = rs[0] + rd[0] * 1200.0f; // 1200 = HUD range default (0x44960000)
farPt.y = rs[1] + rd[1] * 1200.0f;
farPt.z = rs[2] + rd[2] * 1200.0f;
MECH_TARGET_POS(this) = farPt;
targetReticle.targetEntity = gBTTerrainEntity;
targetReticle.rayIntersection = farPt;
}
else
{
MECH_TARGET_ENTITY(this) = 0; // no world sentinel -> no target
MECH_TARGET_SUBIDX(this) = -1;
}
}
// DETERMINISTIC FIRE-AT-STRUCTURE TEST (BT_FIRE_AT_STRUCT, task #50 verify):
// the headless aim ray (BTGetAimRay) is flaky without a focused window, so
// this bypasses it -- designate the NEAREST world structure (360 sweep) as
// the target every frame so BT_AUTOFIRE can be tested against a real, stable,
// in-range building. Diag only, off by default.
if (getenv("BT_FIRE_AT_STRUCT"))
{
extern Entity *gBTTerrainEntity;
Point3D eye = localOrigin.linearPosition; eye.y += 30.0f;
Point3D best; float bestR = 1e30f; bool got = false;
for (int a = 0; a < 24; ++a)
{
double th = (double)a * (6.2831853071795862 / 24.0);
Vector3D d((float)cos(th), 0.0f, (float)sin(th));
Point3D hp;
if (WorldStructurePick(eye, d, 2000.0f, &hp))
{
float dx = hp.x-eye.x, dz = hp.z-eye.z;
float r = (float)Sqrt(dx*dx + dz*dz);
if (r < bestR) { bestR = r; best = hp; got = true; }
}
}
if (got && gBTTerrainEntity != 0)
{
MECH_TARGET_ENTITY(this) = gBTTerrainEntity;
MECH_TARGET_SUBIDX(this) = -1;
MECH_TARGET_POS(this) = best;
static float s_fas = 0.0f; s_fas += dt;
if (getenv("BT_FIRE_LOG") && s_fas >= 1.0f)
{
s_fas = 0.0f;
DEBUG_STREAM << "[fireat] nearest struct (" << best.x << ","
<< best.y << "," << best.z << ") r=" << bestR << "\n" << std::flush;
}
}
}
// DETERMINISTIC FIRE-AT-ICON TEST (BT_FIRE_AT_ICON, issue #3 item b
// verify): designate the NEAREST live (DefaultState) CulturalIcon as the
// weapon target every frame, bypassing the flaky headless aim ray, so
// BT_AUTOFIRE exercises the production weapon-vs-icon dispatch
// (SendDamageMessage -> icon TakeDamage -> res-32 'trkdead' explosion).
// Diag only, off by default.
if (getenv("BT_FIRE_AT_ICON"))
{
extern Entity *gBTCultIcons[256];
extern int gBTCultIconCount;
// Prefer icons AHEAD (toward the BT_GOTO goal when set) so the kill
// happens in the chase camera's field of view -- pure capture-harness
// convenience, the dispatch path is identical either way.
float fwdX = 0.0f, fwdZ = 0.0f; int haveFwd = 0;
{
const char *gv = getenv("BT_GOTO");
float gx, gz;
if (gv != 0 && sscanf(gv, "%f %f", &gx, &gz) == 2)
{
fwdX = gx - localOrigin.linearPosition.x;
fwdZ = gz - localOrigin.linearPosition.z;
haveFwd = 1;
}
}
Entity *bestIcon = 0; float bestR2 = 1e30f;
for (int ii = 0; ii < gBTCultIconCount; ++ii)
{
CulturalIcon *ic = (CulturalIcon *)gBTCultIcons[ii];
if (ic == 0 || ic->GetSimulationState() != CulturalIcon::DefaultState)
continue; // already dying/burning
Point3D ip = ic->localOrigin.linearPosition;
float dx = ip.x - localOrigin.linearPosition.x;
float dz = ip.z - localOrigin.linearPosition.z;
if (haveFwd && (dx*fwdX + dz*fwdZ) <= 0.0f)
continue; // behind the walk direction -> off-camera
float r2 = dx*dx + dz*dz;
if (r2 < bestR2) { bestR2 = r2; bestIcon = (Entity *)ic; }
}
if (bestIcon != 0)
{
Point3D aimPt = ((CulturalIcon *)bestIcon)->localOrigin.linearPosition;
aimPt.y += 3.0f; // aim into the hull, not the base
MECH_TARGET_ENTITY(this) = bestIcon;
MECH_TARGET_SUBIDX(this) = -1;
MECH_TARGET_POS(this) = aimPt;
static float s_fai = 0.0f; s_fai += dt;
if (s_fai >= 1.0f)
{
s_fai = 0.0f;
DEBUG_STREAM << "[fireicon] designating icon entity="
<< bestIcon->GetEntityID() << " r=" << (float)Sqrt(bestR2)
<< " (" << aimPt.x << "," << aimPt.y << "," << aimPt.z << ")\n"
<< std::flush;
}
}
}
// 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;
// task #58: publish the live twist for renderer-side DIAGNOSTICS
// (correlating [eyefwd] against the twist). The cockpit eye does
// NOT consume this -- it inherits the twist authentically through
// jointtorso's HingeRenderable in the draw traversal (see the note
// in DPLEyeRenderable::Execute).
{
extern float gBTEyeTwist;
gBTEyeTwist = gBTHudTwist;
}
// TORSO-ELEVATION AIM -> EYE PITCH (issue #3 follow-up; user +
// tester report "pitch does not work"): the Torso sim integrated
// R/F into currentElevation (authored limits/rates) but NOTHING
// consumed it -- the view and the aim ray (camera basis) never
// pitched. The pod's stick-Y aim pitches the EYE (the torso
// geometry does not tilt); compose it per frame with the look-state
// angles into eyepointRotation, which DPLEyeRenderable reads every
// frame (VIEW = baseOffset * R). BTCommitLookState stores the
// look component in gBTLookPitch/Yaw (below); the elevation rides
// the pitch term. Pitch sign PIXEL-CALIBRATED (BT_FORCE_ELEV
// screenshots): elev pitched the view DOWN for a positive axis;
// R (axis +1) must aim UP -> +elev.
{
extern float gBTLookPitch, gBTLookYaw;
extern Scalar BTGetTorsoElevation(Subsystem *);
const float elev = (float)BTGetTorsoElevation(GetTorsoSubsystem());
eyepointRotation = EulerAngles(
Radian(Radian::Normalize(gBTLookPitch + elev)),
Radian(Radian::Normalize(gBTLookYaw)),
Radian(0.0f));
// ...and the AIM RAY: the boresight publisher (L4VIDRND task
// #48) LEVELS the view direction (drops terrain body pitch) --
// publish the deliberate elevation so it re-applies onto the
// leveled boresight (else shots fire level over low targets).
extern float gBTEyeElev;
gBTEyeElev = elev;
}
}
// task #6 ORDER FIX: the scripted block must run BEFORE the fire-push
// block below -- it writes gBTLaserKey/gBTConfigKey, which the earlier
// keyboard poll zeroes each frame while the window is unfocused; the
// old placement AFTER the push block meant the push never saw the
// scripted press.
// task #6 scripted verify (BT_CONFIG_TEST=1, headless): drives the same
// gBTConfigKey/gBTLaserKey states the keyboard would --
// t=[8,11) hold the configure button (session open)
// t=[9.5,9.7) one Trigger press edge -> ChooseButton toggle
// t>=13 0.2s Trigger pulses -> does the weapon fire now?
if ((Entity *)this == application->GetViewpointEntity()
&& getenv("BT_CONFIG_TEST"))
{
// frame-count driven (this env throttles background windows to a
// few fps -- wall/sim seconds are useless for scripting)
static int s_cfgFrame = 0;
++s_cfgFrame;
(void)dt;
// Schedule LATE (frame 2000+): the sim ticks many frames per second
// and the first-run schedule (30-120) fired before the enemy spawn
// completed -- targetInArc false -> the fire press never pushed.
gBTConfigKey = (s_cfgFrame >= 2000 && s_cfgFrame < 2600) ? 1 : 0;
int fireOn = (s_cfgFrame >= 2300 && s_cfgFrame < 2320) ? 1 : 0; // ONE press edge
if (s_cfgFrame >= 3000)
fireOn = ((s_cfgFrame / 60) & 1); // fire pulses after exit
gBTLaserKey = fireOn;
}
// 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.
{
// AUTHENTIC FIRE INPUT (task #5): key states become PRESS/RELEASE
// edges pushed into the LBE4 buttonGroup channels -- the pod's own
// joystick path (L4CTRL Execute: Update(&v, mode) with v =
// +(button+1) press / -(button+1) release). The streamed per-mech
// controls map binds these buttons DIRECTLY onto each grouped
// weapon's TriggerState@0x31C; CheckFireEdge does the rest. This
// retires the gBT*Trigger globals + the 1,0 pulse hack.
// Desktop map: SPACE/'1' -> Trigger(0x40) [the main fire group],
// '2' -> ThumbLow(0x46), '3'/CTRL -> ThumbHigh(0x47) [missiles on
// most mechs]. BT_AUTOFIRE pulses the Trigger (edge per 2 frames).
// targetInArc stays the opt-in BT_FIRE_ARC presentation clamp.
LBE4ControlsManager *cm =
(LBE4ControlsManager *)application->GetControlsManager();
ModeMask modeMask = application->GetModeManager()->GetModeMask();
static int s_prevBtn[4] = {0, 0, 0, 0};
static int s_afPhase = 0;
s_afPhase ^= 1;
// DIAG (BT_AF_PERIOD=<sec>): throttle autofire cadence -- pulse the trigger
// only briefly every <sec> seconds, to test a slower-than-max fire rate (the
// coolant/jam mechanic is fire-rate dependent: at max spam the weapon overheats
// regardless; at a realistic cadence coolant priority decides the jam).
static float s_afClock = 0.0f;
s_afClock += dt;
static const float s_afPeriod =
getenv("BT_AF_PERIOD") ? (float)atof(getenv("BT_AF_PERIOD")) : 0.0f;
int afGate = 1;
if (s_afPeriod > 0.0f)
afGate = (fmodf(s_afClock, s_afPeriod) < 0.6f) ? 1 : 0;
const int autofire = (gBTDrive.fireForced && targetInArc && afGate) ? s_afPhase : 0;
const int buttons[4] = {
LBE4ControlsManager::ButtonJoystickTrigger, // 0x40
LBE4ControlsManager::ButtonJoystickPinky, // 0x45
LBE4ControlsManager::ButtonJoystickThumbLow, // 0x46
LBE4ControlsManager::ButtonJoystickThumbHigh, // 0x47
};
// DEV: BT_AF_MISSILE=1 pulses the missile group (ThumbHigh). INDEPENDENT
// of BT_AUTOFIRE (which drives the main gun group) so a headless rig can
// exercise MISSILES ONLY -- isolates missile damage/splash from laser+PPC
// fire (task #62 TTK verification).
static const int s_afMissile = getenv("BT_AF_MISSILE") ? 1 : 0;
const int autofireMsl = (s_afMissile && targetInArc) ? s_afPhase : 0;
int want[4];
want[0] = ((gBTLaserKey && targetInArc) || autofire) ? 1 : 0;
want[1] = (gBTPinkyKey && targetInArc) ? 1 : 0; // key '4' (was unmapped)
want[2] = (gBTPPCKey && targetInArc) ? 1 : 0;
want[3] = ((gBTMissileKey && targetInArc) || autofireMsl) ? 1 : 0;
if (cm != 0)
{
for (int b = 0; b < 4; ++b)
{
if (want[b] != s_prevBtn[b])
{
s_prevBtn[b] = want[b];
ControlsButton v = (ControlsButton)(want[b]
? (buttons[b] + 1) : -(buttons[b] + 1));
cm->buttonGroup[buttons[b]].ForceUpdate(&v, modeMask); // diag: bypass the prev-diff gate
if (getenv("BT_FIRE_LOG"))
{
static int s_pushN = 0;
if ((++s_pushN % 60) == 1)
DEBUG_STREAM << "[push] btn=0x" << std::hex
<< buttons[b] << std::dec << " v=" << (int)v
<< " mode=0x" << std::hex << (int)modeMask
<< std::dec << " #" << s_pushN
<< " thisMech=" << (void *)this
<< " viewpoint=" << (void *)application->GetViewpointEntity()
<< std::endl;
}
}
}
}
}
// task #12 scripted verify (BT_GENSEL_TEST=1): one SelectGeneratorD
// press at frame ~2500 -- the PPC re-taps from its authored GeneratorA
// onto GeneratorD; the charging I^2R then lands on GenD ([heat-t]).
if ((Entity *)this == application->GetViewpointEntity()
&& getenv("BT_GENSEL_TEST"))
{
static int s_gsFrame = 0;
++s_gsFrame;
gBTGenSelKey = (s_gsFrame >= 600 && s_gsFrame < 610) ? 7 : 0;
}
// task #13 scripted verify (BT_VALVE_TEST=1): one MoveValve press at
// frame ~600 -- Condenser1's valveState cycles 1 -> 5, so the flow
// redistribution gives it 5/10 of the total coolant flow ([valve] log
// + its bank conduction speeds up 5x).
if ((Entity *)this == application->GetViewpointEntity()
&& getenv("BT_VALVE_TEST"))
{
static int s_vtFrame = 0;
++s_vtFrame;
gBTValveKey = (s_vtFrame >= 600 && s_vtFrame < 610) ? 1 : 0;
}
// Gitea #6 scripted verify (BT_VIEWCYCLE_TEST=<frame>): pulse one
// secondary-schematic cycle (Damage -> Critical -> Heat -> Damage) at
// the given frame and every 300 frames after -- with
// BT_DEV_GAUGES_DOCK + BT_SHOT the docked sec panel's schematic layer
// swap is pixel-verifiable. BT_MODECYCLE_TEST=<frame> does the same
// for the M-key CONTROL-mode cycle (the empirical proof that a
// control-mode change does NOT touch the secondary view).
if ((Entity *)this == application->GetViewpointEntity()
&& getenv("BT_VIEWCYCLE_TEST"))
{
static int s_vcFrame = 0;
++s_vcFrame;
int t0 = atoi(getenv("BT_VIEWCYCLE_TEST"));
if (t0 < 1) t0 = 600;
if (s_vcFrame >= t0 && (s_vcFrame - t0) % 300 == 0)
{
gBTDisplayCycle = 1;
}
}
if ((Entity *)this == application->GetViewpointEntity()
&& getenv("BT_MODECYCLE_TEST"))
{
static int s_mcFrame = 0;
++s_mcFrame;
int t0 = atoi(getenv("BT_MODECYCLE_TEST"));
if (t0 < 1) t0 = 600;
if (s_mcFrame >= t0 && (s_mcFrame - t0) % 300 == 0)
{
gBTModeCycle = 1;
}
}
// Gitea #9 scripted verify (BT_PRESET_TEST=<frame>): from the given
// frame, pulse a preset-page cycle on ALL THREE upper MFDs every 120
// frames (Quad -> populated Eng pages -> Quad, per MFD) -- with
// BT_DEV_GAUGES_DOCK + BT_SHOT the Mfd1/Mfd2/Mfd3 page swaps are
// pixel-verifiable ([mode] preset log lines under BT_MODE_LOG).
if ((Entity *)this == application->GetViewpointEntity()
&& getenv("BT_PRESET_TEST"))
{
static int s_ptFrame = 0;
static int s_ptPulses = 0;
++s_ptFrame;
int t0 = atoi(getenv("BT_PRESET_TEST"));
if (t0 < 1) t0 = 600;
// issue #11: BT_PRESET_HOLD=<n> stops after n pulses so a page can
// be HELD indefinitely (steady-state ghost/graph verification --
// the 120-frame cycle otherwise phase-locks with BT_SHOT's 90-frame
// cadence, so a fixed fraction of shots lands on transition frames).
int holdAfter = getenv("BT_PRESET_HOLD") ? atoi(getenv("BT_PRESET_HOLD")) : 0;
if (s_ptFrame >= t0 && (s_ptFrame - t0) % 120 == 0
&& (holdAfter <= 0 || s_ptPulses < holdAfter))
{
++s_ptPulses;
gBTPresetCycle[0] = 1;
gBTPresetCycle[1] = 1;
gBTPresetCycle[2] = 1;
}
}
// Gitea #7 scripted verify (BT_FLUSH_TEST=<frame>): hold the coolant
// flush for ~60 sim frames starting at the given frame -- the
// reservoir drains ([flush] log), the coolant vertBar drops, and the
// FLUSH.PFX condensation cloud spawns on the 0->1 edge.
if ((Entity *)this == application->GetViewpointEntity()
&& getenv("BT_FLUSH_TEST"))
{
static int s_ftFrame = 0;
++s_ftFrame;
int t0 = atoi(getenv("BT_FLUSH_TEST"));
if (t0 < 1) t0 = 600;
gBTFlushKey = (s_ftFrame >= t0 && s_ftFrame < t0 + 60) ? 1 : 0;
}
// Gitea #7: the COOLANT FLUSH button (the coolant MFD's top-right
// button, manual p24 -- "punch (or hold down) the coolant button to
// flush fresh coolant through your 'Mech's loops"). Press AND
// release edges both dispatch: InjectCoolant (id 4, the Reservoir
// table @0x50e680) starts the flush on data>=1 and stops it on
// data<1 -- the same held-button payload the pod's streamed MFD
// EventMapping delivers.
if ((Entity *)this == application->GetViewpointEntity())
{
static int s_prevFlush = 0;
if (gBTFlushKey != s_prevFlush)
{
int pressed = (gBTFlushKey != 0);
s_prevFlush = gBTFlushKey;
Subsystem *reservoir = 0;
for (int s = 1; s < GetSubsystemCount(); ++s)
{
Subsystem *sub = GetSubsystem(s);
if (sub != 0 && (int)sub->GetClassID() == 0xBC0) // Reservoir
{
reservoir = sub;
break;
}
}
if (reservoir != 0)
{
if (getenv("BT_FLUSH_LOG"))
DEBUG_STREAM << "[flush-tx] InjectCoolant "
<< (pressed ? "PRESS" : "RELEASE") << " -> "
<< reservoir->GetName() << std::endl;
ReceiverDataMessageOf<ControlsButton> msg(
4 /*Reservoir::InjectCoolantMessageID*/,
sizeof(ReceiverDataMessageOf<ControlsButton>),
(ControlsButton)(pressed ? 1 : 0));
reservoir->Dispatch(&msg);
}
}
}
// task #13 dev harness: the COOLANT VALVE lever. 'C' edge -> dispatch
// MoveValve (id 4, the Condenser table @0x50E52C) to the selected
// condenser -- the same press payload the pod's engineering-screen aux
// buttons deliver.
if ((Entity *)this == application->GetViewpointEntity())
{
static int s_prevValve = 0;
if (gBTValveKey != s_prevValve)
{
s_prevValve = gBTValveKey;
if (gBTValveKey != 0)
{
Subsystem *condenser = 0;
const char *slotEnv = getenv("BT_VALVE_SLOT");
int wantSlot = (slotEnv != 0) ? atoi(slotEnv) : -1;
for (int s = 1; s < GetSubsystemCount(); ++s)
{
Subsystem *sub = GetSubsystem(s);
if (sub == 0)
continue;
if (wantSlot >= 0 ? (s == wantSlot)
: ((int)sub->GetClassID() == 0xBBD)) // Condenser
{
condenser = sub;
break;
}
}
if (condenser != 0)
{
if (getenv("BT_FIRE_LOG"))
DEBUG_STREAM << "[valve-tx] MoveValve -> "
<< condenser->GetName() << std::endl;
ReceiverDataMessageOf<ControlsButton> msg(
4 /*Condenser::MoveValveMessageID*/,
sizeof(ReceiverDataMessageOf<ControlsButton>),
(ControlsButton)1 /*press*/);
condenser->Dispatch(&msg);
}
}
}
}
// task #12 dev harness: the POWER-ROUTING buttons. F5..F9 edge ->
// dispatch SelectGeneratorA..D / ToggleGeneratorMode (ids 4..8) to the
// selected weapon -- the same ReceiverDataMessageOf<ControlsButton>
// press payload the pod's aux-panel EventMappings deliver. The
// handler chain is PoweredSubsystem's (weapons inherit via MechWeapon).
if ((Entity *)this == application->GetViewpointEntity())
{
static int s_prevGenSel = 0;
if (gBTGenSelKey != s_prevGenSel)
{
int pressedID = gBTGenSelKey; // 0 on release
s_prevGenSel = gBTGenSelKey;
if (pressedID != 0)
{
Subsystem *weapon = 0;
const char *slotEnv = getenv("BT_CONFIG_SLOT");
int wantSlot = (slotEnv != 0) ? atoi(slotEnv) : -1;
for (int s = 1; s < GetSubsystemCount(); ++s)
{
Subsystem *sub = GetSubsystem(s);
if (sub == 0)
continue;
int cid = (int)sub->GetClassID();
if (wantSlot >= 0 ? (s == wantSlot)
: (cid == 0xBC8 || cid == 0xBCA || cid == 0xBCE
|| cid == 0xBD0 || cid == 0xBD4))
{
weapon = sub;
break;
}
}
if (weapon != 0)
{
if (getenv("BT_FIRE_LOG"))
{
extern void BTGenSelProbe();
BTGenSelProbe();
DEBUG_STREAM << "[gensel-tx] id=" << pressedID
<< " -> " << weapon->GetName() << std::endl;
}
ReceiverDataMessageOf<ControlsButton> msg(
pressedID /*4..8 (powersub.hpp ids)*/,
sizeof(ReceiverDataMessageOf<ControlsButton>),
(ControlsButton)1 /*press*/);
weapon->Dispatch(&msg);
}
}
}
}
// task #6 dev harness: the config-session BRACKET. 'G' edge ->
// dispatch ConfigureMappables (id 9) press/release to the selected
// weapon -- the exact ReceiverDataMessageOf<ControlsButton> payload
// (+-(element+1)) the pod's streamed aux-panel EventMapping delivers.
// Everything downstream is the AUTHENTIC chain: EnterConfiguration
// flips the global mode to Mapping(0x8000) (normal fire mappings go
// dormant for the hold), the fire keys deliver ChooseButton (id 10)
// through the temp 0x8000 event mappings pushed by the SAME live-mode
// button path above, and the release restores NonMapping(0x10000).
if ((Entity *)this == application->GetViewpointEntity())
{
static int s_prevCfg = 0;
if (gBTConfigKey != s_prevCfg)
{
s_prevCfg = gBTConfigKey;
Subsystem *weapon = 0;
const char *slotEnv = getenv("BT_CONFIG_SLOT");
int wantSlot = (slotEnv != 0) ? atoi(slotEnv) : -1;
for (int s = 1; s < GetSubsystemCount(); ++s) // slot 0 = the MAPPER, never a weapon
{
Subsystem *sub = GetSubsystem(s);
if (sub == 0)
continue;
// Pick by WEAPON classID (0xBC8 Emitter / 0xBCA ProjectileWeapon /
// 0xBCE GaussRifle / 0xBD0 MissileLauncher / 0xBD4 PPC). The old
// (simulationFlags & 0x8) heuristic matched the MAPPER at slot 0:
// its MakeViewpointEntity resource is stack-built with only
// name/classID/size set, so its flags are STACK GARBAGE -- and a
// config message dispatched to the mapper lands in the mapper's
// OWN id space (the aux-preset Fail trap) = the abort popup.
int cid = (int)sub->GetClassID();
if (wantSlot >= 0 ? (s == wantSlot)
: (cid == 0xBC8 || cid == 0xBCA || cid == 0xBCE
|| cid == 0xBD0 || cid == 0xBD4))
{
weapon = sub;
break;
}
}
if (weapon == 0 && getenv("BT_FIRE_LOG"))
{
DEBUG_STREAM << "[config] NO WEAPON matched (count="
<< GetSubsystemCount() << ")" << std::endl;
for (int s2 = 0; s2 < GetSubsystemCount() && s2 < 33; ++s2)
{
Subsystem *p = GetSubsystem(s2);
if (p != 0)
DEBUG_STREAM << " [cfg-scan] " << s2 << " " << p->GetName()
<< " flags=0x" << std::hex << (int)p->simulationFlags
<< std::dec << std::endl;
}
}
if (weapon != 0)
{
const int kElem = 0x0e; // an aux-panel element (as authored)
ReceiverDataMessageOf<ControlsButton> msg(
9 /*MechWeapon::ConfigureMappablesMessageID (mechweap.hpp)*/,
sizeof(ReceiverDataMessageOf<ControlsButton>),
(ControlsButton)(gBTConfigKey ? (kElem + 1) : -(kElem + 1)));
weapon->Dispatch(&msg);
if (getenv("BT_FIRE_LOG"))
DEBUG_STREAM << "[config] " << (gBTConfigKey ? "ENTER" : "EXIT")
<< " session on " << weapon->GetName()
<< " mode now=0x" << std::hex
<< (int)application->GetModeManager()->GetModeMask()
<< std::dec << std::endl;
}
}
}
{
// 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)"
: (MECH_TARGET_ENTITY(this) != 0 ? " fire-at-nothing (max-range designate)"
: " no target (no world sentinel)")))
<< " range=" << range
<< (anyWeaponInRange ? " IN WEAPON RANGE" : "")
<< " [mechPicks=" << gAimHits << " groundPicks=" << gAimGround
<< " noRay=" << gAimNoRay << "]"
<< "\n" << std::flush;
gAimHits = 0; gAimNoRay = 0; gAimGround = 0;
}
// Resolve the "explode" effect resource once.
if (gExplodeReady == 0)
{
gExplodeReady = -1;
if (application != 0 && application->GetResourceFile() != 0)
{
ResourceDescription *exp =
application->GetResourceFile()->FindResourceDescription(
"explode", (ResourceDescription::ResourceType)1, -1);
if (exp != 0)
{
gExplodeRes = exp->resourceID;
gExplodeReady = 1;
DEBUG_STREAM << "[fire] explode effect resolved id="
<< (long)gExplodeRes << "\n" << std::flush;
}
else
{
DEBUG_STREAM << "[fire] 'explode' effect not found\n" << std::flush;
}
}
}
// THE PHANTOM-SHOT BLOCK IS RETIRED (task #11, user-reported): this
// bring-up stand-in spawned an explosion at the victim on its OWN
// 0.3s cadence whenever the fire key was down -- matched the old
// one-frame recharge, but once the AUTHENTIC electrical model
// landed (task #10: 2-5s recharges + the generator thermal breaker)
// it kept painting hits while no weapon was discharging ("enemy
// shows fire even though I'm not producing lasers"). The authentic
// impact visual flows from each REAL discharge: Emitter::FireWeapon
// -> SendDamageMessage -> SubsystemMessageManager explosion
// bundling -> SubmitExplosion (messmgr.cpp). This block now keeps
// only its presentation roles above (boresight pick -> the target
// slots the weapons read, and the [target] log).
}
}
// --- 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 (i != 0) // slot 0 = the mapper (task #7)
++subsystemsPresent;
if (!subsystem->IsNonReplicantExecutable())
continue;
// The controls-mapping subsystem (roster slot 0 via Mech::SetMapping
// Subsystem -- the [0x10d] mirror is GONE, task #7) 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 && i == 0) // slot 0 = the mapper (task #7)
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();
// Resolve the authored splash radius for every weapon so we can
// confirm the GameModel +0x50 plumbing yields real values without
// needing a live missile hit + bystander (task #62 verification).
Scalar splash = -1.0f;
if (rs->IsDerivedFrom(*MechWeapon::GetClassDerivations()))
splash = BTResolveSplashRadius((Entity *)this, ri);
DEBUG_STREAM << "[roster] " << ri
<< " classID=" << (int)rs->GetClassID()
<< " " << (rd ? rd->className : "?")
<< " splashRadius=" << splash << "\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;
// DEV: BT_SPLASH_TEST=1 -- deterministic splash verification (task #62).
// Once per second the LOCAL mech synthesizes a missile detonation AT the
// enemy's position (directVictim=0, so the enemy IS a splash candidate)
// using its first missile launcher. Exercises the FULL path (radius
// resolve -> candidate walk -> distance-scaled burst -> damage delivery)
// without depending on the live targeting/autofire path.
static const int s_splashTest = getenv("BT_SPLASH_TEST") ? 1 : 0;
if (s_splashTest && gEnemyMech != 0 && (Entity *)this != gEnemyMech)
{
int mslIdx = -1;
for (int i = 0; i < subsystemCount; ++i)
{
Subsystem *s = GetSubsystem(i);
if (s != 0 && s->IsDerivedFrom(MissileLauncher::ClassDerivations))
{ mslIdx = i; break; }
}
if (mslIdx >= 0)
{
// Detonate as a NEAR-MISS 15 units from the enemy (a realistic
// splash geometry: the round bursts beside the mech, not at its
// exact center), so the burst falloff exercises the typical case.
Point3D ep = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
Point3D burst = ep; burst.x += 15.0f;
Damage sd;
sd.damageType = Damage::ExplosiveDamageType;
sd.damageAmount = 0.03f; // small test dose
sd.burstCount = 1;
sd.impactPoint = burst;
extern void BTApplySplashDamage(Entity *, int, const Point3D &,
Entity *, const Damage &);
BTApplySplashDamage((Entity *)this, mslIdx, burst, 0, sd);
}
}
}
//
// GYRO JOINT-WRITE DISPATCH (task #56, byte-verified @0x4aaf18-0x4aaf89):
// the binary Mech master performance tail pushes the gyro's integrated state
// into the skeleton every frame AFTER the animation pass + subsystem tick:
// gyro->swayBias = mech sway accumulator (the mech+0x3F0 overspeed model is
// NOT yet reconstructed -- feed 0, flagged);
// if (!deathAnimationLatched) { if (legAnimationState != 0) WriteEyeJoint();
// WriteMechJoint(); }
// WriteMechJoint drives 'jointeye' (translation = the eye spring offset +
// hit bounce, rotation = body tip); siteeyepoint rides that joint, so this
// IS what moves the cockpit eye. Runs after the roster tick above (the gyro
// Performance integrated this frame) and after the gait channel wrote the
// joints (WriteEyeJoint is multiplicative on the animated rotation).
//
if (GetInstance() == MasterInstance)
{
// (task #66) INSTABILITY MODEL -- byte-decoded from the binary master
// perf @0x4aad3d-0x4aaf14 [T1] (the un-exported gap; capstone disasm).
// Runs HERE, right before the gyro dispatch, exactly as the binary
// does: it reads LAST frame's acceleration snapshot (the tail updates
// localAcceleration afterwards -- "AccelerationLastFrame", the same
// vector the binary copies to +0x82c after this point).
{
// term 1: acceleration sway, squared
Scalar a = localAcceleration.linearMotion.Length();
a = (maxUnstableAcceleration > 0.0f) ? a / maxUnstableAcceleration : 0.0f;
if (a > 1.0f) a = 1.0f;
a *= unstableAccelerationEffect;
unstablePercentage = a * a;
// term 2: "gun the engine" -- commanded speed the legs haven't
// reached, as a fraction of the command (negative when coasting
// past the demand -- the binary does not floor it)
MechControlsMapper *im = MappingMapper();
Scalar u = (im != 0) ? im->speedDemand : 0.0f;
if (u > reverseSpeedMax2) u = reverseSpeedMax2; // @0x7a0 run cap
if (u < gimpStrideLength) u = gimpStrideLength; // @0x350 (negative reverse cap)
Scalar r = 0.0f;
if (u > 0.0f)
r = (u - legCycleSpeed) / u;
else if (u < 0.0f)
{
r = u - legCycleSpeed;
r = ((r <= 0.0f) ? -r : r) / (-u);
}
if (r > 1.0f) r = 1.0f;
unstablePercentage += r * unstableGunTheEngineEffect;
// term 3: turning in place (trn, leg state 4)
if (legAnimationState == 4)
unstablePercentage += unstableStopedTurnEffect;
if (unstablePercentage > 1.0f) unstablePercentage = 1.0f;
if (getenv("BT_GYRO_TRACE")) { static long s_it=0; if ((++s_it % 120)==0)
DEBUG_STREAM << "[instab] " << unstablePercentage
<< " (|a|=" << localAcceleration.linearMotion.Length()
<< " demand=" << u << " cyc=" << legCycleSpeed
<< " legState=" << legAnimationState << ")\n" << std::flush; }
}
extern void GyroFrameJointWrite(Subsystem *, Scalar, int, int);
static int s_gyrodbg = 0;
if (getenv("BT_GYRO_LOG") && s_gyrodbg++ == 0)
DEBUG_STREAM << "[gyro] dispatch: gyro=" << (void *)gyroSubsystem
<< " legAnim=" << legAnimationState
<< " death=" << deathAnimationLatched << "\n" << std::flush;
GyroFrameJointWrite(gyroSubsystem, unstablePercentage,
legAnimationState, deathAnimationLatched);
}
else if (getenv("BT_GYRO_LOG"))
{
static int s_gyrodbg2 = 0;
if (s_gyrodbg2++ == 0)
DEBUG_STREAM << "[gyro] dispatch SKIPPED: instance=" << (int)GetInstance()
<< " != master\n" << std::flush;
}
// Keep the simulation/networking bookkeeping consistent (this is exactly
// what the base "no time / stasis" early-out does). MASTER ONLY (2026-07-14):
// replication is master-authoritative -- a replicant must never serialize.
// The port's replicant runs the leg SM for JOINTS (task #50 accommodation),
// whose transitions call ForceUpdate() and mark updateModel; serializing those
// emitted derived/uninitialized state back into the stream (and the type-3
// writer re-dispatches SetBodyAnimation on the WRITER -- the real-clock crash
// rode this path with legAnimationState still 0xCDCDCDCD). Discard replicant
// marks instead.
// WATCHER POLL (engine Simulation::PerformAndWatch tail, SIMULATE.cpp:460):
// the engine path ends Perform() -> ExecuteWatchers() -> WriteSimulationUpdate().
// This reconstruction replaced the whole performance but DROPPED the
// ExecuteWatchers() step, so the entity-level POLLED audio watchers
// (AudioLogicalTrigger FootStep, AudioMotionScale/Trigger LocalVelocity, ...)
// never executed -- only the PUSHED StateIndicator watchers fired. That's
// why footsteps/motion-scaled sounds were silent while state sounds worked. [T0]
// (AUDIO_FIDELITY F5) footStep is the authored CONTACT LEVEL (binary
// disasm @0x4a9e80-0x4a9eb6): while the leg channel's gait state is not
// Standing(0)/1, footStep = (jointlocal.y <= *clip threshold); states
// 0/1 RETAIN the previous value. The FootStep matcher fires on the
// CHANGE to 1 -- one clean rising edge per actual foot plant, timed by
// the clip's authored root-height crossing (replaces the 150 ms
// transition pulse, which fired at clip boundaries with a fixed width).
if (legAnimationState != 0 && legAnimationState != 1)
{
if (!footStepRootResolved)
{
footStepRootResolved = 1;
footStepRootJoint = ResolveJoint("jointlocal");
}
Scalar *fs_thresh = legAnimation.footStepThreshold;
if (footStepRootJoint != 0 && fs_thresh != 0)
{
int contact =
(footStepRootJoint->GetTranslation().y <= *fs_thresh) ? 1 : 0;
if (getenv("BT_AUDIO_LOG")) { static int s_fc=0;
if (contact != footStep && s_fc++ < 40)
DEBUG_STREAM << "[audio] footstep contact " << footStep << "->" << contact
<< " rootY=" << footStepRootJoint->GetTranslation().y
<< " thresh=" << *fs_thresh << " legState=" << legAnimationState
<< "\n" << std::flush; }
footStep = contact;
}
}
// (AUDIO_FIDELITY F19) the localAcceleration feed, EXACTLY the binary's
// structure: 15-sample RING BUFFERS of the raw per-frame position
// derivative + dt (ctor part_012.c:9836-9840, fill 0); accel = the
// frame-to-frame derivative of the ring MEAN over the mean dt
// (part_012.c:15169-15195). Runs HERE, right before the watcher poll,
// so the accel attribute updates EVERY polled frame -- the ring mean's
// rotation jitter keeps the change-gate open and the authored N=30
// footstep smoother fed (fed per-stride only, it took 10-20 s to warm
// from fill=0: the late-footsteps bug).
{
Scalar rdx = localOrigin.linearPosition.x - accelPrevPos.x;
Scalar rdy = localOrigin.linearPosition.y - accelPrevPos.y;
Scalar rdz = localOrigin.linearPosition.z - accelPrevPos.z;
accelPrevPos = localOrigin.linearPosition;
if (dt > 1.0e-4f)
{
velRingFwd[velRingCursor] = (Scalar)sqrtf(rdx * rdx + rdz * rdz) / dt;
velRingVert[velRingCursor] = rdy / dt;
velRingDt[velRingCursor] = dt;
velRingCursor = (velRingCursor + 1) % 15;
}
Scalar fwdMean = 0.0f, vertMean = 0.0f, dtMean = 0.0f;
for (int vr = 0; vr < 15; ++vr)
{
fwdMean += velRingFwd[vr];
vertMean += velRingVert[vr];
dtMean += velRingDt[vr];
}
fwdMean *= (1.0f / 15.0f);
vertMean *= (1.0f / 15.0f);
dtMean *= (1.0f / 15.0f);
if (dtMean > 1.0e-4f) // binary: _DAT_004ab9d4 guard
localAcceleration.linearMotion = Vector3D(
0.0f,
(vertMean - accelPrevVertMean) / dtMean,
-((fwdMean - accelPrevFwdMean) / dtMean));
accelPrevFwdMean = fwdMean;
accelPrevVertMean = vertMean;
}
// (AUDIO_FIDELITY F7) latch the incoming-missile report accumulated by
// Missile::MoveAndCollide since our last frame, then re-arm. The authored
// beeper matches incomingLock 1/0 and the tempo scale reads the range --
// both polled by ExecuteWatchers below.
incomingLock = incomingLockNext;
distanceToMissile = distanceToMissileNext;
incomingLockNext = 0;
distanceToMissileNext = FLT_MAX;
if (getenv("BT_AUDIO_LOG")) { static int s_wd=0; if ((s_wd++ % 300)==0)
DEBUG_STREAM << "[audio] mech watcher poll t=" << (GetTickCount() % 1000000)
<< " n=" << s_wd
<< " delayed=" << (int)AreWatchersDelayed() << " audioWatchers=" << DebugAudioWatcherCount()
<< " simFlags=0x" << std::hex << (unsigned)simulationFlags << std::dec
<< "\n" << std::flush; }
if (!AreWatchersDelayed())
ExecuteWatchers();
if (GetInstance() != ReplicantInstance)
WriteSimulationUpdate(update_stream);
else
updateModel = 0; // drop accommodation-path marks
}
//###########################################################################
//###########################################################################
// 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
// Per-frame collision-state reset (@4aa741): the walk below re-accumulates it
// per resolved contact, then it's pushed into the collisionState StateIndicator.
collisionTemporaryState = 0;
// COLLISION-DAMAGE ECONOMY GUARD: snapshot the TRUE frame motion for the
// per-contact velocity restore in Mech::ProcessCollision (see mech.hpp) --
// the list walk below reflects worldLinearVelocity at every StaticBounce,
// and a multi-solid frame compounds those reflections into a garbage
// velocity that priced mech-vs-mech damage 4x-40x too high (mp_a.log:32651).
frameEntryWorldVelocity = savedWorldVel;
// ram contact-edge decay: once contact with the last victim has been
// broken for the linger window, re-arm the one-bump-one-hit gate
if (ramContactLinger > 0.0f)
{
ramContactLinger -= dt;
if (ramContactLinger <= 0.0f)
ramLastVictim = 0;
}
Damage dmg; // ctor zeroes damageAmount
if (cols != 0)
ProcessCollisionList(cols, dt, old_position, &dmg);
// Push the frame's accumulated contact state into the audio-bound
// collisionState indicator: 0->1 on a fresh contact fires the impact sound,
// 1->0 when contact breaks; SetState only fires on change so a pressed-against
// contact plays once, not every frame. (@4aa741 sibling of the reset above.)
collisionState.SetState((unsigned)collisionTemporaryState);
// 6. response policy (@4aa76c-4aab5f)
if (dmg.damageAmount == 0.00123f) // crushable icon: move STANDS
{
worldLinearVelocity = savedWorldVel;
localOrigin.linearPosition = savedPos;
MoveCollisionVolume();
dmg.damageAmount = 0.0f;
// Binary @4aa7ce-4aa871 (task #56): the gyro CRUNCH. n = Normalize(
// dmg.damageForce) (the delta-v across the bounce, filled by the engine
// Mover::ProcessCollisionList -- no new plumbing), then a torque along
// n @ 0.4 (@4aa81e, 0x3ecccccd) + an upward impulse @ 0.2 (@4aa86c,
// 0x3e4ccccd). Normalize is unguarded in the binary too.
if (gyroSubsystem != 0)
{
extern void GyroApplyDamageTorque (Subsystem *, Scalar, Scalar, Scalar, Scalar);
extern void GyroApplyDamageImpulse(Subsystem *, Scalar, Scalar, Scalar, Scalar);
Vector3D n;
n.Normalize(dmg.damageForce);
GyroApplyDamageTorque (gyroSubsystem, n.x, n.y, n.z, 0.4f); // @4aa81e
GyroApplyDamageImpulse(gyroSubsystem, 0.0f, 1.0f, 0.0f, 0.2f); // @4aa86c
}
if (GroundLog())
DEBUG_STREAM << "[ground] CRUNCH (crushable icon) at ("
<< savedPos.x << ", " << savedPos.z << ")" << std::endl;
}
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);
ForceUpdate(1); // FUN_004a4c54(this, 1): pose record
ForceUpdate(0x20); // FUN_004a4c54(this, 0x20): the
// type-5 KNOCKDOWN record -- the
// peer's replicant staggers too
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] << ")" << std::endl;
}
else if (GroundLog())
DEBUG_STREAM << "[ground] BLOCK dmg=" << dmg.damageAmount
<< " at (" << old_position.x << ", " << old_position.z
<< ")" << std::endl;
}
// 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 << ")" << std::endl;
}
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 << ")" << std::endl;
}
}
}
//###########################################################################
//###########################################################################
// 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;
// RAM ECONOMY NORMALIZATION [T3, evidence-forced 2026-07-12]: StaticBounce
// prices the kinetic loss with the AUTHORED moverMass (~1.3e6 units) --
// ~59,000 "points" for a 10 m/s ram, against zones whose armor absorbs
// 1 point per point (scale = 1/armorPoints, armor 50-140: the [zone-armor]
// dump). The BINARY dispatches this raw number [T1 @part_012:15324] --
// but on the pod network it only ever hit the local REPLICANT of the
// victim, where it evaporated (the master never heard it; MECH.CPP:986
// merely warns). Our MP forwards replicant damage (task #47, required
// for weapons), which surfaced the dormant 59K as a one-shot ram-kill.
// Normalize by 1e-3 (the mass-unit/armor-point scale): a walking bump =
// a few points, a full charge = tens -- the armor-table economy.
dmg.damageAmount = resolved->damageAmount * 0.001f;
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;
// RAM ECONOMY TELEMETRY (the MP ram one-shot investigation): every
// dispatched ram, with the exact velocity StaticBounce priced it from --
// the decisive evidence channel for any future out-of-economy amount.
if (getenv("BT_MP_NET"))
{
const Vector3D &v = inflictor->GetWorldLinearVelocity();
DEBUG_STREAM << "[collide-tx] " << inflictor->GetEntityID()
<< " rams " << victim->GetEntityID()
<< " dmg=" << resolved->damageAmount
<< " mass=" << inflictor->moverMass
<< " e=" << inflictor->elasticityCoefficient
<< " |v|=" << (Scalar)sqrtf(v.x*v.x + v.y*v.y + v.z*v.z)
<< " at(" << dmg.impactPoint.x << "," << dmg.impactPoint.y
<< "," << dmg.impactPoint.z << ")" << std::endl;
}
}
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;
}
// COLLISION-DAMAGE ECONOMY GUARD (see mech.hpp frameEntryWorldVelocity):
// this runs once PER CONTACT in the frame's ProcessCollisionList walk, and
// the StaticBounce below reflects worldLinearVelocity (+= delta_v) every
// time. Restore the TRUE frame-entry motion first so a multi-solid frame
// (mech + terrain solids + debris) cannot compound the reflections into an
// amplified velocity: every contact -- and every dispatched mech-vs-mech
// damage amount -- is priced at the mech's real approach speed, which is
// all the 1995 binary's StaticBounce ever saw (its ground was a heightfield
// probe, never a collision-list entry). The post-list velocity is
// overwritten by the response policy / next frame's derive regardless.
worldLinearVelocity = frameEntryWorldVelocity;
// --- 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()))
{
// RAM CONTACT EDGE (see mech.hpp): dispatch damage only on a FRESH
// contact with this victim; while pressed, refresh the linger so
// sustained contact (held stick / respawn overlap) stays a BLOCK,
// not a 60Hz damage grinder -- the binary's bounce-separation made
// this edge implicit; our gait-derived velocity needs it explicit.
if (owner == ramLastVictim && ramContactLinger > 0.0f)
{
ramContactLinger = 0.35f; // still pressed: refresh, no damage
}
else
{
ramLastVictim = owner;
ramContactLinger = 0.35f;
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)
}
// Contact-state accumulator tail (decomp part_012.c:15406-15413). The
// authentic split is InitialHit(1) vs Slide(2), keyed on the two collision
// floats @0x240/0x244 -- both are still fieldAt() stubs (return 0), so the
// Slide branch can't be computed faithfully yet. A resolved contact IS an
// impact, so accumulate InitialHit(1) -- the decomp's dominant `else` branch;
// upgrade to the full 1-vs-2 split once 0x240/0x244 are reconstructed. [T3]
if (collisionTemporaryState == 0)
{
collisionTemporaryState = 1;
// (AUDIO_FIDELITY F17) capture the impact speed for the authored
// CollisionSpeed volume/brightness scales ([0,25] -> harder hits
// sound louder + brighter). |worldLinearVelocity| at the arming
// contact = the impact speed.
collisionSpeed = worldLinearVelocity.Length();
}
}
//###########################################################################
//###########################################################################
// 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;
// replicant beams: age the replicated discharge locally (a lost
// beam-END record otherwise pins the beam on forever -- see
// Emitter::ReplicantServiceBeam)
if (GetInstance() == Entity::ReplicantInstance)
em->ReplicantServiceBeam(ttl1);
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);
// PHANTOM-BEAM FORENSICS (BT_FIRE_LOG): identify every drawn beam --
// owner/instance, roster slot, class, discharge state, colour, ends.
if (getenv("BT_FIRE_LOG"))
{
static int s_bd = 0;
if ((++s_bd % 30) == 1) // ~2Hz per sustained beam
DEBUG_STREAM << "[beam-draw] mech=" << GetEntityID()
<< " inst=" << (int)GetInstance()
<< " slot=" << wi << " cid=" << wcid
<< " timer=" << em->DischargeTimer()
<< " pip=(" << r << "," << g << "," << b << ")"
<< " mz=(" << mz.x << "," << mz.y << "," << mz.z << ")"
<< " end=(" << bend.x << "," << bend.y << "," << bend.z
<< ")" << std::endl;
}
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 << ")" << std::endl;
}
}
//###########################################################################
// BTReportIncomingMissile -- complete-type bridge (F7)
//
// Missile::MoveAndCollide (missile.cpp, forward-declared Mech only) reports
// its target + range each tick; the missile-alarm attributes latch in
// Mech::PerformAndWatch. Seeker targets are always mechs in this game
// (the launcher's lock); null-guarded.
//###########################################################################
void BTReportIncomingMissile(Entity *target, Scalar range)
{
if (target != 0)
{
((Mech *)target)->ReportIncomingMissile(range);
}
}
//###########################################################################
// Rear-fire / look-view bridges (task #68)
//
// The binary's controls mapper (part_013.c:396-459) runs a five-state LOOK
// machine off the pod's look buttons: on a state change it re-aims the
// eyepoint (mech+0x360 = EyepointRotation, consumed by DPLEyeRenderable)
// and re-arms each weapon's view-fire enable (+0x3E0): FORWARD view enables
// the non-rear weapons, LOOK-BACK enables the REAR-mounted ones (+0x334,
// set from the mount segment's 'b' marker -- sitelbgunport/siterbgunport),
// side/down views disable all fire. Complete-Mech/Weapon TU bridges; the
// mapper calls BTCommitLookState on each state change.
//###########################################################################
int BTWeaponMountIsRear(void *ownerMech, int segIndex)
{
Mech *m = (Mech *)ownerMech;
if (m == 0)
return 0;
EntitySegment *seg = m->GetSegment(segIndex);
if (seg == 0)
return 0;
const char *segname = seg->GetName(); // CString -> const char*
if (segname == 0)
return 0;
// the binary marker @0x511aa2 is the single character "b" (the back
// gun-port site names are the only port names containing it)
int rear = (strstr(segname, "b") != 0) ? 1 : 0;
if (getenv("BT_PROJ_LOG")) { static int s_rn=0; if (s_rn++<40)
DEBUG_STREAM << "[rearfire] segIndex=" << segIndex << " name='"
<< segname << "' rear=" << rear << "\n" << std::flush; }
return rear;
}
// The current look-state eye component (pitch/yaw) -- stored so the per-frame
// eyepoint compose (the HUD tick above) can combine it with the live torso
// elevation. Single-viewpoint state (BTCommitLookState runs for the local
// player's mapper only), same pattern as the other viewpoint globals.
float gBTLookPitch = 0.0f;
float gBTLookYaw = 0.0f;
void BTCommitLookState(void *mech_v, int look_state)
{
Mech *m = (Mech *)mech_v;
if (m == 0)
return;
// eyepoint per state (binary case switch: yaw = the authored side angles,
// look-back = yaw pi (0x40490fdb) + lookBackAngle pitch, look-down =
// lookFrontAngle pitch)
Scalar pitch = 0.0f, yaw = 0.0f;
switch (look_state)
{
case 1: /*LookLeft*/ yaw = m->lookLeftAngle; break;
case 2: /*LookRight*/ yaw = m->lookRightAngle; break;
case 3: /*LookBehind*/ yaw = PI; // binary 0x40490fdb
pitch = m->lookBackAngle; break;
case 4: /*LookDown*/ pitch = m->lookFrontAngle; break;
default: break; // LookNone: identity
}
gBTLookPitch = (float)pitch; // per-frame compose adds elevation
gBTLookYaw = (float)yaw;
m->eyepointRotation = EulerAngles(
Radian(Radian::Normalize(pitch)),
Radian(Radian::Normalize(yaw)),
Radian(0.0f));
// weapon view-fire enables (binary: fwd = !rear, back = rear, else 0)
{
extern int BTWeaponIsRearFiring(Subsystem *sub); // -1 = not a weapon
extern void BTWeaponSetViewFireEnable(Subsystem *sub, int enable);
for (int id = 2; id < m->GetSubsystemCount(); ++id)
{
Subsystem *sub = m->GetSubsystem(id);
int rf = BTWeaponIsRearFiring(sub);
if (rf < 0)
continue;
BTWeaponSetViewFireEnable(sub,
(look_state == 0) ? (rf == 0) :
(look_state == 3) ? rf : 0);
}
}
// (task #68) the HUD pip GROUP mask (binary mech+0x390, weapon update
// part_013.c:5588-5595): forward view shows the FRONT pip group (|=1,&~2),
// look-back shows the REAR group (|=2,&~1); side/down views leave it.
// mech4's HUD tick publishes the low bits as gBTHudGroupMask each frame.
if (look_state == 0)
m->targetReticle.reticleElementMask = (Reticle::ReticleElements)
(((int)m->targetReticle.reticleElementMask | 1) & ~2);
else if (look_state == 3)
m->targetReticle.reticleElementMask = (Reticle::ReticleElements)
(((int)m->targetReticle.reticleElementMask | 2) & ~1);
if (getenv("BT_KEY_LOG"))
DEBUG_STREAM << "[look] state=" << look_state
<< " rearFiring=" << m->rearFiring
<< " yaw=" << yaw << " pitch=" << pitch << "\n" << std::flush;
}