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