//===========================================================================// // 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 // LBE4ControlsManager (the fire buttonGroup push, task #5) -- needs the // btl4app/btl4mode forward-decl order btl4mppr.cpp uses. #if !defined(BTL4APP_HPP) # include #endif #if !defined(BTL4MODE_HPP) # include #endif #if !defined(L4CTRL_HPP) # include #endif #pragma hdrstop #if !defined(MECH_HPP) # include // Mech class -- owned by mech.cpp slice #endif #if !defined(MECHMPPR_HPP) # include // MechControlsMapper -- the real-controls bridge/consumption #endif #if !defined(APP_HPP) # include #endif #if !defined(SUBSYSTM_HPP) # include // Subsystem -- the per-frame roster entry (PerformAndWatch) #endif #if !defined(EXPLODE_HPP) # include // Explosion::Make / MakeMessage -- bring-up fire effect #endif #if !defined(DAMAGE_HPP) # include // Damage -- the per-hit damage payload #endif #if !defined(MECHDMG_HPP) # include // 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 // BoundingBoxTreeNode::FindBoundingBoxUnder / ...ContainingColumn #include // BoxedSolid / BoxedSolidCollision / BoxedSolidCollisionList #include // CulturalIcon::IsStoppingCollisionVolume / GetClassDerivations #include // HostManager::GetEntityPointer (band-effect attacker resolve) #include // SubsystemMessageManager (task #7 consolidated delivery) #include // MechWeapon::GetExplosionResourceID (per-round detonation) #include // MissileLauncher::ClassDerivations (splash-radius gate) #if !defined(PLAYER_HPP) # include // Player::VehicleDeadMessage -- the death->respawn notification (task #52) #endif #if !defined(EMITTER_HPP) # include // 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; static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire button (Pinky 0x45) int gBTModeCycle = 0; // 'M' edge: cycle the control mode (mapper consumes) float gBTTwistAxis = 0.0f; // Q/E torso-twist deflection (assisted-mode stick X) int gBTTorsoRecenter = 0; // 'X' edge: pulse the authentic torso recenter (mapper consumes) static int gBTConfigKey = 0; // task #6: HOLD 'G' = the weapon-configure button static int gBTGenSelKey = 0; // task #12: F5..F8 = SelectGeneratorA..D, F9 = mode toggle // (0 idle; else the message id 4..8) static int gBTValveKey = 0; // task #13: 'C' = MoveValve on the selected condenser // Damage: each shot dispatches a REAL Entity::TakeDamageMessage to the target. Now // that the damage zones are constructed (mech.cpp Pass-3 zone build), the engine base // Entity::TakeDamageMessageHandler routes damageZones[zone]->TakeDamage -> the // reconstructed Mech__DamageZone::TakeDamage (the real armor/structure model). We aim // a VALID zone index (the Mech cylinder-lookup resolver for an unaimed/-1 hit is the // STEP-6 reconstruction). Death is driven by the REAL model (Mech::IsDisabled, the // vital-zone/leg/torso destroyed query FUN_0049fb54), NOT a hit counter. static const Scalar kShotDamage = 12.0f; // per-burst damage amount (units = armor points) static int gEnemyDestroyed = 0; // BRING-UP: the spawned target/enemy mech (defined in btplayer.cpp). The player // mech locks onto it as its current target each frame (see targeting step below). extern Entity *gEnemyMech; // gauge scoring wave: producers (btplayer.cpp) -- feed the scoreboard from combat. extern void BTPostDamageScore(Entity *victim, Scalar damage); // per-hit SCORE (ScoreInflicted) extern void BTPostKillScore(Entity *victim, Scalar damage); // KILL (+ MP death) // Mech target slots (verified vs the binary's weapon/fire path, part_013.c): // mech+0x37c Point3D current target world position (range/aim source) // mech+0x388 Entity* current target entity (HasActiveTarget gate) // mech+0x38c int targeted subsystem index (-1 = whole mech / none) // These are undocumented in the reconstructed mech.hpp member map; the fire path // (Emitter/MechWeapon) reads exactly these, so we write them directly. #define MECH_TARGET_POS(m) (*(Point3D *)((char *)(m) + 0x37c)) #define MECH_TARGET_ENTITY(m) (*(Entity **)((char *)(m) + 0x388)) #define MECH_TARGET_SUBIDX(m) (*(int *)((char *)(m) + 0x38c)) // task #8 bridge: the weapon-side damage submission (mechweap.cpp) reads the // owner's designated-zone slot; the raw target-slot block lives in this TU. int BTMechTargetZone(void *mech) { return MECH_TARGET_SUBIDX(mech); } // Bring-up body-animation player (file scope so OnBodyAnimFinished can re-arm it). // The engine AnimationInstance walks the mech's joint subsystem from a baked .ani // clip in btl4.res; we advance it each moving frame so the legs CYCLE. static AnimationInstance *gBodyAnim = 0; static int gBodyAnimReady = 0; // 0=untried 1=ok -1=failed static Scalar gBodyAnimLog = 0.0f; static int gBodyAnimLoops = 0; // count completed cycles (sanity) static int gCurrentGait = -1; // P3 STEP 2: -1=none 0=walk 1=run (bound clip) static const Scalar kRunThrottle = 0.5f; // |throttle| >= this -> run clip, else walk clip //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Mech::OnBodyAnimFinished -- AnimationInstance finished-callback. // AnimationInstance::Animate invokes (moverToAnimate->*finishedCallback) when a // clip runs off its last keyframe (JMOVER.cpp ~1592). The real game uses this to // chain/loop gaits; for bring-up we LOOP the same clip by re-arming it at frame 0. // Dropping the sub-frame carryover is imperceptible. Returns extra root movement // contributed this call (0 -- the next frame's Animate carries the gait forward). //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Scalar Mech::OnBodyAnimFinished( ResourceDescription::ResourceID animation_number, Scalar /*carryover*/, Logical /*animate_joints*/) { ++gBodyAnimLoops; if (gBodyAnim) { gBodyAnim->SetAnimation( animation_number, reinterpret_cast(&Mech::OnBodyAnimFinished)); } return 0.0f; } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // BTResolveWeaponMuzzle -- the faithful FUN_004b9948 (MechWeapon::GetMuzzlePoint) // muzzle resolve: look up the weapon's mount segment (index, from the subsystem's // inherited this+0xdc slot) in the owner Mech's segment table and transform it to // world. Lives here (a complete-Mech TU with the segment API); mechweap.cpp treats // `owner` as a raw pointer so it calls this via a void* bridge instead of the Mech API. //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ void BTResolveWeaponMuzzle(void *ownerMech, int segIndex, Point3D &out) { Mech *m = (Mech *)ownerMech; if (m == 0) { out = Point3D(0.0f, 0.0f, 0.0f); return; } EntitySegment *seg = m->GetSegment(segIndex); // owner+0x300 table, GetNth(index) if (seg != 0) { AffineMatrix mw; mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld); // segment -> world (== mech4 gun-port path) out = mw; // Point3D = matrix W_Axis translation } else out = m->localOrigin.linearPosition; // safe non-garbage fallback (owner origin) } // First vital damage-zone index (Mech__DamageZone::vitalDamageZone is protected; Mech has access). int Mech::FirstVitalZone() const { for (int k = 0; k < damageZoneCount; ++k) if (Zone(k)->vitalDamageZone) return k; return 0; } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Port-side tracked-projectile service (WAVE 7 Phase B -- flying missiles/rounds). // The 1995 Projectile/Missile WORLD-ENTITY classes cannot be revived byte-exact: // the 2007 engine Entity base is 0x1BC bytes vs the 1995 binary's 0x300, so the // reconstruction's raw base-offset reads (velocity@0x1dc, roster@0x124, motion@ // 0x250) read garbage on the engine (the same 0x638-vs-0x854 gap the Mech has, // but the entity integrator depends on those offsets). So a fired projectile is // a PORT reconstruction (like the beam renderer): seeded from the launcher's fire // with the decomp's real muzzle / launch velocity / per-shot damage, it flies // toward the target (tracer via BTPushBeam) and delivers the weapon's damage on // impact through the SAME Entity::TakeDamage path as the beam. The byte-exact // world-entity Missile (Projectile : Mover, MP-replicable) is the deeper follow-up. //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ struct BTProjectile { Point3D pos; Vector3D vel; // world velocity (authored MuzzleVelocity, steered per frame) Scalar speed; // |vel| held constant through the steer Scalar traveled; Scalar range; Entity *target; Point3D targetPos; Scalar aimOffsetY; // vertical aim offset vs the target's origin (live re-lead) Scalar damage; // <= 0 -> VISUAL-ONLY round (replicant-side salvo mirror) int guided; // 1 = missile (seeker loft + steering); 0 = ballistic (straight) Entity *shooter; // the firing mech (messmgr explosion bundling at impact) int weaponSubsys; // firing weapon's roster index (-1 = unthreaded) -- // resolves the weapon's ExplosionModelFile (mslhit/ // acanhit) in the messmgr, task #7 bundling int splashBurst; // SALVO-LEAD cluster count for splash (task #62 fix): // >0 ONLY on the first round of a missile salvo -- the // baseBurst for ONE splash event = the whole cluster // (matches the ONE arcade cluster missile). 0 on every // other round (a straight tracer / non-lead volley round) // so splash is NOT re-applied + floored-at-1 per round. int active; }; static BTProjectile gProjectiles[64]; extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float); //########################################################################### // Zone-effect bridge (mechdmg has no `application` access): route the damage- // band effect through the AUTHENTIC RendererManager::StartEntityEffect chain // (the @004d097c dispatcher; the AudioRenderer hears the same broadcast). // Returns 0 when the manager isn't up so the caller can fall back. //########################################################################### int BTStartZoneEffect(Mech *mech, void *zone, int resource) { if (mech == 0 || zone == 0 || resource <= 0 || application == 0 || application->GetRendererManager() == 0) return 0; application->GetRendererManager()->StartEntityEffect( (Entity *)mech, (DamageZone *)zone, (ResourceDescription::ResourceID)resource); return 1; } //########################################################################### // Segment world-transform bridge (the @004d097c per-zone effect dispatcher + // the attached-emitter follow in L4VIDEO's PFX layer). Resolves the entity's // segment (by index) to its world position + 3x3 basis rows; falls back to // the mech origin at torso height when the segment doesn't resolve. Returns // 0 for a non-mech / unregistered entity (the follow then keeps its last // frame -- and the emitter dies with its authored window anyway). //########################################################################### int BTResolveSegmentWorld(void *entity, int seg_index, float *pos3, float *rows9) { extern int BTIsRegisteredMech(Entity *e); if (entity == 0 || !BTIsRegisteredMech((Entity *)entity)) return 0; Mech *m = (Mech *)entity; Point3D p = m->localOrigin.linearPosition; p.y += kMuzzleHeight; // fallback: torso height if (seg_index >= 0) { EntitySegment::SegmentTableIterator it(m->segmentTable); EntitySegment *seg; while ((seg = it.ReadAndNext()) != NULL) { if (seg->GetIndex() == seg_index) { AffineMatrix mw; mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld); p = mw; // Point3D = matrix translation break; } } } pos3[0] = (float)p.x; pos3[1] = (float)p.y; pos3[2] = (float)p.z; UnitVector ax, ay, az; m->localToWorld.GetFromAxis(X_Axis, &ax); m->localToWorld.GetFromAxis(Y_Axis, &ay); m->localToWorld.GetFromAxis(Z_Axis, &az); rows9[0] = (float)ax.x; rows9[1] = (float)ax.y; rows9[2] = (float)ax.z; rows9[3] = (float)ay.x; rows9[4] = (float)ay.y; rows9[5] = (float)ay.z; rows9[6] = (float)az.x; rows9[7] = (float)az.y; rows9[8] = (float)az.z; return 1; } //########################################################################### // MUZZLE FLASH (task #61) -- the GENUINE shipped projectile-gun muzzle effect. // // BTDPL.INI documents psfx 6 (external) / 14 (internal) = DAFC.PFX as "the // effect used on all projectile guns": an orange fire-smoke blast // (btfx:firesmoke1, maxIssue 25 / releasePeriod ~0.2s -> the emitter auto- // expires after one ~0.2s burst = a per-shot muzzle flash). The AC is the // only weapon that gets it -- lasers show their beam, missiles their launch. // NOT the cut MUZFLASH.BGF card (that asset is orphaned; the shipped flash is // this particle). Disasm of ProjectileWeapon::FireWeapon (@0x4bc104) shows no // direct spawn there, so the fire edge is the faithful hook; the effect, // asset, and gun-port attach point are all confirmed shipped [T1 asset/INI]. // // Attached to the gun-port SEGMENT (BTResolveSegmentWorld re-resolves the // muzzle position + mech body frame each frame): DAFC sprays -Z, and the mech // faces -Z, so the fire-smoke blows forward out the barrel. //########################################################################### void BTFlashMuzzle(void *ownerMech, int seg_index, float mx, float my, float mz) { if (ownerMech == 0) return; float pos[3], rows[9]; if (!BTResolveSegmentWorld(ownerMech, seg_index, pos, rows)) return; extern void BTStartPfxAttached(int, void *, int, float, float, float, const float *); // psfx 6 = DAFC (external); the day table maps both 6 and 14 to DAFC.PFX, // so the external slot serves every viewer. BTStartPfxAttached(6, ownerMech, seg_index, mx, my, mz, rows); if (getenv("BT_MUZZLE_LOG")) DEBUG_STREAM << "[muzzle] DAFC flash seg=" << seg_index << " at(" << mx << "," << my << "," << mz << ")\n" << std::flush; } //########################################################################### // PER-ROUND DETONATION (the binary's Missile::MoveAndCollide @004bef78: every // round spawns ITS OWN ExplosionModelFile at its impact point, resource // missile+0x33c -- a volley RIPPLES fireballs across its arrival frames, the // demo look). Resolved from the firing weapon's roster entry (weapon+0x3E4); // runs on BOTH nodes (the replicant visual salvo passes its launcher's index // too). The messmgr's one bundled explosion remains (direct-fire semantics + // the cross-pod damage stream); among a rippled volley it is invisible. //########################################################################### static void BTSpawnRoundDetonation(Entity *shooter, int weapon_subsys, const Point3D &at) { extern int BTIsRegisteredMech(Entity *e); if (shooter == 0 || weapon_subsys < 0 || !BTIsRegisteredMech(shooter)) return; Mech *sm = (Mech *)shooter; if (weapon_subsys >= sm->GetSubsystemCount()) return; Subsystem *w = sm->GetSubsystem(weapon_subsys); if (w == 0 || !w->IsDerivedFrom(*MechWeapon::GetClassDerivations())) return; ResourceDescription::ResourceID res = ((MechWeapon *)w)->GetExplosionResourceID(); if (res <= 0) return; Origin o; o.linearPosition = at; o.angularPosition = EulerAngles(Radian(0.0f), Radian(0.0f), Radian(0.0f)); Explosion::MakeMessage m( Explosion::MakeMessageID, sizeof(Explosion::MakeMessage), (Entity::ClassID)RegisteredClass::ExplosionClassID, EntityID::Null, res, Explosion::DefaultFlags, o, sm->GetEntityID(), sm->GetEntityID()); Explosion *e = Explosion::Make(&m); if (e) Register_Object(e); } //########################################################################### // SPLASH DAMAGE (task #62) -- Explosion::SplashDamage @0042fad0 / engine // EXPLODE.cpp:50-254 [T0]. A detonating projectile's blast damages every mech // within its authored SplashRadius. Damage MODEL (T0 EXPLODE.cpp:209-246, // decomp part_004.c:856-911): the per-burst AMOUNT + damage TYPE pass through // UNCHANGED; the distance falloff is applied to the BURST COUNT -- // bursts = round( baseBurst / dist^1.25 ), floored at 1 // (arcade exponent 1.25 -- part_004.c:865 = 0x3ff40000; the WinTesla source // EXPLODE.cpp:209 drifted to 1.2f -- we use the arcade value). So a mech // inside the radius takes the round's damage; a point-blank one takes extra // bursts. Base burst = the incoming Damage's burstCount (Damage+0x2c). // // The SplashRadius is the ROUND's GameModel resource +0x50. ONLY missiles // splash: in the arcade a MissileLauncher spawns a Missile ENTITY whose Perform // (part_013.c:9887) is the sole SplashDamage caller (@10097); the AC's tracer is // not a Missile and never splashes. The Missile's model id comes from the // launcher's linked AmmoBin (ammoModelFile @0x1e8, part_013.c:8778), NOT the // launcher's ExplosionModelFile; the Missile ctor reads SplashRadius from that // model's type-0xf record +0x50 (@10184). So the port gates on MissileLauncher // and resolves radius via launcher -> AmmoBin -> round model -> +0x50. // // PORT: the binary enumerates interest-zone movers; reconstructed mechs aren't // reliably registered there, so we walk the live-mech registry // (BTGetTargetCandidates, which already excludes the shooter -- no self-splash) // -- guaranteed populated + cheaper. The DIRECT victim is excluded (it already // took the direct hit; matches EXPLODE.cpp:179 excluding entityHit) so it is // not double-damaged. Delivery reuses the direct-hit path (messmgr bundle / // Dispatch), so replicant victims reroute cross-pod. //########################################################################### static Scalar BTResolveSplashRadius(Entity *shooter, int weapon_subsys) { extern int BTIsRegisteredMech(Entity *e); if (shooter == 0 || weapon_subsys < 0 || application == 0 || application->GetResourceFile() == 0 || !BTIsRegisteredMech(shooter)) return 0.0f; Mech *sm = (Mech *)shooter; if (weapon_subsys >= sm->GetSubsystemCount()) return 0.0f; Subsystem *w = sm->GetSubsystem(weapon_subsys); // ONLY missile launchers spawn Missile entities that carry splash (see banner). if (w == 0 || !w->IsDerivedFrom(MissileLauncher::ClassDerivations)) return 0.0f; // The round's model id lives on the launcher's linked AmmoBin (+0x1e8), // resolved through the same ammoBinLink the fire path uses. extern void *BTWeaponAmmoBin(void *weapon); extern int BTAmmoRoundModelResource(void *bin); void *bin = BTWeaponAmmoBin(w); if (bin == 0) return 0.0f; ResourceDescription::ResourceID res = (ResourceDescription::ResourceID)BTAmmoRoundModelResource(bin); if (res <= 0) return 0.0f; // The round's GameModel record (type 0xf): SplashRadius @ +0x50 (binary // part_013.c:10184 reads resource_data+0x50; the MissileThruster parser // FUN_004bf8ec writes "SplashRadius" to +0x50 of the type-0xf/size-0x54 record). ResourceDescription *rd = application->GetResourceFile()->SearchList( res, ResourceDescription::GameModelResourceType); if (rd == 0) return 0.0f; rd->Lock(); Scalar radius = *(const Scalar *)((const unsigned char *)rd->resourceAddress + 0x50); rd->Unlock(); if (!(radius > 0.0f) || radius > 1.0e4f) // NaN / garbage / no-splash guard radius = 0.0f; return radius; } void BTApplySplashDamage(Entity *shooter, int weapon_subsys, const Point3D ¢er, Entity *directVictim, const Damage &dmgTemplate) { Scalar radius = BTResolveSplashRadius(shooter, weapon_subsys); static const int s_log = getenv("BT_SPLASH_LOG") ? 1 : 0; if (s_log) DEBUG_STREAM << "[splash] HOOK weapon=" << weapon_subsys << " radius=" << radius << "\n" << std::flush; if (radius <= 0.0f) return; // this weapon authors no splash extern int BTIsRegisteredMech(Entity *e); extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut); Entity *cand[32]; int nc = BTGetTargetCandidates(shooter, cand, 32); // excludes the shooter const Scalar baseBurst = (Scalar)dmgTemplate.burstCount; if (s_log) DEBUG_STREAM << "[splash] detonation radius=" << radius << " candidates=" << nc << " directVictim=" << (void *)directVictim << "\n" << std::flush; for (int ci = 0; ci < nc; ++ci) { Entity *e = cand[ci]; if (e == 0 || e == directVictim || !BTIsRegisteredMech(e)) continue; Mech *m = (Mech *)e; if (m->IsMechDestroyed() || m->damageZoneCount <= 0) continue; Point3D p = m->localOrigin.linearPosition; Scalar dx = p.x - center.x, dy = p.y - center.y, dz = p.z - center.z; Scalar dist = (Scalar)sqrtf((float)(dx*dx + dy*dy + dz*dz)); if (dist > radius) // RADIUS GATE -- required: the continue; // falloff floors at 1, so without // this every mech would take a burst // EXPLODE.cpp:209 -- burstCount = baseBurst / dist^exp, floored at 1. // Exponent is the ARCADE 1.25 (decomp 0x3ff40000); the WinTesla source // drifted to 1.2f. Point-blank AMPLIFIES (dist<1 -> >baseBurst bursts) -- // authentic; a real splash victim is never at dist~0 (the mech AT the // blast is the excluded direct victim), so the div-guard epsilon (mechs // have body size) only ever fires in the synthetic BT_SPLASH_TEST. Scalar d = (dist > 0.01f) ? dist : 0.01f; // div-guard (no arcade floor) int bursts = (int)(0.5f + baseBurst / powf((float)d, 1.25f)); // arcade ROUND if (bursts < 1) bursts = 1; // EXPLODE.cpp:210 Min_Clamp Damage dmg = dmgTemplate; // amount + type pass through dmg.burstCount = bursts; dmg.impactPoint = center; dmg.damageForce = Vector3D(dx, dy, dz); // EXPLODE.cpp:216 radial knockback // DIRECT dispatch to EACH victim (T0 EXPLODE.cpp:246 -- target->Dispatch). // NOT the shooter's SubsystemMessageManager: AddDamageMessage CONSOLIDATES // every damage message of the frame onto ONE common entity (the first hit, // messmgr.cpp:279) -- so routing splash through it delivered the bystander's // blast onto the DIRECT victim instead (double-hitting the excluded mech). // Dispatch reroutes cross-pod for a replicant victim on its own. Entity::TakeDamageMessage td( Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, dmg); e->Dispatch(&td); if (s_log) DEBUG_STREAM << "[splash] victim=" << e->GetEntityID() << " dist=" << dist << " radius=" << radius << " bursts=" << bursts << " amount=" << dmg.damageAmount << " baseBurst=" << (int)baseBurst << "\n" << std::flush; } } // Called from ProjectileWeapon / MissileLauncher::FireWeapon (via the extern below) // with the live muzzle, the SHOOTER mech (to resolve the real launch port), the owner's // locked target entity + point, the launch speed (|muzzleVelocity|), and per-shot damage. void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos, Scalar speed, Scalar damage, const Vector3D *launch_velocity, int guided, int weapon_subsys, int splash_burst) { // MUZZLE (muzzle wave, 2026-07-12): the passed muzzle is now the weapon's // AUTHENTIC mount segment (GetMuzzlePoint reads the real segmentIndex -- // the old raw-offset garbage that made it resolve feet/heads is fixed), so // the port-name rotation hack that compensated for it is RETIRED. Keep // one safety: a degenerate resolve at/below origin height (an unposed // segment) launches from torso height instead of underground. Point3D mz = muzzle; if (shooter != 0) { Mech *sm = (Mech *)shooter; if (mz.y - sm->localOrigin.linearPosition.y < 1.0f) { mz = sm->localOrigin.linearPosition; mz.y += 12.0f; } } // AUTHENTIC (task #36): NO fallback target. The launcher passes the mech's // own designated-target slots (owner+0x388 via GetTargetPosition); with no // designation the missile does not launch -- the acquisition (crosshair on // the enemy -> pick -> designate) is the ONLY route to a target, exactly // like the energy weapons. (The old gEnemyMech fallback pre-dated the // acquisition and let missiles bypass it.) Point3D tpos = targetPos; // A VISUAL round (damage <= 0, the replicant-side salvo mirror) flies on the // replicated aim POINT alone; a live round still requires the designation. if (target == 0 && damage > 0.0f) return; Vector3D d; d.x = tpos.x - mz.x; d.y = tpos.y - mz.y; d.z = tpos.z - mz.z; Scalar len = (Scalar)sqrtf(d.x*d.x + d.y*d.y + d.z*d.z); if (getenv("BT_PROJ_LOG")) { Scalar relY = (shooter != 0) ? (mz.y - ((Mech *)shooter)->localOrigin.linearPosition.y) : mz.y; DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target << " len=" << len << " speed=" << speed << " dmg=" << damage << " mz=(" << mz.x << "," << mz.y << "," << mz.z << ") relY=" << relY << " lv=" << (launch_velocity ? "(auth)" : "(fallback)") << "\n" << std::flush; } if (len < 0.001f) return; for (int i = 0; i < 64; ++i) { if (gProjectiles[i].active) continue; BTProjectile &p = gProjectiles[i]; p.pos = mz; // resolved launch port p.speed = (speed > 1.0f) ? speed : 120.0f; // |launchVelocity|; sane fallback // AUTHENTIC LAUNCH (missile-arc wave): the launcher's authored // MuzzleVelocity is a VECTOR in the shooter's frame (typically // up-tilted); rotate it into world by the shooter's pose so the round // leaves the rack climbing -- the front half of the pod's missile arc. // (The old code collapsed it to |v| along the straight line to the // target: dead-flat flight.) Fall back to the straight line when no // authored vector reaches us (non-missile callers). int haveLaunch = 0; if (launch_velocity != 0 && shooter != 0) { // FRAME CONVENTION (telemetry-verified 2026-07-12): the authored // MuzzleVelocity is (0,0,+100) -- the launcher frame's +Z is // FORWARD, but the mech's body frame faces -Z. Transforming +Z // through the body basis launched every round BACKWARD (then the // seeker looped it through the ground onto the target -- // user-reported). Map launcher-forward (+Z) onto body-forward // (-Z basis): negate the z term. Mech *sm = (Mech *)shooter; UnitVector ax, ay, az; sm->localToWorld.GetFromAxis(X_Axis, &ax); sm->localToWorld.GetFromAxis(Y_Axis, &ay); sm->localToWorld.GetFromAxis(Z_Axis, &az); p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y - az.x*launch_velocity->z; p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y - az.y*launch_velocity->z; p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y - az.z*launch_velocity->z; Scalar lv = (Scalar)sqrtf(p.vel.x*p.vel.x + p.vel.y*p.vel.y + p.vel.z*p.vel.z); if (lv > 1.0f) { p.speed = lv; haveLaunch = 1; } } if (!haveLaunch) { p.vel.x = d.x/len*p.speed; p.vel.y = d.y/len*p.speed; p.vel.z = d.z/len*p.speed; } p.traveled = 0.0f; p.range = len * 1.3f + 60.0f; // arc margin; expire past the target p.target = (Entity *)target; p.targetPos = tpos; // resolved target position (fallback-aware) // live re-lead (authentic: the Seeker re-leads the MOVING target every // slice): remember the aim's height above the target's origin so the // per-frame refresh keeps striking the same body height. extern int BTIsRegisteredMech(Entity *e); p.aimOffsetY = (target != 0 && BTIsRegisteredMech((Entity *)target)) ? (tpos.y - ((Mech *)target)->localOrigin.linearPosition.y) : 0.0f; p.damage = damage; p.guided = guided; // autocannon shells fly straight (no seeker in the binary's plain Projectile) p.shooter = (Entity *)shooter; p.weaponSubsys = weapon_subsys; p.splashBurst = splash_burst; // >0 only on a salvo-lead round // RACK-TUBE SPREAD [T3, physically grounded]: the binary's Missile // entities each launch from their own rack tube (per-tube authored // MuzzlePosition); our single muzzle superimposed a whole salvo onto // ONE trajectory -- 12 rounds read as one round, and their 12 // detonations stacked into one frame. Approximate the tube offsets // with a small deterministic per-slot cone (+-~2.5 deg) + lateral // muzzle offset; the seeker re-converges them onto the target so the // volley arrives as the demo's RIPPLE of impacts. // GUIDED ROUNDS ONLY: a ballistic shell has no seeker to re-converge // it -- the slot-0 pattern deflected every AFC100 shell a fixed // 3.4deg left / 2.5deg down (the phantom "4th gold beam" burying its // tracer beside the target, user-reported). The binary's plain // Projectile flies straight at the pick. if (guided) { float ja = ((i % 5) - 2) * 0.030f; // yaw +-0.06 rad float jb = (((i / 5) % 5) - 2) * 0.022f; // pitch +-0.044 rad float vx = p.vel.x, vy = p.vel.y, vz = p.vel.z; float ca = cosf(ja), sa = sinf(ja); p.vel.x = vx * ca - vz * sa; p.vel.z = vx * sa + vz * ca; p.vel.y = vy + jb * p.speed; p.pos.x += ((i % 3) - 1) * 0.6f; p.pos.z += (((i / 3) % 3) - 1) * 0.6f; } p.active = 1; if (getenv("BT_PROJ_LOG")) DEBUG_STREAM << "[projectile] vel=(" << p.vel.x << "," << p.vel.y << "," << p.vel.z << ")" << (launch_velocity ? " authored=(" : " none=(") << (launch_velocity ? launch_velocity->x : 0.0f) << "," << (launch_velocity ? launch_velocity->y : 0.0f) << "," << (launch_velocity ? launch_velocity->z : 0.0f) << ")" << std::endl; return; } } // Per-frame (viewpoint mech): advance each projectile, draw its tracer, deliver damage on impact. static void BTUpdateProjectiles(Scalar dt) { for (int i = 0; i < 64; ++i) { BTProjectile &p = gProjectiles[i]; if (!p.active) continue; Point3D prev = p.pos; // AUTHENTIC GUIDANCE (missile-arc wave; Seeker::LeadTarget @004beae4 + // the @004bef78 steering [T1]). The seeker LOFTS the aim point while // the round is far out -- beyond 200 m the aim rises by // 0.1 x clamp(range-200, 0, 300) (up to +30 m above the target) -- so // the missile climbs at range and dives as it closes: the pod's // "gravity arc". Steering is the port shape of the binary's // squared-error guidance: rotate the velocity toward the lofted aim at // the decomp turn gain (MissileTurnGain = 4.0, _DAT_004bf5a4), speed // held at the authored launch speed. if (p.guided) { // LIVE RE-LEAD (authentic: Seeker::LeadTarget runs every slice on // the MOVING target): refresh the aim from the target's current // position, preserving the launch aim's body height. extern int BTIsRegisteredMech(Entity *e); if (p.target != 0 && BTIsRegisteredMech(p.target) && !((Mech *)p.target)->IsMechDestroyed()) { p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition; p.targetPos.y += p.aimOffsetY; } Point3D aim = p.targetPos; Scalar rx = aim.x - p.pos.x, ry = aim.y - p.pos.y, rz = aim.z - p.pos.z; Scalar range = (Scalar)sqrtf(rx*rx + ry*ry + rz*rz); if (range > 200.0f) // SeekerLeadMinRange @004bec18 { Scalar lead = range - 200.0f; if (lead > 300.0f) lead = 300.0f; // SeekerLeadMaxClamp @004bec24 aim.y += 0.1f * lead; // SeekerLeadCoef @004bec28 ry = aim.y - p.pos.y; } if (range > 0.001f) { Scalar inv = 1.0f / range; Scalar cx = p.vel.x / p.speed, cy = p.vel.y / p.speed, cz = p.vel.z / p.speed; // MissileTurnGain (4.0, _DAT_004bf5a4) far out; DOUBLED inside // the no-loft radius so the dive converges onto the contact // sphere (the port shape of the binary's squared-error terminal // aggressiveness -- error^2 steering climbs steeply near boresight). Scalar k = ((range < 200.0f) ? 8.0f : 4.0f) * dt; cx += (rx*inv - cx) * k; cy += (ry*inv - cy) * k; cz += (rz*inv - cz) * k; Scalar cl = (Scalar)sqrtf(cx*cx + cy*cy + cz*cz); if (cl > 0.001f) { p.vel.x = cx/cl*p.speed; p.vel.y = cy/cl*p.speed; p.vel.z = cz/cl*p.speed; } } } p.pos.x += p.vel.x*dt; p.pos.y += p.vel.y*dt; p.pos.z += p.vel.z*dt; p.traveled += p.speed * dt; // WORLD IMPACT (authentic: the binary missile runs a world collision // query every frame, FUN_0042291c, and DETONATES on geometry). Ray the // flight step against the terrain/cave solids -- a lofted round in a // low cavern bursts on the CEILING instead of punching through it. { extern bool BTGroundRayHit(float,float,float, float,float,float, float, float*,float*,float*); extern Entity *gBTTerrainEntity; // captured by MakeEntityRenderables Scalar step = p.speed * dt; if (gBTTerrainEntity != 0 && step > 0.0001f) { Vector3D rd; rd.x = p.vel.x/p.speed; rd.y = p.vel.y/p.speed; rd.z = p.vel.z/p.speed; float hx, hy, hz; if (BTGroundRayHit(prev.x, prev.y, prev.z, rd.x, rd.y, rd.z, step + 1.0f, &hx, &hy, &hz)) { // burst on the rock: the round's own DETONATION (the binary // missile detonates on ANY geometry, @004bef78) + a tight // puff cluster, no damage { Point3D hp; hp.x = hx; hp.y = hy; hp.z = hz; BTSpawnRoundDetonation(p.shooter, p.weaponSubsys, hp); // SPLASH (task #62): the binary missile detonates on ANY // geometry and still runs SplashDamage (the +0x360 gate sits // inside the collision branch @part_013.c:10045, which fires // for a world hit as well as a mover hit). A missile that // bursts on terrain near a mech still catches it in the blast. // ONCE per salvo (splashBurst>0), baseBurst = the cluster count; // no direct victim here. No-op for non-missile rounds. if (p.damage > 0.0f && p.splashBurst > 0) { Damage sdmg; sdmg.damageType = Damage::ExplosiveDamageType; sdmg.damageAmount = p.damage; sdmg.burstCount = p.splashBurst; // cluster count as baseBurst sdmg.impactPoint = hp; extern void BTApplySplashDamage(Entity *, int, const Point3D &, Entity *, const Damage &); BTApplySplashDamage(p.shooter, p.weaponSubsys, hp, 0, sdmg); } } extern void BTPfxTrailPuff(int, float, float, float, float, float, float, int); for (int pf = 0; pf < 4; ++pf) BTPfxTrailPuff(0, hx, hy, hz, -rd.x, -rd.y, -rd.z, 2); if (getenv("BT_PROJ_LOG")) DEBUG_STREAM << "[projectile] WORLD burst at(" << hx << "," << hy << "," << hz << ")" << std::endl; p.active = 0; continue; } } } // AUTHENTIC missile look: the round is a short hot streak, and the trail // is the real dsrm smoke-trail effect (psfx 0, "the lrm smoke trail") // puffed along the flight path each frame with local +Z = backward (the // .PFX's velocities stream the smoke behind the round). This replaces // the old 3-segment white tracer lines (a bring-up placeholder). Vector3D bd; bd.x = p.vel.x/p.speed; bd.y = p.vel.y/p.speed; bd.z = p.vel.z/p.speed; BTPushBeam(prev.x, prev.y, prev.z, p.pos.x, p.pos.y, p.pos.z, 0x00FFB040u, 0.10f, 0.9f); // the round (hot streak) { extern void BTPfxTrailPuff(int, float, float, float, float, float, float, int); BTPfxTrailPuff(0, prev.x, prev.y, prev.z, -bd.x, -bd.y, -bd.z, 2); } // CONTACT-ONLY damage (user-verified bug: an arcing round that ran out // its flight cap while still descending "applied damage without making // contact"). Proximity = the hit; the flight-cap expiry is a FIZZLE -- // no damage, matching the binary (a missile that dies mid-air detonates // nothing; only the world-collision hit spawns the Damage entity). Scalar dx = p.targetPos.x - p.pos.x, dy = p.targetPos.y - p.pos.y, dz = p.targetPos.z - p.pos.z; const int contact = (dx*dx + dy*dy + dz*dz < (10.0f*10.0f)); if (!contact && p.traveled >= p.range) { if (getenv("BT_PROJ_LOG")) DEBUG_STREAM << "[projectile] FIZZLE (flight cap, no contact)\n" << std::flush; p.active = 0; continue; } if (contact) { BTSpawnRoundDetonation(p.shooter, p.weaponSubsys, p.pos); Entity *tgt = p.target; // Deliver to the projectile's target mech -- the launcher set p.target // from the shooter's 0x388 slot (the picked victim; any peer mech in // MP, task #46). A replicant target reroutes cross-pod via Dispatch. extern int BTIsRegisteredMech(Entity *e); if (tgt != 0 && BTIsRegisteredMech(tgt) && p.damage > 0.0f) { Mech *m = (Mech *)tgt; if (m->damageZoneCount > 0) { // UNAIMED (STEP 6): the projectile's world impact position IS the // hit point; Mech::TakeDamageMessageHandler resolves the struck // zone from the cylinder hit-location table. (Previously this // aimed the internal vital zone directly -> invisible insta-kill.) Damage dmg; dmg.damageType = Damage::ExplosiveDamageType; dmg.damageAmount = p.damage; dmg.burstCount = 1; dmg.impactPoint = p.pos; // Route through the SHOOTER's SubsystemMessageManager with the // firing launcher's roster index (task #7 bundling): the // consolidation resolves roster[id]+0x3E4 = the weapon's // ExplosionModelFile (mslhit/acanhit) and fires it AT the // impact point -- the missing missile-hit explosion (the // laser path always did this via SendDamageMessage; the // projectile path bypassed the manager entirely). SubsystemMessageManager *mgr = 0; if (p.shooter != 0 && p.weaponSubsys >= 0 && BTIsRegisteredMech(p.shooter)) mgr = (SubsystemMessageManager *)((Mech *)p.shooter)->GetMessageManager(); if (mgr != 0) { Entity::TakeDamageMessage take_damage( Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), p.shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, dmg, p.weaponSubsys); mgr->AddDamageMessage(tgt, &take_damage); } else { Entity::TakeDamageMessage take_damage( Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), 0 /*inflictor id: bring-up*/, -1 /*unaimed -> cylinder resolves*/, dmg); tgt->Dispatch(&take_damage); } // gauge scoring wave (Step 6): a projectile hit credits SCORE too // (tgt == gEnemyMech here; local player is the viewpoint shooter). BTPostDamageScore((Entity *)tgt, p.damage); DEBUG_STREAM << "[projectile] IMPACT damage=" << p.damage << " subsys=" << p.weaponSubsys << (mgr ? " (msgmgr bundled)" : " (direct)") << " (zone cyl-resolved)\n" << std::flush; // SPLASH (task #62): a detonating missile SALVO damages every // OTHER mech in the blast (tgt excluded -- it took the direct hit // above). Fires ONCE per salvo, on the salvo-LEAD round only // (splashBurst>0), with baseBurst = the whole cluster count -- the // ONE arcade cluster missile. The other visual rounds of the // salvo carry splashBurst=0 so the falloff floor-at-1 is applied // once, not N times (the missileCount-x over-splash bug). if (p.splashBurst > 0) { Damage sdmg = dmg; sdmg.burstCount = p.splashBurst; // cluster count as baseBurst extern void BTApplySplashDamage(Entity *, int, const Point3D &, Entity *, const Damage &); BTApplySplashDamage(p.shooter, p.weaponSubsys, p.pos, tgt, sdmg); } } } p.active = 0; } } } //########################################################################### // MechDeathHandler effect-spawn bridge (mechdmg.cpp calls this) // // Spawns a damage-state descriptor's explosion at the mech. The authentic path // (FUN_0043663c -> FUN_004364e4) broadcasts a class-5 message to the effect // manager app+0x38 (the 0xBD3 SubsystemMessageManager, unreconstructed); we use // the same established Explosion::Make port the weapon path uses. The binary // derives the position from the subsystem (mech+0x184); we use the mech origin. //########################################################################### void BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_index) { if (mech == 0) return; ResourceDescription::ResourceID res = (ResourceDescription::ResourceID)effect_resource; if (res <= 0) res = gExplodeRes; // fall back to the resolved generic explosion if (res <= 0) return; // nothing to spawn yet // // Effect position: the damaged zone's SEGMENT, in world space (the binary // derives the effect position from the damaged subsystem/zone, not the mech // origin -- an origin-anchored effect burns at ground level between the // feet). Resolve segment_index through the segment table exactly as the // gun-port muzzles do (GetSegmentToEntity x localToWorld); fall back to // torso height over the origin when the zone has no segment. // Origin o = mech->localOrigin; Point3D fxPos = o.linearPosition; fxPos.y += kMuzzleHeight; // default: torso height if (segment_index >= 0) { EntitySegment::SegmentTableIterator it(mech->segmentTable); EntitySegment *seg; while ((seg = it.ReadAndNext()) != NULL) { if (seg->GetIndex() == segment_index) { AffineMatrix mw; mw.Multiply(seg->GetSegmentToEntity(), mech->localToWorld); fxPos = mw; // Point3D = matrix translation break; } } } o.linearPosition = fxPos; // IMPACT FRAME for the band effect: orient local -Z from the victim toward // the LAST ATTACKER (lastInflictingID, maintained by TakeDamageMessageHandler) // -- the .PFX offsets are authored "out of the struck armor". Falls back to // the victim's own frame when the attacker can't be resolved. if (application != 0 && application->GetHostManager() != 0) { Entity *attacker = application->GetHostManager()->GetEntityPointer(mech->lastInflictingID); if (attacker != 0 && attacker != (Entity *)mech) { float adx = (float)(mech->localOrigin.linearPosition.x - attacker->localOrigin.linearPosition.x); float adz = (float)(mech->localOrigin.linearPosition.z - attacker->localOrigin.linearPosition.z); if (adx * adx + adz * adz > 1e-6f) o.angularPosition = // -Z at the attacker EulerAngles(0.0f, (Scalar)atan2((double)adx, (double)adz), 0.0f); } } Explosion::MakeMessage m( Explosion::MakeMessageID, sizeof(Explosion::MakeMessage), (Entity::ClassID)RegisteredClass::ExplosionClassID, EntityID::Null, res, Explosion::DefaultFlags, o, mech->GetEntityID(), mech->GetEntityID()); Explosion *e = Explosion::Make(&m); if (e) Register_Object(e); } //########################################################################### // Death sequence -- the un-exported master-perf death branch (region // 0x4a9770-0x4ab188), reconstructed from its EXPORTED consumers + the RP // VTV::DeathShutdown analog. See context/combat-damage.md "Death SEQUENCE". // // On a vital kill the damage side raises graphicAlarm to 9 (mechdmg.cpp:426/586). // The authentic per-frame death transition (movementMode 5-8 = collapse -> 2/9 = // disabled) lives in the Simulate the bring-up drive bypasses, so it is run here. //########################################################################### Logical Mech::IsMechDestroyed() { // AUTHENTIC LATCH first: IsDestroyed == (movementMode 2 || 9) -- the death // modes (FUN_004ab1c8 zeroes locomotion for exactly these) [T1]. The death // modes never revert, so a wreck STAYS destroyed. // // graphicAlarm >= 9 is only the vital-kill TRIGGER that enters the death // transition (mechdmg.cpp:407/426). It is a STATUS indicator, not a latch: // a later leg hit on the wreck legitimately rewrites it to 4/3 // (mechdmg.cpp:417/419), which -- when this predicate was the alarm alone -- // "resurrected" the wreck (movementMode reverted to 1) and let the next // vital hit run the WHOLE death transition again: double kill score, and a // Score dispatched into the respawn window's severed playerVehicle -> the // engine's Check(playerVehicle) abort (task #52 cdb catch). if (MovementMode() == 2 || MovementMode() == 9) return True; return graphicAlarm.GetLevel() >= 9 ? True : False; } //########################################################################### // Mech::Reset (@0049fb74) -- the respawn RESET. The authentic respawn REUSES // the same mech entity: it moves the mech to the new drop-zone origin AND heals // it back to a clean, alive state. It does NOT create a new mech (our old // sever-and-create respawn left the wreck behind as a second entity and built a // duplicate viewpoint -- the source of the "2 mechs / camera-inside / on-fire // respawn" glitches). // // Faithful reproduction of FUN_0049fb74, adapted to OUR layout: the 1995 raw // offsets the decomp writes (+0x260/+0x298/+0x12c/...) land on DIFFERENT engine // fields in the 2007 base (projectedOrigin/projectedVelocity/updateOrigin) and // on our RELOCATED gait accumulators, so we reset the equivalent NAMED members // rather than the raw offsets (per the databinding rule). Steps mirror the // decomp: reposition + kill dead-reckon; clear the death latch; heal every // damage zone; DeathReset (vtable+0x28) every subsystem; ForceUpdate to // broadcast the reset state to replicants. //########################################################################### void Mech::Reset(const Origin &origin, int mode) { Check(this); // --- reposition (localOrigin @0x100, localToWorld @0xd0 rebuilt) --- localOrigin = origin; localToWorld = origin; // --- kill dead-reckon / replication motion so the reset mech SNAPS to the // drop zone and its replicant stops lerping back toward the death spot // (the 2007 Mover-base fields at the decomp's +0x260/+0x298/+0x12c) --- projectedOrigin = origin; previousOrigin = origin; projectedVelocity = Motion::Identity; updateVelocity = Motion::Identity; updateAcceleration = Motion::Identity; // The Mover motion scalars too (the binary Reset zeroes the whole motion // block): a respawn is a TELEPORT -- stale death-frame velocity must never // survive into the first post-respawn collision/publish. worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f); localVelocity = Motion::Identity; frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f); ramLastVictim = 0; ramContactLinger = 0.0f; // StopAllEntityEffects (@004d0c14): a respawned mech must not trail its // corpse's attached zone effects -- the authentic per-entity effect // cleanup, broadcast to every renderer. if (application != 0 && application->GetRendererManager() != 0) application->GetRendererManager()->StopAllEntityEffects((Entity *)this); // --- our RELOCATED gait/motion accumulators -> identity (the 1995 offsets // the decomp zeroes map to these named members in our layout) --- ReconQuatIdentity(&motionDelta, &kIdentityQuat); ReconQuatIdentity(&worldPose, &kIdentityQuat); ReconQuatIdentity(&worldPoseBase, &kIdentityQuat); ReconQuatIdentity(&angularAccum, &kIdentityQuat); ReconQuatIdentity(&aimRate, &kIdentityQuat); // --- CLEAR THE DEATH LATCH (binary FUN_0049fb74: FUN_0041bbd8(+0x2c, 0) // + the motion/replication scalars zeroed) --- SetMovementMode(0); // simulationState 0 = init/reset; the // drive re-selects 1 next frame [T1] legCycleSpeed = 0.0f; // @0x348 (decomp Reset zeroes these) bodyCycleSpeed = 0.0f; // @0x6b8 bodyTargetSpeed = 0.0f; // @0x6b4 poseSyncLatch = 0; // @0x77c graphicAlarm.SetLevel(0); // clear >=9 (the vital-kill trigger) // --- HEAL every damage zone: full structure, intact skin, no burning --- for (int z = 0; z < damageZoneCount; ++z) { Mech__DamageZone *zone = Zone(z); if (zone != 0) zone->Heal(); } // --- RESET every subsystem (the decomp's loop-2 vtable+0x28 = DeathReset, // the base virtual symmetric with the DeathShutdown we run on death): // restores each subsystem's heat/power/ammo/charge to the initial state --- for (int i = 0; i < GetSubsystemCount(); ++i) { Subsystem *s = GetSubsystem(i); if (s != 0) s->DeathReset(mode); } // --- locomotion pre-run + interest gates (a reset master must tick) --- SetPreRunFlag(); if (interestCount == 0) interestCount = 1; // --- RENDER un-wreck: drop the sunk dbr hulk + restore the intact body --- // (the wreck swap is one-way on the render side; Reset heals the sim but // the mech keeps rendering as the sunk hulk without this). { extern void BTRebuildMechModel(Entity *entity); BTRebuildMechModel((Entity *)this); } // --- broadcast the reset state to replicants: the binary Reset tail is // ForceUpdate(0x1f) -- record types 0-4 in one shot (pose + damage // zones + speed + leg-state + orientation resync), replicant-gated on // (flags+0x28 & 0xc) != 4. Bit 1 re-broadcasts the healed damage // zones: the falling edge MechDeathHandler::Tick uses to un-wreck + // warp the peer on the observer's screen. [T1 @0x49fdfc] if (GetInstance() != ReplicantInstance) ForceUpdate(0x1f); if (getenv("BT_DEATH_LOG")) DEBUG_STREAM << "[respawn] Mech::Reset " << GetEntityID() << " healed+moved to (" << localOrigin.linearPosition.x << "," << localOrigin.linearPosition.y << "," << localOrigin.linearPosition.z << ") alive=" << (int)(!IsMechDestroyed()) << " zones=" << damageZoneCount << " subsys=" << GetSubsystemCount() << "\n" << std::flush; Check_Fpu(); } void Mech::UpdateDeathState(Scalar dt) { if (!IsMechDestroyed()) // alive return; if (MovementMode() == 9) // already settled (disabled/frozen) { // WRECK SMOKE (port addition, [T3]): keep the dead hulk smoking -- re-fire // the death/rubble smoke plume (psfx 1 = DDTHSMK, "the mech death/rubble // smoke plume") each time its authored 10-second emission window elapses. // The .PFX itself trickles 3 particles/sec for 10s; re-arming it on that // same cadence reads as a continuously burning wreck. The one-shot death // visuals (dnboom + skins) stay in the kill path. // The wreck hulk's quadratic SINK (the 1996 script's burial): the pieces // settle into the ground and are gone ~17s after the kill. While the // wreck is still visible, keep the death/rubble smoke plume alive on its // authored 10s window; once buried, the smoke stops with it. extern int BTWreckSinkTick(Entity *victim, float dt); int wreck_visible = BTWreckSinkTick(this, (float)dt); // BURIAL TRANSITION (one-shot): the authentic game REMOVES the dead // entity (the death row) once its wreck is gone; full entity teardown is // the remaining P5 follow-through (the render tree must be unhooked // first), so until then the buried wreck goes INERT instead: no // collision volume (stops phantom blocking -- the assistant's gather // skips a mover with collisionVolumeCount 0, and MoveCollisionVolume // early-outs) and no target lock (stops phantom hits/impact smoke on // the empty spot). if (!wreck_visible && collisionVolumeCount != 0) { // Zeroing the count stops MoveCollisionVolume from re-placing the // box -- but the mech-vs-mech gather (Mover::GetCurrentCollisions) // tests mover->collisionVolume DIRECTLY, never the count, so the // stale box would keep blocking at the wreck spot. PARK it far // underground: a box 100km down intersects nothing, ever. collisionVolumeCount = 0; if (collisionVolume != 0) { collisionVolume->minY -= 100000.0f; collisionVolume->maxY -= 100000.0f; } if ((Entity *)this == gEnemyMech) gEnemyMech = 0; if (getenv("BT_DEATH_LOG")) DEBUG_STREAM << "[death] buried wreck went INERT (collision parked, " "target lock dropped)\n" << std::flush; } wreckSmokeTimer -= dt; if (wreck_visible && wreckSmokeTimer <= 0.0f) { wreckSmokeTimer = 10.0f; // DDTHSMK's release window extern void BTStartPfx(int effect_number, float x, float y, float z); BTStartPfx(1, (float)localOrigin.linearPosition.x, (float)(localOrigin.linearPosition.y + kMuzzleHeight), (float)localOrigin.linearPosition.z); if (getenv("BT_DEATH_LOG")) DEBUG_STREAM << "[death] wreck smoke plume re-armed (psfx 1)\n" << std::flush; } return; } // // DEATH = FREEZE, not collapse (decomp-verified 2026-07-08, task #32). // The fall modes 5-8 latch leg/body clips from animation-table slots // 0x1c-0x1f (FUN_004a5028) -- but NO loader anywhere in the binary fills // those slots (mech+0x63c..0x648 appear in no exported function; the table // loader FUN_004a80d4 stops at the knockdown slot 0x20), NO fall clip // exists in the shipped 27-clip set, and firing the latch would bind // resource id 0 -- a StaticAudioStream -- as keyframes (garbage). The // fall modes are engine-lineage vestiges; BT's death modes are the FREEZE // modes: IsDestroyed() == (movementMode 2 || 9), and FUN_004ab1c8 zeroes // locomotion for them. So: shut the subsystems down and settle straight // to 9 (disabled/frozen wreck) -- never 5-8. The death READ comes from // the effect layer (dnboom + the ddthsmk plume) + the destroyed skins. // // RP VTV::DeathShutdown analog: shut every subsystem down. The base // Subsystem::DeathShutdown is an empty virtual (SUBSYSTM.h); overrides // act. This is a SHUTDOWN, not teardown -- it frees nothing and never // removes the entity, so it is safe (the wreck STAYS). for (int i = 0; i < GetSubsystemCount(); ++i) { Subsystem *s = GetSubsystem(i); if (s != 0) s->DeathShutdown(1); } // Request the DEATH record BEFORE entering the disabled state (the // ForceUpdate filter masks types 2..8 once IsDisabled) -- the binary // death sender is Force(1) + Force(0x40) (@0x4aab2f/@0x4aab3a). The // record emits on the next update pump with simulationState 9 in its // header; that flips the replicant's MovementMode, whose own // UpdateDeathState then runs the wreck sink/burial on the OBSERVER's // screen (this function runs for every mech, replicants included). ForceUpdate(Simulation::DefaultUpdateModelFlag | (1 << MechDeathUpdateModelBit)); SetMovementMode(9); // disabled: IsDisabled() true, locomotion frozen if (getenv("BT_DEATH_LOG")) DEBUG_STREAM << "[death] mech " << GetEntityID() << " destroyed -> subsystem shutdown + frozen wreck (IsDisabled=" << (int)IsDisabled() << ")" << std::endl; // --- DEATH EFFECTS (task #42): dispatched HERE, at the VICTIM's own // once-per-death transition, so they fire regardless of WHAT killed it // (laser fire block, missile impact frames later, collision damage). // They used to live in the shooter's laser fire block, whose kill // check a missile killing blow never reached -- and post-#41 the // boresight pick skips a dead mech, so that block never ran against // it again: internally dead, smoking, standing, invulnerable. { // KILL score (once; the ownerless dummy yields no death for us). // task #60: pass the REAL killing-blow magnitude (latched in the damage // handler), not the flat bring-up kShotDamage -- the score handler // @0x4c02e4 derives the whole kill award from this damageAmount, so a // flat 12 made every kill score identically regardless of weapon. BTPostKillScore((Entity *)this, lastInflictingDamage); if ((Entity *)this == gEnemyMech) gEnemyDestroyed = 1; // latches off damage score // The victim's AUTHENTIC per-mech death ModelList -- 'blhdead' (RES 22; // the burning-wreck script chain: effect 104 wreck swap + 1007 dnboom + // 1001 ddthsmk + 3/4/5/15). Falls back to the generic explode effect. if (gExplodeReady == 0 && application != 0 && application->GetResourceFile() != 0) { ResourceDescription *exp = application->GetResourceFile()->FindResourceDescription( "explode", (ResourceDescription::ResourceType)1, -1); if (exp != 0) { gExplodeRes = exp->resourceID; gExplodeReady = 1; } else gExplodeReady = -1; } ResourceDescription::ResourceID dead_res = gExplodeRes; if (application != 0 && application->GetResourceFile() != 0) { ResourceDescription *dr = application->GetResourceFile()->FindResourceDescription( "blhdead", (ResourceDescription::ResourceType)1, -1); if (dr != 0) { dead_res = dr->resourceID; if (getenv("BT_DEATH_LOG")) DEBUG_STREAM << "[death] firing authentic death list 'blhdead' id=" << (long)dead_res << "\n" << std::flush; } } if (gExplodeReady == 1 || dead_res != gExplodeRes) { Origin death_origin = localOrigin; death_origin.linearPosition.y += kMuzzleHeight; Explosion::MakeMessage death_exp( Explosion::MakeMessageID, sizeof(Explosion::MakeMessage), (Entity::ClassID)RegisteredClass::ExplosionClassID, EntityID::Null, dead_res, Explosion::DefaultFlags, death_origin, GetEntityID(), // the victim lastInflictingID); // the killer (task #31 bookkeeping) Explosion *boom = Explosion::Make(&death_exp); if (boom) Register_Object(boom); } DEBUG_STREAM << "[damage] *** " << GetEntityID() << " DESTROYED (death effects dispatched from the death transition) ***\n" << std::flush; // OBSERVED-DEATH tally (MP DEATHS fix): a REPLICANT's death observed // here counts onto its owning player's LOCAL score copy -- the // symmetric half of the cross-pod KILLS credit (see btplayer.cpp // BTPlayerCountObservedDeath). Masters count through their own // VehicleDead(-1) path; counting them here too would double-tally. if (GetInstance() == ReplicantInstance) { Player *owning_player = GetOwningPlayer(); // entity+0x190 if (owning_player != 0) { extern void BTPlayerCountObservedDeath(void *); BTPlayerCountObservedDeath(owning_player); } } } // // --- RESPAWN CYCLE (task #52): notify the owning player of the death. --- // The BTPlayer VehicleDead handler @004c05c4 receives this with // deathCount == -1 (the ctor default = "immediate death notification"), // does the death bookkeeping, severs playerVehicle (this wreck entity // STAYS in the world) and re-posts the message to itself at +5 seconds // (5.0f @004c0830) -> the engine drop-zone hunt -> DropZoneReply -> // CreatePlayerVehicle (a NEW mech). Only the owner pod's master has a // live playerLink, which is exactly where the cycle must run; replicant // wrecks just mirror the master's death. [T1 the handler @004c05c4; // T3 this dispatch site -- the binary's exact sender is undecoded, but // the once-per-death transition is the only death edge and the message // ctor's deathCount=-1 default exists for precisely this notification.] // { Player *owner = GetPlayerLink(); if (owner != 0) { Player::VehicleDeadMessage vehicle_dead( Player::VehicleDeadMessageID, sizeof(Player::VehicleDeadMessage) ); owner->Dispatch(&vehicle_dead); if (getenv("BT_DEATH_LOG")) DEBUG_STREAM << "[death] VehicleDead(-1) dispatched to the owning player\n" << std::flush; } } } // Peer heading published each sim update, sampled by the render loop (BT_RENDHDG). volatile float gBTReplRenderYaw = -999.0f; void Mech::PerformAndWatch(const Time& till, MemoryStream *update_stream) { // Frame time slice from the simulation clock (same idiom as Mover::Perform). Scalar dt = till - lastPerformance; lastPerformance = till; // The bring-up DRIVE + ANIMATION path is single-player scaffolding: it reads // the global gBTDrive and a single shared gBodyAnim, and integrates THIS body's // origin. It must run ONLY for the local player's mech (the viewpoint entity); // any other mech in the world (e.g. a spawned target/enemy) must NOT be driven // by the player's input or share the player's AnimationInstance. Non-player // mechs still fall through to the subsystem-roster tick below. const Logical isPlayerMech = (application != 0 && (Entity *)this == application->GetViewpointEntity()); // task #59: keep this mech on the authentic gait clip set for its role -- // INTERIOR (level cockpit) if it's the mech you pilot, EXTERIOR (the -8deg // walk lean) for every other mech. One-time flip per viewpoint change; the // ctor lands everyone on interior because the port never sets the copy bit. MaintainViewClipSet(); // DEATH consumer -- runs for EVERY mech (player + spawned targets). On a // vital kill it collapses + shuts the subsystems down + settles to disabled; // the drive's movementMode write below is guarded on !IsMechDestroyed so a // dead mech keeps its death state instead of reverting to a live gait. UpdateDeathState(dt); // MechDeathHandler (@0042aa2c): per-frame destroyed-skin + explosion effects as // this mech's zones cross damage thresholds. The binary ticks it off the mech's // Performance list (mech+0xbc), which the bring-up drive override bypasses, so // it is driven here. Runs for EVERY mech (so the enemy visibly falls apart too). if (deathHandler != 0) ((MechDeathHandler *)deathHandler)->Tick(); // WAVE 7 Phase B: advance/render/impact the flying projectiles once per frame (viewpoint). if (isPlayerMech && dt > 0.0001f) BTUpdateProjectiles(dt); // 1-Hz non-player mech telemetry (multiplayer diagnosis): every mech that is // NOT the local viewpoint -- whatever its instance claims -- with position. if (!isPlayerMech && getenv("BT_REPL_LOG") && dt > 0.0001f) { static float s_otherLog = 0.0f; s_otherLog += dt; if (s_otherLog >= 1.0f) { s_otherLog = 0.0f; DEBUG_STREAM << "[mech-exec] entity " << GetEntityID() << " instance=" << (int)GetInstance() << " pos=(" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.z << ")" << std::endl; } } // --- REPLICANT MOTION (P6 multiplayer) ----------------------------------- // A remote-mastered mech does not run the local drive: it dead-reckons // between network updates via the engine reckoner (Mover::DeadReckon lerps // localOrigin toward projectedOrigin, which ReadUpdateRecord snaps from the // master's broadcast update records -- MOVER.cpp:493). Subsystems still // tick below (their ctors carry the replicant gates on simulationFlags). if (GetInstance() == ReplicantInstance) { if (dt > 0.0001f && dt < 0.5f) { // AUTHENTIC PEER HEADING (decomp FUN_004ab188/FUN_00409f58 via FUN_004ab1c8): // the original replicant integrates its heading INCREMENTALLY from the // CURRENT pose -- compose an exact rotation of (replicated yaw rate * dt) // onto the rendered heading each frame -- and re-anchors it when a type-4 // resync lands. It does NOT slerp toward a projected angular target: the // engine Mover::DeadReckon lerp uses the shared lastUpdate/nextUpdate // timebase, which the DENSE type-0 pose stream resets every frame while // walking while RESTORING a stale orientation (the authentic case-0 strip) // -- so the angular projection barely advances from a stale base and the // slerp DRAGS the heading back each frame. Measured: walking+turning, the // peer yaw advanced at ~40% rate with half the frames stepping BACKWARD // (the keyboard-steering skip); pure spin/walk never showed it (one stream). // Save the heading, let DeadReckon own the LINEAR channel, then override // the angular result with the original's incremental integration. Quaternion replPrevHeading = localOrigin.angularPosition; DeadReckon(dt); // engine reckoner: LINEAR position/velocity if (angSyncLatch) { // type-4 arrived: re-anchor on the authoritative orientation localOrigin.angularPosition = updateOrigin.angularPosition; angSyncLatch = 0; } else { Vector3D replAngStep; replAngStep.Multiply(updateVelocity.angularMotion, dt); ReconQuatIntegrate(&localOrigin.angularPosition, &replPrevHeading, &replAngStep); // exact FUN_00409f58 } localOrigin.angularPosition.Normalize(); localToWorld = localOrigin; // RENDER-vs-SIM decoupling probe: publish the heading the RENDER will // actually draw (localToWorld == localOrigin here). The render loop // (L4VIDEO.cpp) logs this PER RENDERED FRAME; if it repeats the same // value across render frames, the peer sim updates slower than the // render draws -> the rotation stutters even though each sim update is // smooth (which the per-update [replhdg] metric could not see). { extern volatile float gBTReplRenderYaw; YawPitchRoll _rry; _rry = localOrigin.angularPosition; gBTReplRenderYaw = (float)_rry.yaw; } // Lightweight dead-reckon smoothness probe (one flush/sec, cheap). // PATH length (sum of |per-frame step|) vs NET displacement reveals // jitter: a smooth walk has path==net; oscillation inflates path while // net stays small. avgHorizon is the lerp target distance in time. if (getenv("BT_JIT")) { static bool s_jinit = false; static Point3D s_jprev, s_jnetA; static float s_jacc = 0.0f, s_jpath = 0.0f, s_jmax = 0.0f, s_jhzn = 0.0f; static int s_jfr = 0; if (s_jinit) { const float dx = (float)(localOrigin.linearPosition.x - s_jprev.x); const float dz = (float)(localOrigin.linearPosition.z - s_jprev.z); const float step = sqrtf(dx * dx + dz * dz); s_jpath += step; if (step > s_jmax) s_jmax = step; } else { s_jnetA = localOrigin.linearPosition; s_jinit = true; } s_jprev = localOrigin.linearPosition; s_jhzn += (float)(nextUpdate - lastPerformance); s_jfr++; s_jacc += dt; if (s_jacc >= 1.0f) { const float ndx = (float)(localOrigin.linearPosition.x - s_jnetA.x); const float ndz = (float)(localOrigin.linearPosition.z - s_jnetA.z); const float net = sqrtf(ndx * ndx + ndz * ndz); DEBUG_STREAM << "[repljit] frames=" << s_jfr << " path=" << s_jpath << " net=" << net << " ratio=" << (net > 0.001f ? s_jpath / net : 0.0f) << " maxStep=" << s_jmax << " avgStep=" << (s_jpath / s_jfr) << " avgHorizon=" << (s_jhzn / s_jfr) << "\n" << std::flush; s_jacc = 0.0f; s_jpath = 0.0f; s_jmax = 0.0f; s_jhzn = 0.0f; s_jfr = 0; s_jnetA = localOrigin.linearPosition; } } // REPLICANT GAIT (task #50): animate the peer mech's legs at the // REPLICATED speed -- without this the replicant slides around as a // frozen statue. The master publishes worldLinearVelocity in every // update record (Mover::WriteUpdateRecord, MOVER.cpp:759); the // replicant stores it in updateVelocity.linearMotion (:712). Derive // the signed speed demand from it (forward = local -Z; a negative // forward dot = the master is reversing), feed the LEG channel's live // demand source, and advance the leg state machine for its JOINT // writes only -- TRAVEL stays with DeadReckon (the returned distance // is discarded), so position always follows the master and the clip // cadence matches the replicated speed (residual foot-slip is the // inherent dead-reckoning artifact). The state machine self-arms // stand->walk / winds down from the demand exactly as on the master. MechControlsMapper *replMppr = MappingMapper(); // roster slot 0 (task #7) if (!IsMechDestroyed() && replMppr != 0) { // AUTHENTIC LEG-CHANNEL DEMAND (decomp-verified, workflow wv1km7lvc D3): // the leg channel FUN_004a5028 reads its speed demand from // mapper->speedDemand == **(mech+0x128)+0x128 (part_012.c:11947/11975/ // 12028) and slews cadence + selects the stand->walk gate from it. On a // peer this MUST be the REPLICATED COMMANDED speed bodyTargetSpeed@0x6b4 -- // the master's own throttle-commanded speedDemand (mechmppr.cpp:749), // stamped into every record (mech.cpp:2036, record->speedDemand) and read // back on the peer (mech.cpp type-0 @1833, type-2 @1841/1848, type-3 @1871). // It is the IDENTICAL value the master's leg channel consumes, so the peer // clears the stand->walk gate exactly when the master does (the commanded // speed is what makes the master walk, so it clears standSpeed by // construction) and ramps cadence CONTINUOUSLY through accel/decel -- // cadence tracks travel like the master. bodyTargetSpeed is signed // (negative == reverse); it is 0 only while COASTING (thr=0), which // authentically winds the gait down to stand, matching the master. // // The prior default DERIVED a noisy speed from the dead-reckoned velocity // and PINNED it flat with a standSpeed*1.05 floor across the walk threshold // -- the CONFIRMED leg-stutter/skip + cadence-vs-travel desync. Retained // behind BT_REPL_VEL for A/B comparison only. static const int s_replVel = getenv("BT_REPL_VEL") ? 1 : 0; if (s_replVel) { const Vector3D &wv = updateVelocity.linearMotion; float spd = sqrtf((float)(wv.x * wv.x + wv.z * wv.z)); UnitVector zAxR; localToWorld.GetFromAxis(Z_Axis, &zAxR); const float fdot = -((float)wv.x * (float)zAxR.x + (float)wv.z * (float)zAxR.z); // mech faces -Z Scalar sd = (fdot < 0.0f) ? -spd : spd; if (sd > standSpeed * 0.5f && sd < standSpeed * 1.05f) sd = standSpeed * 1.05f; replMppr->speedDemand = sd; } else { replMppr->speedDemand = bodyTargetSpeed; // authentic replicated commanded speed } // AUTHENTIC PEER TURNING is NET-DRIVEN (decomp-verified D8): the angular // slerp inside DeadReckon (MOVER.cpp:521-525) rotates // localOrigin.angularPosition toward projectedOrigin.angularPosition from // the replicated yaw rate (decomp angular path part_012.c:15001/15010). // The leg SM's turn-in-place 'trn' clip is a MASTER-ONLY construct fed by // the LIVE controls mapper; the old synthetic yawRate->turnDemand // (quantized +-0.02) flickered the trn clip in/out as the noisy replicated // rate crossed the band -> the jerky pivot / statue-rotate. Do NOT // synthesize it -- let the whole body rotate via the slerp. // TURN-STEP (presentation of the authentic pivot): the authentic peer // shows a stepping pivot because its BODY channel replays the master's // replicated turn body-STATE (type-3 SetBodyAnimation, mech.cpp:1869). // We run the LEG channel (D2 not switched), which has no replicated turn // state, so we reproduce the visible turn-step by arming the leg SM's // 'trn' clip from the replicated yaw rate. The old feed used a bare // +-0.02 threshold that CHATTERED the trn clip in/out as the noisy rate // wandered near zero (the "jerky pivot", decomp-noted D8). HYSTERESIS // fixes that: enter the turn only above +-0.08 rad/s, hold it (the // mapper's turnDemand persists frame-to-frame -- we are its only writer on // a peer) until the rate clearly collapses below 0.03. A steady turn sits // well above both, so the clip never flickers. (BT_REPL_NOTURN = // decomp-strict net-driven rotation with NO step.) const float yawRate = (float)updateVelocity.angularMotion.y; static const int s_noTurn = getenv("BT_REPL_NOTURN") ? 1 : 0; if (s_noTurn) { replMppr->turnDemand = 0.0f; } else { const float prevTd = (float)replMppr->turnDemand; // last frame (our own write) if (prevTd != 0.0f) // turning: hold until the rate collapses replMppr->turnDemand = (fabsf(yawRate) < 0.03f) ? 0.0f : prevTd; else // idle: start only on a clear turn replMppr->turnDemand = (yawRate > 0.08f) ? 1.0f : (yawRate < -0.08f) ? -1.0f : 0.0f; } // Prime the same clip-advance scalars the master's gait block sets // each frame -- uninitialized on a replicant they read 0, freezing // the clip at advance-time dt*0 (observed: legState engaged at 11, // legCycle stuck 0). (The old forwardCycleRate floor is retired: // the ctor reads the authentic MaxAcceleration since the task #4 // record-layout fix.) globalTimeScale = 1.0f; idleStrideScale = 1.0f; // reverseSpeedMax2@0x7a0: the run-cycle rise-CLAMP (leg case 12/13); // LoadLocomotionClips doesn't set it, so a replicant reads 0xCDCD // (-4.3e8) and the clamp CLOBBERS legCycleSpeed the moment the run // cycle engages (observed live: legCycle=-4.31602e+08, state 13 // stuck). Same heal as the master's drive block. reverseSpeedMax2 = reverseStrideLength; // legCycleSpeed itself is also debug-heap garbage until a walk // state first writes it (Standing never touches it); sane-band it // once so a direct run-state entry can't slew from -4.3e8. if (legCycleSpeed < -100.0f || legCycleSpeed > 200.0f) legCycleSpeed = 0.0f; (void)AdvanceLegAnimation(dt); // joints only; travel = DeadReckon // TURN-STEP rate probe (BT_TRNRATE, 0.5s): body rotation rate vs trn clip advance. if (getenv("BT_TRNRATE")) { static bool s_tinit = false; static float s_tYaw = 0.0f, s_tFrm = 0.0f, s_tAcc = 0.0f, s_tRot = 0.0f, s_tAdv = 0.0f; YawPitchRoll _typ; _typ = localOrigin.angularPosition; const float yaw = (float)_typ.yaw, frm = (float)legAnimation.currentFrame; if (s_tinit) { float dy = yaw - s_tYaw; if (dy > 3.14159f) dy -= 6.28319f; if (dy < -3.14159f) dy += 6.28319f; s_tRot += (dy < 0.0f) ? -dy : dy; const float df = frm - s_tFrm; if (df > 0.0f) s_tAdv += df; } s_tYaw = yaw; s_tFrm = frm; s_tinit = true; s_tAcc += dt; if (s_tAcc >= 0.5f) { DEBUG_STREAM << "[trnrate] bodyRot/s=" << (s_tRot / s_tAcc) << "rad" << " legFrm/s=" << (s_tAdv / s_tAcc) << " legState=" << (int)legStateAlarm.GetLevel() << " turnDemand=" << MappingMapper()->turnDemand << "\n" << std::flush; s_tAcc = 0.0f; s_tRot = 0.0f; s_tAdv = 0.0f; } } // Cadence-vs-travel sync probe (0.25s, ~4 flush/s -- cheap). Compares // the leg CADENCE input (bodyTargetSpeed / legCycleSpeed) against the // ACTUAL dead-reckoned ground speed; if the legs are in sync the two // track together and legFrm advances monotonically. if (getenv("BT_ANIM")) { static bool s_ainit = false; static Point3D s_aprev; static float s_aacc = 0.0f, s_apath = 0.0f; if (s_ainit) { const float dx = (float)(localOrigin.linearPosition.x - s_aprev.x); const float dz = (float)(localOrigin.linearPosition.z - s_aprev.z); s_apath += sqrtf(dx * dx + dz * dz); } s_aprev = localOrigin.linearPosition; s_ainit = true; s_aacc += dt; if (s_aacc >= 0.25f) { DEBUG_STREAM << "[anim] cmd=" << bodyTargetSpeed << " drSpeed=" << (s_apath / s_aacc) << " legCyc=" << legCycleSpeed << " legState=" << (int)legStateAlarm.GetLevel() << " legFrm=" << legAnimation.currentFrame << "\n" << std::flush; s_aacc = 0.0f; s_apath = 0.0f; } } if (getenv("BT_REPL_TRN")) { static float s_rt = 0.0f; s_rt += dt; if (s_rt >= 0.15f) { s_rt = 0.0f; DEBUG_STREAM << "[repltrn] yawRate=" << yawRate << " turnDemand=" << replMppr->turnDemand << " legState=" << (int)legStateAlarm.GetLevel() << " bodyState=" << (int)bodyStateAlarm.GetLevel() << " legFrm=" << legAnimation.currentFrame << " bodyFrm=" << bodyAnimation.currentFrame << " spd=" << replMppr->speedDemand << " tgt=" << bodyTargetSpeed << "\n" << std::flush; } } // PER-FRAME heading trace (BT_REPL_HDG, choppy-spin diagnosis): the // replicant's rendered yaw each frame + dt. A smooth dead-reckoned // spin advances ~yawRate*dt every frame; choppiness shows as heading // STALLS followed by SNAPS (records arriving without integration // between them). if (getenv("BT_REPL_HDG") && yawRate != 0.0f) { YawPitchRoll rh, ph, uh; rh = localOrigin.angularPosition; ph = projectedOrigin.angularPosition; uh = updateOrigin.angularPosition; DEBUG_STREAM << "[replhdg] yaw=" << (Scalar)rh.yaw << " proj=" << (Scalar)ph.yaw << " upd=" << (Scalar)uh.yaw << " lp-lu=" << (lastPerformance.ticks - lastUpdate.ticks) << " nu-lp=" << (nextUpdate.ticks - lastPerformance.ticks) << " yawRate=" << yawRate << " dt=" << dt << "\n" << std::flush; } // PER-FRAME travel/animation coupling probe (BT_REPL_MOV, user: // "slides forward before it steps"): position vs the dead-reckon target // vs the derived speed vs the leg SM state, every frame there is // replicated motion. Reveals travel-vs-animation lag + lerp-gap jitter. if (getenv("BT_REPL_MOV")) { const Scalar uvx = (Scalar)updateVelocity.linearMotion.x; const Scalar uvz = (Scalar)updateVelocity.linearMotion.z; const Scalar umag = (Scalar)sqrtf(uvx*uvx + uvz*uvz); if (umag > 0.01f || replMppr->speedDemand != 0.0f) DEBUG_STREAM << "[replmov]" << " pos=(" << localOrigin.linearPosition.x << "," << localOrigin.linearPosition.z << ")" << " proj=(" << projectedOrigin.linearPosition.x << "," << projectedOrigin.linearPosition.z << ")" << " uvel=" << umag << " spd=" << replMppr->speedDemand << " legState=" << (int)legStateAlarm.GetLevel() << " legFrm=" << legAnimation.currentFrame << " standSp=" << standSpeed << " walkStr=" << walkStrideLength << " nu-lp=" << (nextUpdate.ticks - lastPerformance.ticks) << " dt=" << dt << "\n" << std::flush; } // REPLICANT BEAMS (task #51): the emitters carry live replicated // discharge state (Emitter::ReadUpdateRecord); draw them with the // same per-weapon walk the master uses. DrawWeaponBeams(dt); } if (getenv("BT_REPL_LOG")) { static float s_replLog = 0.0f; s_replLog += dt; if (s_replLog >= 1.0f) { s_replLog = 0.0f; DEBUG_STREAM << "[repl] mech " << (long)GetEntityID() << " pos=(" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.y << ", " << localOrigin.linearPosition.z << ")" << " vel=(" << updateVelocity.linearMotion.x << "," << updateVelocity.linearMotion.z << ")" << " legState=" << (int)legStateAlarm.GetLevel() << " legCycle=" << legCycleSpeed << "\n" << std::flush; } } } } // AUTHENTIC GROUND MODEL for NON-PLAYER masters (task #15): the binary // installs FUN_004a9b5c as the performance of EVERY MasterInstance mech // (ctor part_012.c:9947-9956), so the spawned test dummy grounds/collides // too -- not just the player. The player's call runs inside the drive // block below (after its move, with the true start-of-frame position). if (!isPlayerMech && GetInstance() == MasterInstance && GroundReal() && dt > 0.0001f && dt < 0.5f) { // A stationary master: old_position is a COPY of the current position // (must not alias localOrigin.linearPosition -- the block writes it). Point3D preMovePos = localOrigin.linearPosition; AuthenticGroundAndCollide(dt, preMovePos); } if (isPlayerMech && dt > 0.0001f && dt < 0.5f) // ignore zero / huge (stall) slices { // --- VIRTUAL CONTROLS (dev keyboard -> the pod's analog inputs) -------- // The pod's throttle was an ABSOLUTE analog LEVER and its turn input an // analog stick; momentary 0/1 keys can't express either (at 60fps every // tap = a frame-perfect FULL demand = the "controls too sensitive after // the perf fix" report -- at the old 10fps most taps aliased away, which // only LOOKED like fine control). Integrate dt-scaled (fps-independent): // lever: W/S sweep a PERSISTENT lever (tap ~= 0.07 step, full sweep // ~1.4s) with a DETENT at zero -- braking from forward stops AT // 0; release and press S again to engage reverse. X = all stop. // stick: A/D deflect a momentary stick over ~0.4s, auto-centering on // release -- tap = gentle nudge, hold = full rate. { static float sLever = 0.0f, sStick = 0.0f; static int sDetent = 0; // latched at the zero crossing const float kLeverRate = 0.7f; // lever sweep per second const float kStickRate = 2.5f; // stick deflect per second const float kStickCenterRate = 5.0f; // stick auto-center per second // POLL the real key state. WndProc key messages are unusable: the // engine keyboard reader (L4CTRL.cpp:1506) GetMessage()s every // WM_KEYUP/WM_CHAR out of the queue for its key-command channel, so // only KEYDOWNs ever reached the WndProc and key state latched on // forever. GetAsyncKeyState is immune; the foreground guard keeps // background typing from driving the mech. { typedef short (__stdcall *AsyncFn)(int); typedef void *(__stdcall *FgFn)(void); typedef unsigned long (__stdcall *WtpFn)(void *, unsigned long *); typedef unsigned long (__stdcall *PidFn)(void); static AsyncFn pAsync = 0; static FgFn pFg = 0; static WtpFn pWtp = 0; static PidFn pPid = 0; if (pAsync == 0) { HMODULE u = GetModuleHandleA("user32.dll"); HMODULE k = GetModuleHandleA("kernel32.dll"); pAsync = (AsyncFn)GetProcAddress(u, "GetAsyncKeyState"); pFg = (FgFn)GetProcAddress(u, "GetForegroundWindow"); pWtp = (WtpFn)GetProcAddress(u, "GetWindowThreadProcessId"); pPid = (PidFn)GetProcAddress(k, "GetCurrentProcessId"); } int focused = 0; if (pFg && pWtp && pPid) { void *fg = pFg(); unsigned long fgPid = 0; if (fg) pWtp(fg, &fgPid); focused = (fgPid == pPid()); } // TEST HOOK (BT_KEY_NOFOCUS=1): accept keys without foreground focus -- // automation harnesses (SendInput from a background shell) can't grant // real foreground; interactive play never needs this. static int sNoFocus = -1; if (sNoFocus < 0) { const char *nf = getenv("BT_KEY_NOFOCUS"); sNoFocus = (nf && *nf == '1') ? 1 : 0; } if (sNoFocus) focused = 1; if (pAsync) { const int dn = 0x8000; gBTDrive.keyFwd = focused && ((pAsync('W') | pAsync(0x26 /*VK_UP*/)) & dn) ? 1 : 0; gBTDrive.keyBack = focused && ((pAsync('S') | pAsync(0x28 /*VK_DOWN*/)) & dn) ? 1 : 0; gBTDrive.keyLeft = focused && ((pAsync('A') | pAsync(0x25 /*VK_LEFT*/)) & dn) ? 1 : 0; gBTDrive.keyRight = focused && ((pAsync('D') | pAsync(0x27 /*VK_RIGHT*/)) & dn) ? 1 : 0; // WEAPON GROUPS (task #43, KEYBOARD only per user): three fire // channels like three pod buttons -- 1/SPACE = lasers, 2 = PPCs, // 3/CTRL = missiles. (Interim; the authentic system is the // ConfigureMappables/ChooseButton mapper channels.) gBTLaserKey = focused && ((pAsync('1') | pAsync(0x20 /*VK_SPACE*/)) & dn) ? 1 : 0; gBTPPCKey = focused && (pAsync('2') & dn) ? 1 : 0; gBTMissileKey = focused && ((pAsync('3') | pAsync(0x11 /*VK_CONTROL*/)) & dn) ? 1 : 0; // The pod's 4TH fire button (Pinky, 0x45) -- previously // unmapped on desktop, so any weapon the authored groups // put there (the Avatar's NARC etc.) was unreachable. gBTPinkyKey = focused && (pAsync('4') & dn) ? 1 : 0; // task #6: HOLD 'G' opens the config session on the selected // weapon (BT_CONFIG_SLOT, default: the first weapon); while // held, the fire keys TOGGLE its group membership. gBTConfigKey = focused && (pAsync('G') & dn) ? 1 : 0; // task #12: F5..F8 assign the selected weapon to Generator // A..D; F9 toggles Manual/Auto reconnect. gBTGenSelKey = (focused && (pAsync(0x74 /*F5*/) & dn)) ? 4 : (focused && (pAsync(0x75 /*F6*/) & dn)) ? 5 : (focused && (pAsync(0x76 /*F7*/) & dn)) ? 6 : (focused && (pAsync(0x77 /*F8*/) & dn)) ? 7 : (focused && (pAsync(0x78 /*F9*/) & dn)) ? 8 : 0; // task #13: 'C' cycles the coolant valve (BT_VALVE_SLOT // picks the condenser roster slot; default = Condenser1). gBTValveKey = focused && (pAsync('C') & dn) ? 1 : 0; // TORSO CONTROLS (2026-07-13): 'M' cycles the control mode // (Basic -> Standard -> Veteran -- the pod console button, // CycleControlModeMessageHandler); Q/E deflect the torso // twist axis (the STICK in Standard/Veteran, where A/D // become the pedals). Ramped like the turn stick. { static int sPrevM = 0; const int mNow = focused && (pAsync('M') & dn) ? 1 : 0; if (mNow && !sPrevM) gBTModeCycle = 1; // edge -> one cycle sPrevM = mNow; const int tw = (focused && (pAsync('E') & dn) ? 1 : 0) - (focused && (pAsync('Q') & dn) ? 1 : 0); static float sTwist = 0.0f; if (tw != 0) { sTwist += tw * kStickRate * dt; if (sTwist > 1.0f) sTwist = 1.0f; if (sTwist < -1.0f) sTwist = -1.0f; } else if (sTwist != 0.0f) { // SPRING-CENTER on release (user fix 2026-07-13: the // old hold-deflection model left a residual axis -> // the torso drifted forever with no way to stop). // The pod stick is spring-centered: the axis is a // twist-RATE demand, so release = rate 0 = the torso // HOLDS where you aimed it (position is kept by // Torso::currentTwist, not by the axis). Same model // as the A/D turn stick (kStickCenterRate). const float step = kStickCenterRate * dt; if (sTwist > step) sTwist -= step; else if (sTwist < -step) sTwist += step; else sTwist = 0.0f; } // X = all-stop for the torso too: zero the axis NOW and // pulse the AUTHENTIC recenter (torso centerCommand -> // Recenter @004b6918 slews currentTwist back to 0; any // new Q/E deflection cancels it, sim-side). Separate // edge detector from the drive all-stop below -- both // fire on the same press. static int sPrevXT = 0; const int xtNow = focused && (pAsync('X') & dn) ? 1 : 0; if (xtNow && !sPrevXT) { sTwist = 0.0f; gBTTorsoRecenter = 1; // mapper consumes -> CommandRecenter() } sPrevXT = xtNow; gBTTwistAxis = sTwist; } // gBTDrive.fire = "any weapon trigger down" (feeds the bring-up // damage dispatcher + the beam-visual keepalive) gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey || gBTPinkyKey) ? 1 : 0; static int sPrevX = 0; const int xNow = 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 = SCREEN CENTER (task #58 correction, supersedes the // task #39 "body-mounted view" model): the CAMERA yaws with // the torso (the cockpit sits on jointtorso -- see the // gBTEyeTwist publisher), so screen center always IS the gun // boresight and the crosshair stays centered while the WORLD // rotates past it -- the twist reads on the bottom tape // carets / compass / radar wedge, not the crosshair. [T1: // jointtorso->jointeye->siteeyepoint chain + FUN_004c22c4 // inverse-chain view; crosshair-twist forensics 2026-07-13.] // The old gBTAimX = tan(twist) slew was a port invention on // the falsified body-mounted premise -- with the yawing eye // it double-counts (crosshair pinned to HULL-forward, fire // ray resolving body-forward: "twisting leaves the // crosshairs behind"). Reticle mobility exists in the // binary (Reticle::reticlePosition, RETICLE.h:42) but its // twist-era use is the FIXED-TORSO free-aim channel // (mech+0x36c, writer un-exported) -- deferred. // BT_AIM="x y" remains as the headless harness. { static int sAimEnv = -1; static float sAimEnvX = 0.0f, sAimEnvY = 0.0f; if (sAimEnv < 0) { const char *av = getenv("BT_AIM"); sAimEnv = (av != 0 && sscanf(av, "%f %f", &sAimEnvX, &sAimEnvY) == 2) ? 1 : 0; } if (sAimEnv) { gBTAimX = sAimEnvX; gBTAimY = sAimEnvY; } else { // Boresight = screen center: the eye carries the // twist (task #58), the pick ray inherits it from // the published eye basis. gBTAimX = 0.0f; gBTAimY = 0.0f; } } } } // DEV: BT_AUTOFIRE=1 holds the trigger (drives the fireForced hook) and // BT_AUTODRIVE=<0..1> holds the throttle (drives the forced hook) so the // full walk->fire->damage->death chain can be exercised headlessly. { static int sAutoFire = -1; static float sAutoDrive = -1.0f; static int sGotoDrive = -1; if (sAutoFire < 0) { const char *af = getenv("BT_AUTOFIRE"); sAutoFire = (af && *af == '1') ? 1 : 0; const char *ad = getenv("BT_AUTODRIVE"); sAutoDrive = ad ? (float)atof(ad) : 0.0f; const char *gv = getenv("BT_GOTO"); sGotoDrive = (gv && *gv) ? 1 : 0; } gBTDrive.fireForced = sAutoFire; if (sAutoDrive > 0.0f) { gBTDrive.forced = 1; gBTDrive.forcedThrottle = sAutoDrive; // task #64 harness: BT_WALK_DELAY holds the FORCED throttle (the // mapper's actual input -- the local `throttle` gate below never // reached it) at 0 for n sim-secs (turn-in-place phase, with // BT_FORCE_TURN active), then ramps it over 1.2s -- the manual // "lever dwell through the walk threshold" repro. { static float s_wdD = -1.0f, s_wdC = 0.0f; if (s_wdD < -0.5f) { const char *v = getenv("BT_WALK_DELAY"); s_wdD = v ? (float)atof(v) : 0.0f; } if (s_wdD > 0.0f) { s_wdC += dt; float r = (s_wdC - s_wdD) / 1.2f; gBTDrive.forcedThrottle = (r <= 0.0f) ? 0.0f : (r < 1.0f ? sAutoDrive * r : sAutoDrive); } } } else if (sGotoDrive) { // BT_GOTO is self-driving: enable forced mode so the goto beeline // block (gated on gBTDrive.forced, below) runs on its own without // requiring BT_AUTODRIVE. That block sets the actual throttle // (drive toward the target, 0 inside the stop radius). gBTDrive.forced = 1; gBTDrive.forcedThrottle = 0.8f; } } if (gBTDrive.allStop) { sLever = 0.0f; sDetent = 0; gBTDrive.allStop = 0; } if (getenv("BT_KEY_LOG")) { static float sKlog = 0.0f; sKlog += dt; if (sKlog >= 1.0f) { sKlog = 0.0f; DEBUG_STREAM << "[vctl] fwd=" << gBTDrive.keyFwd << " back=" << gBTDrive.keyBack << " L=" << gBTDrive.keyLeft << " R=" << gBTDrive.keyRight << " fire=" << gBTDrive.fire << " lever=" << sLever << " stick=" << sStick << "\n" << std::flush; } } const int fwd = gBTDrive.keyFwd, back = gBTDrive.keyBack; if (!fwd && !back) sDetent = 0; // keys released -> detent re-arms float sweep = ((fwd ? 1.0f : 0.0f) - (back ? 1.0f : 0.0f)) * kLeverRate * dt; if (sweep != 0.0f && !sDetent) { const float prev = sLever; sLever += sweep; // zero detent: a sweep that CROSSES 0 stops there and latches until // the keys are released (clean stop instead of swinging to reverse). if ((prev > 0.0f && sLever <= 0.0f && back) || (prev < 0.0f && sLever >= 0.0f && fwd)) { sLever = 0.0f; sDetent = 1; } if (sLever > 1.0f) sLever = 1.0f; if (sLever < -1.0f) sLever = -1.0f; } const float want = (gBTDrive.keyRight ? 1.0f : 0.0f) - (gBTDrive.keyLeft ? 1.0f : 0.0f); if (want != 0.0f) { sStick += want * kStickRate * dt; if (sStick > 1.0f) sStick = 1.0f; if (sStick < -1.0f) sStick = -1.0f; } else if (sStick != 0.0f) // auto-center { const float step = kStickCenterRate * dt; if (sStick > step) sStick -= step; else if (sStick < -step) sStick += step; else sStick = 0.0f; } gBTDrive.throttle = sLever; gBTDrive.turn = sStick; } // DEATH GATE (task #52): a DESTROYED mech takes no pilot input -- kill // throttle/turn/fire and the forced harnesses at the source so the // wreck can't be steered around invisibly ("ghost mode", user-reported // from 2-window play). Deliberately INPUT-only: the gait/animation // machinery below keeps running so the death collapse plays out and the // wind-down settles; respawn is the recovery/drop cycle (task #52 open). if (IsMechDestroyed()) { gBTDrive.throttle = 0.0f; gBTDrive.turn = 0.0f; gBTDrive.fire = 0; gBTDrive.fireForced = 0; gBTDrive.forced = 0; gBTLaserKey = gBTPPCKey = gBTMissileKey = gBTPinkyKey = 0; } float throttle = gBTDrive.forced ? gBTDrive.forcedThrottle : gBTDrive.throttle; float turn = gBTDrive.forced ? 0.0f : gBTDrive.turn; // BT_SPAWN_AT="x z [hdg]" (DEBUG harness): teleport the player to a // world position (+ optional heading, radians) on the first driven // frame -- exact-position reproduction of a user-reported render // glitch for A/B toggle comparison at the same viewpoint. { static int s_spawnAt = -1; static float s_sx = 0.0f, s_sz = 0.0f, s_sh = 0.0f; static int s_haveH = 0; if (s_spawnAt < 0) { const char *sv = getenv("BT_SPAWN_AT"); if (sv) { const int n = sscanf(sv, "%f %f %f", &s_sx, &s_sz, &s_sh); if (n >= 2) { s_spawnAt = 1; s_haveH = (n >= 3); } else s_spawnAt = 0; } else s_spawnAt = 0; } if (s_spawnAt == 1) { s_spawnAt = 2; localOrigin.linearPosition.x = s_sx; localOrigin.linearPosition.z = s_sz; // FIX (user-reported: teleport embeds the mech in sloped terrain): // BT_SPAWN_AT set only X/Z, keeping the OLD (dropzone) Y -- wrong for // the new ground height. Lift the mech WELL ABOVE local terrain so the // authentic per-frame ground SNAP (BoxTree FindBoundingBoxUnder, no // gravity) settles it onto the surface next frame. The snap only // lowers, so it must start above the surface, not below. localOrigin.linearPosition.y += 500.0f; if (s_haveH) { gDriveHeading = s_sh; // the mech's REAL orientation (the chase camera derives from // it) is the quaternion, not the scalar mirror -- set both, // same convention as the bring-up drive path below. localOrigin.angularPosition = EulerAngles(0.0f, s_sh, 0.0f); } localToWorld = localOrigin; DEBUG_STREAM << "[spawnat] teleported to (" << s_sx << ", " << s_sz << ") hdg=" << (s_haveH ? s_sh : gDriveHeading) << "\n" << std::flush; } } // BRING-UP repro harness: BT_FORCE_SECONDS= 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= holds a steering demand (the turn path). if (gBTDrive.forced) { static float s_forceClock = 0.0f, s_forceLimit = -1.0f, s_forceTurn = -999.0f; if (s_forceLimit < 0.0f) { const char *fs = getenv("BT_FORCE_SECONDS"); s_forceLimit = fs ? (float)atof(fs) : 0.0f; } if (s_forceTurn < -900.0f) { const char *ft = getenv("BT_FORCE_TURN"); s_forceTurn = ft ? (float)atof(ft) : 0.0f; } turn = s_forceTurn; // Publish the forced turn to gBTDrive too: mechmppr derives turnDemand // from gBTDrive.turn (NOT this local), so without this the headless rig // never entered the trn state -- BT_FORCE_TURN was silently inert for // the gait (task #64 harness fix). gBTDrive.turn = s_forceTurn; // BT_WALK_DELAY= (stutter repro, task #64): hold throttle at 0 for the // first n sim-seconds (TURN-IN-PLACE, turn stays active) then release the // forced throttle -- reproduces "turn in place, then start walking BEFORE // the turn stops" exactly. Turn (BT_FORCE_TURN) persists across the seam. static float s_walkDelay = -1.0f, s_wdClock = 0.0f; if (s_walkDelay < -0.5f) { const char *wd = getenv("BT_WALK_DELAY"); s_walkDelay = wd ? (float)atof(wd) : 0.0f; } if (s_walkDelay > 0.0f) { s_wdClock += dt; if (s_wdClock < s_walkDelay) throttle = 0.0f; // turn-in-place phase else { // RAMP the throttle up over 1.2s (simulate the manual lever // sweep DWELLING through standSpeed) with the turn still active // -- the continuous-stutter repro. float r = (s_wdClock - s_walkDelay) / 1.2f; if (r < 1.0f) throttle *= r; } } if (s_forceLimit > 0.0f) { s_forceClock += dt; if (s_forceClock > s_forceLimit) { throttle = 0.0f; turn = 0.0f; } } // BT_GOTO="x z" (DEBUG harness): beeline toward a world point -- // steer the turn demand from the heading error each frame (headless // reproduction of "run toward that feature"). Uses the scalar // heading mirror gDriveHeading (forward = -Z at heading 0). { static float s_gx = 0.0f, s_gz = 0.0f; static int s_goto = -1; if (s_goto < 0) { const char *gv = getenv("BT_GOTO"); if (gv && !stricmp(gv, "enemy")) s_goto = 2; // chase nearest replicant else if (gv && sscanf(gv, "%f %f", &s_gx, &s_gz) == 2) s_goto = 1; else s_goto = 0; } if (s_goto == 2) { // DYNAMIC target (test harness): beeline toward the CLOSEST live // peer replicant wherever it currently is -- MP spawn positions // vary per run, so a fixed "x z" can't reliably face the enemy. extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut); Entity *ec[32]; const int enc = BTGetTargetCandidates((Entity *)this, ec, 32); float best = 1e30f; int found = 0; for (int ei = 0; ei < enc; ++ei) { Mech *em = (Mech *)ec[ei]; if (em == 0 || em->IsMechDestroyed()) continue; // any peer (solo dummy OR replicant) Point3D ep = em->localOrigin.linearPosition; float edx = (float)ep.x - (float)localOrigin.linearPosition.x; float edz = (float)ep.z - (float)localOrigin.linearPosition.z; float ed2 = edx*edx + edz*edz; if (ed2 < best) { best = ed2; s_gx = (float)ep.x; s_gz = (float)ep.z; found = 1; } } if (found) s_goto = 2; else { gBTGotoActive = 0; } } if (s_goto == 1 || (s_goto == 2 && (s_gx != 0.0f || s_gz != 0.0f))) { float ddx = s_gx - (float)localOrigin.linearPosition.x; float ddz = s_gz - (float)localOrigin.linearPosition.z; float dist2 = ddx * ddx + ddz * ddz; // Heading error from the mech's ACTUAL forward (localToWorld's -Z // axis), NOT the gDriveHeading scalar mirror: that mirror is seeded // to 0, but MP pilots (and any non-zero spawn pose) start facing a // different way, so `want - gDriveHeading` steered the beeline the // WRONG way. err = signed angle (about +Y) from forward to target. UnitVector zAxisG; localToWorld.GetFromAxis(Z_Axis, &zAxisG); float fwdX = -(float)zAxisG.x, fwdZ = -(float)zAxisG.z; // faces -Z float err = 0.0f; float tlen = (float)sqrt((double)dist2); if (tlen > 1e-3f) { float tX = ddx / tlen, tZ = ddz / tlen; float dot = fwdX * tX + fwdZ * tZ; float crs = fwdZ * tX - fwdX * tZ; // signed; flip if it steers away err = (float)atan2((double)crs, (double)dot); } // STOP radius: "enemy" holds at weapon range so it can shoot // instead of ramming; a fixed point drives right up to the spot. const float stopDist = (s_goto == 2) ? 300.0f : 5.0f; const float stopDist2 = stopDist * stopDist; const int arrived = (dist2 < stopDist2); // publish for the mapper bridge (the real-controls path reads // gBTDrive in mechmppr.cpp, NOT this local `turn`) gBTGotoTurn = (err > 0.3f) ? 1.0f : (err < -0.3f ? -1.0f : (err > 0.02f ? 0.3f : (err < -0.02f ? -0.3f : 0.0f))); if (arrived) gBTGotoTurn = 0.0f; // throttle down while far off-heading (the authentic // speed-vs-turn clamp makes run-speed turn circles huge) gBTGotoThrottle = (err > 0.5f || err < -0.5f) ? 0.2f : 1.0f; gBTGotoActive = 1; if (getenv("BT_GOTO_LOG")) { static float s_gl = 0.0f; s_gl += dt; if (s_gl >= 1.0f) { s_gl = 0.0f; DEBUG_STREAM << "[goto] dist=" << (float)sqrt((double)dist2) << " err=" << err << " turn=" << gBTGotoTurn << " thr=" << gBTGotoThrottle << " arr=" << arrived << " fwd=(" << fwdX << "," << fwdZ << ")" << " tgt=(" << s_gx << "," << s_gz << ")" << std::endl; } } // SELF-DRIVE (task #48): a "beeline" harness must supply its OWN // forward throttle, not only steering -- otherwise it just turns // in place and never closes the distance (the "drive-to-range // stall": BT_GOTO alone advanced nothing because the throttle came // only from BT_AUTODRIVE). Drive forward toward the target and cut // the throttle inside the stop radius so it holds at firing range. gBTDrive.forced = 1; gBTDrive.forcedThrottle = arrived ? 0.0f : 0.8f; turn = gBTGotoTurn; // non-real-controls fallback path } } } // ⭐ REAL CONTROLS (env BT_REAL_CONTROLS): route the raw input through the // RECONSTRUCTED MechControlsMapper (@004afd10) instead of consuming it raw. // INPUT BRIDGE (dev scaffolding, marked): write the mapper's published input // attributes exactly as a streamed Direct .CTL mapping would -- the engine's // ControlsInstanceDirectOf writes these same public members from the // device groups; the dev box has no RIO/Thrustmaster, so WASD stands in for // the stick/throttle HARDWARE. Everything downstream (speed/turn demands, // mode clamps, torso axes, HUD free-aim) is the real reconstructed tick, // which runs in the subsystem-roster walk below (un-skipped under this env). static const int s_realControls = BTEnvOn("BT_REAL_CONTROLS", 1); // default ON (=0 to disable) MechControlsMapper *mppr = MappingMapper(); // roster slot 0 (task #7) if (s_realControls && mppr != 0) { // Diagnostic: what the ENGINE controls push left in the attribute since // our last write (a stale device element overwriting the bridge shows // here as pre != our previous write). float preThrottle = mppr->throttlePosition; (void)preThrottle; mppr->throttlePosition = (throttle >= 0.0f) ? throttle : -throttle; mppr->reverseThrust = (throttle < 0.0f) ? 1 : 0; // ControlsButton: >=1 engaged mppr->stickPosition.x = turn; // Basic mode: turn = stick yaw mppr->stickPosition.y = 0.0f; // Consume the PREVIOUS frame's interpreted demands (the mapper ticks in // the roster walk after this block -- one frame of input latency). // turnDemand is the mode-shaped steering; speedDemand (world u/s, sign = // reverse) feeds the gait target below. turn = mppr->turnDemand; // BT_GOTO steering must reach the orientation integration DIRECTLY: the // mapper round-trip (stickPosition -> turnDemand) zeroes out in -net mode // (the key-bridge only shapes the local viewpoint mech there), which froze // the beeline's heading (it drove straight past the target). The goto turn // is a plain yaw demand -- apply it here, after the mapper read, so it works // identically solo and cross-net. extern int gBTGotoActive; extern float gBTGotoTurn; if (gBTGotoActive) turn = gBTGotoTurn; static float s_mpprLog = 0.0f; s_mpprLog += dt; if (s_mpprLog >= 1.0f) { s_mpprLog = 0.0f; DEBUG_STREAM << "[mppr] in thr=" << mppr->throttlePosition << " pre=" << preThrottle << " rev=" << mppr->reverseThrust << " stickX=" << mppr->stickPosition.x << " -> speedDemand=" << mppr->speedDemand << " turnDemand=" << mppr->turnDemand << " mode=" << mppr->controlMode << " mapper=" << (void*)mppr << " &mode=" << (void*)&mppr->controlMode << "\n" << std::flush; } } // BRING-UP one-shot: dump the mech's skeleton segment names + joint indices so // we can identify the TORSO horizontal joint for torso-twist tracking (P3). { static int s_segDump = 0; if (s_segDump++ == 0) { EntitySegment::SegmentTableIterator it(segmentTable); EntitySegment *seg; int i = 0; while ((seg = it.ReadAndNext()) != 0) { DEBUG_STREAM << "[skel] seg[" << i++ << "] name=" << seg->GetName() << " jointIdx=" << seg->GetJointIndex() << "\n"; } DEBUG_STREAM << "[skel] total=" << i << "\n" << std::flush; } } // Snapshot the pre-move position for the collision resolve after the drive. Point3D collisionOldPos = localOrigin.linearPosition; if (!gDriveSeeded) { gDriveHeading = 0.0f; gDriveSeeded = 1; } // --- AUTHENTIC turn rate (master-perf disasm 0x4aa3d3-0x4aa4ff [T1]) --------- // The yaw rate is per-mech lerp(walkingTurnRate, runningTurnRate) by GROUND // SPEED, not the old bring-up constant kDriveTurnRate. spd = horizontal speed // (last frame's worldLinearVelocity); base = walkingTurnRate; above the low // gate (reverseSpeedMax@0x538) lerp walk->run across [walkStride 0x534 .. // topSpeed 0x34c], with an over-run falloff runningTurnRate/t^2 past topSpeed; // clamp >=0. angularVelocity = turnDemand * turnRate, ZEROED in the death/ // limbo/airborne leg states (1/2/3) or when the death-anim latch is set. // Falls back to the bring-up constant if the model supplied no rates. Scalar authTurnRate = kDriveTurnRate; if (walkingTurnRate > 0.0f) { const Scalar spd = (Scalar)sqrtf(worldLinearVelocity.x * worldLinearVelocity.x + worldLinearVelocity.z * worldLinearVelocity.z); authTurnRate = walkingTurnRate; // base (walk / idle / turn-in-place) if (spd >= reverseSpeedMax) // 0x538 low gate { const Scalar den = reverseStrideLength - walkStrideLength; // topSpeed - walkStride const Scalar t = (den != 0.0f) ? (spd - walkStrideLength) / den : 0.0f; if (t <= 1.0f) authTurnRate = walkingTurnRate + (runningTurnRate - walkingTurnRate) * t; // walk->run else authTurnRate = runningTurnRate / (t * t); // over-run falloff } if (authTurnRate < 0.0f) authTurnRate = 0.0f; const int ls = legAnimationState; // 0x3b0 if (deathAnimationLatched != 0 || ls == 1 || ls == 2 || ls == 3) authTurnRate = 0.0f; // no yaw in death/limbo/airborne } if (getenv("BT_TURN_LOG")) DEBUG_STREAM << "[turnrate] walkTR=" << walkingTurnRate << " runTR=" << runningTurnRate << " spd=" << (Scalar)sqrtf(worldLinearVelocity.x*worldLinearVelocity.x + worldLinearVelocity.z*worldLinearVelocity.z) << " -> authTurnRate=" << authTurnRate << " (kDrive=" << kDriveTurnRate << ")\n" << std::flush; // --- turn: integrate heading, write it onto the body orientation ----- gDriveHeading += turn * authTurnRate * dt; // scalar mirror (drive log + fallback) if (s_realControls) { // AUTHENTIC ORIENTATION INTEGRATION (the IntegrateMotion-tail form): // compose the yaw RATE into the pose quaternion via the engine // integrate op (Quaternion::Add == FUN_00409f58) -- the same op the // binary's motion tail uses -- instead of rebuilding the orientation // from a scalar heading. The rate SCALE is now the AUTHENTIC per-mech // turn rate (authTurnRate above) -- lerp(walkingTurnRate, runningTurnRate) // by ground speed, master-perf 0x4aa3d3 [T1]. Vector3D angStep(0.0f, turn * authTurnRate * dt, 0.0f); Quaternion prevPose = localOrigin.angularPosition; localOrigin.angularPosition.Add(prevPose, angStep); } else { localOrigin.angularPosition = EulerAngles(0.0f, gDriveHeading, 0.0f); } localToWorld = localOrigin; // build the world matrix // --- forward step: the mech faces local -Z (gun ports / eyepoint are at // -Z; see btl4vid.cpp). Take the matrix Z basis in world and negate // to get the facing direction; the ACTUAL forward distance this frame // is the animation's root translation (see the Animate block below) -- // P3 STEP 1: locomotion is now ANIMATION-DRIVEN, not a procedural slide, // so the body advances exactly as far as the gait clip's feet step // (feet plant, no skating). The old `kDriveMaxSpeed * throttle * dt` // slide is removed; `adv` from AnimationInstance::Animate replaces it. UnitVector zAxis; localToWorld.GetFromAxis(Z_Axis, &zAxis); const float fx = -zAxis.x; const float fz = -zAxis.z; // --- P3 STEP 2: GAIT SELECTION by throttle. Pick the body clip from throttle // magnitude -- |throttle| >= kRunThrottle -> RUN (blhrrl), else WALK (blhwwl); // each moves at ITS clip's authentic root-translation speed (STEP 1). Throttle // SIGN gives direction: negative -> reverse (back up). (The blh set has no // dedicated reverse CYCLE clip -- stand/walk/run + transitions only -- so reverse // is a bring-up: it plays the selected forward clip and drives the body backward.) // SetAnimation only on a gait CHANGE (re-binding every frame would reset the phase). // // ⚠ GAIT MUST TICK EVERY FRAME (task #15 fix): this block was gated on // `throttle != 0`, so releasing the key stopped CALLING the state machine -- // the skeleton froze at the last written pose and the authentic walk-> // stop->stand transition chain (BodyClipFinished states 8/9, the wsl/wsr // clips, which END in the standing pose) never got the chance to play. // The SM handles idle natively: bodyTargetSpeed=0 drives walk->stop->stand // and the Standing case returns adv=0 (no motion). Only the LEGACY inline // clip-select path (BT_GAIT_SM=0) still needs the throttle gate (it would // loop the walk clip forever at idle). { static const int s_gaitTicksAlways = BTEnvOn("BT_GAIT_SM", 1); if (s_gaitTicksAlways || throttle != 0.0f) { const int wantGait = ((throttle < 0.0f ? -throttle : throttle) >= kRunThrottle) ? 1 : 0; const char *suffix = wantGait ? "rrl" : "wwl"; // ⭐ P3 STEP-7 CUTOVER (env BT_GAIT_CUTOVER; default OFF -> STEP 1-2 path). // Drive locomotion via the reconstructed two-channel gait (the real // SequenceController bodyAnimation), replacing the STEP-1/2 free-standing // AnimationInstance stand-in. bodyAnimation.Advance runs the reconstructed // controller: it animates the skeleton joints AND returns the clip's // per-frame root-translation forward step (applied to localOrigin). static const int s_gaitCutover = BTEnvOn("BT_GAIT_CUTOVER", 1); // default ON (=0 to disable) if (s_gaitCutover) { static const int s_gaitSM = BTEnvOn("BT_GAIT_SM", 1); // default ON (=0 to disable) Scalar adv; Scalar legAdv = 0.0f; // leg-channel distance (drives the LOCAL mech) if (s_gaitSM) { // STEP 2: drive the REAL gait STATE MACHINE (Mech::AdvanceBodyAnimation, // mech2.cpp) instead of the inline clip-select. It re-syncs bodyAnimation // State from bodyStateAlarm, slews bodyCycleSpeed toward bodyTargetSpeed with // the LoadLocomotionClips speed caps, and Advances the bound clip. Feed the // inputs the controls mapper normally would (it is bypassed in bring-up) and // force the walk-cycle state 6 (case 6/7 -> slew to walkStrideLength + Advance // animationClips[6]=wwr). Requires BT_GAIT_CUTOVER too (loads the clips). globalTimeScale = 1.0f; idleStrideScale = 1.0f; // forwardCycleRate: read from the model record (+0x44) in the // ctor, but the Blackhawk's value decodes to ~1 u/s^2 -- a // 55-SECOND ramp to run speed (the mech "couldn't go forward" // and the clip crawled in slow motion). Either the record // field is mis-offset or its units differ (per-frame?). Until // the field decode is verified against the raw parser, FLOOR // the authentic MaxAcceleration reads directly since the task #4 // record-layout fix (madcat: 30 u/s^2); the old floor-25 block // and its one-shot log are retired. if (!IsMechDestroyed()) // a dead mech keeps its death movementMode SetMovementMode(1); // ground, non-death, non-airborne // reverseSpeedMax2@0x7a0 is the run-cycle bodyCycleSpeed CLAMP (AdvanceBody // Animation case 12/13); LoadLocomotionClips does not set it -> it reads // 0xCDCDCDCD (-4.3e8) and the clamp clobbers bodyCycleSpeed -> the run cycle // explodes. Bring-up: clamp the run cadence to the run stride (reverseStride // Length, the case-12 divisor) so bodyCycleSpeed/reverseStrideLength ~ 1, like // walk. (The authentic value comes from the model/LoadLocomotionClips.) reverseSpeedMax2 = reverseStrideLength; // Gait SELECTION by throttle: walk aims for walkStrideLength; run aims for // reverseSpeedMax (the run/reverse cap). Exceeding walkStrideLength makes the // walk handler transition "up" to the run cycle (state 11 -> 12/13) via the real // BodyClipFinished callback -- i.e. the authentic walk->run transition. // REAL CONTROLS: the commanded speed comes from the reconstructed mapper's // speedDemand (topSpeed * throttle * scale, mode-clamped vs steering -- the // AUTHENTIC gait selection input; |.| because sign encodes reverse). // SIGNED demand (task #15): the earlier Abs() stripped the sign, so // the body/motion channel could never see reverse -- motion direction // then had to come from the raw throttle sign, flipping INSTANTLY // while the animation transitioned gradually (the "physics don't // line up with the animation" desync). Signed, the SM decelerates // through stop -> reverse-entry exactly like the leg channel. if (s_realControls && MappingMapper() != 0) bodyTargetSpeed = MappingMapper()->speedDemand; else bodyTargetSpeed = ((throttle < 0.0f) ? -1.0f : 1.0f) * (wantGait ? reverseSpeedMax : walkStrideLength); if (gCurrentGait != 6) { bodyCycleSpeed = 0.0f; // slew up from rest // Enter at STANDING (task #15): both channels' case-0 handlers // self-arm stand->walk when the demand rises past standSpeed // (AdvanceBodyAnimation case 0 == raw FUN_004a5678), so the boot // state is the authentic idle stand -- no pre-armed walk clip // stuck at frame 1 while waiting for the first throttle. bodyStateAlarm.SetLevel(0); if (s_realControls) { // AUTHENTIC TWO-CHANNEL SPLIT: the LEG channel enters // NATURALLY from Standing (state 0): AdvanceLegAnimation's // case-0 sees the live mapper speedDemand > standSpeed and // arms stand->walk (5); the real leg finished-callback // (LegClipFinished == FUN_004a6928) then chains the // transitions. Only hygiene here: a clean cycle-speed start. legCycleSpeed = 0.0f; } gCurrentGait = 6; DEBUG_STREAM << "[gaitSM] enter walk-6 walkStride=" << walkStrideLength << " reverseSpeedMax=" << reverseSpeedMax << " target=" << bodyTargetSpeed << " split=" << s_realControls << "\n" << std::flush; } if (s_realControls) { // CHANNEL ROLES (task #49, disasm-corrected [T1]): in the binary // - the MASTER PERF (0x4a9b5c region) advances the LEG channel // (call @0x4aa399, airborne @0x4aa388) and stores -dist/dt into // localVelocity (+0x1cc) -- the LEG drives the LOCAL mech's // travel AND (writing last, mj=1) the displayed pose; // - IntegrateMotion (0x4ab1c8, body advance @0x4ab312) stores // -dist/dt into the PROJECTED velocity (+0x2a0) -- the BODY // channel is the dead-reckoning/replication PROJECTOR, whose // phase drift is locally INVISIBLE. // The previous arrangement here (v4: display+travel from the BODY) // had the roles swapped: whenever the two state machines phase-split // (the leg-only trn pivot seed, or a demand change landing between // the channels' end-of-clip callbacks -> divergent transitions), the // out-of-phase LEG pose showed through on every frame the body // didn't write (Standing/wind-down) = the visible leg stutter. // v5 ordered body-first/leg-last believing the leg's overwrite made // body drift invisible -- WRONG (task #64): the leakage merely // inverted (the body's pose flashed through instead). v6 (below) // removes the body from the skeleton entirely (mj=0). // task #64 RESOLUTION (user-verified): the body channel must NOT // write joints. The v5 claim "body writes first, leg overwrites, // so body drift can never show" was FALSE -- whenever the two // channels phase-split (the turn-in-place entry armed only the // leg), the body's out-of-phase pose leaked into the displayed // skeleton: rhythmic leg skips + visibly reduced bob, 100% repro // on turn-then-walk, while every leg-channel trace read clean // (the leg data was fine -- the RENDERED pose wasn't the leg's). // mj=0 advances + projects the body channel identically (records, // cycle speeds, travel projection all unchanged -- same engine // mode the replicant leg path uses) but structurally removes the // second skeleton writer. This also matches the binary's own // observable: "the body channel's phase drift is locally // INVISIBLE in the binary" (locomotion.md) -- now it is here too. // BT_BODY_MJ=1 restores the old double-writer for A/B. static const int s_bodyMj = BTEnvOn("BT_BODY_MJ", 0); adv = AdvanceBodyAnimation(dt, s_bodyMj); // channel B: replication projection (mj=0) legAdv = AdvanceLegAnimation(dt); // channel A: local sim -- pose + travel } else { adv = AdvanceBodyAnimation(dt, 1); // single-channel: body does both } // [syncF] per-frame divergence probe (task #49): log every frame // where the two channels return different distances or either // state machine changed state -- pinpoints WHICH frames the // advSum/legSum drift and the visible pose pops come from. if (getenv("BT_SYNC_LOG")) { static int s_lastBS = -1, s_lastLS = -1; const int bs = (int)bodyStateAlarm.GetLevel(); const int ls = (int)legStateAlarm.GetLevel(); const float dD = (float)(adv - legAdv); if (bs != s_lastBS || ls != s_lastLS || dD > 0.001f || dD < -0.001f) { DEBUG_STREAM << "[syncF] dt=" << dt << " bs=" << bs << " ls=" << ls << " adv=" << adv << " legAdv=" << legAdv << " bCyc=" << bodyCycleSpeed << " lCyc=" << legCycleSpeed << " bFrm=" << bodyAnimation.currentFrame << " bT=" << bodyAnimation.currentTime << " lFrm=" << legAnimation.currentFrame << " lT=" << legAnimation.currentTime << "\n" << std::flush; s_lastBS = bs; s_lastLS = ls; } } // COCKPIT BOB (task #15): the leg channel writes the clip's vertical // root motion into jointlocal (balltranslate) every frame, but the // camera's DCS chain doesn't consume animated joints -- publish the // baseline-relative bob for DPLEyeRenderable (L4VIDRND.cpp) to add // to the view. Baseline = the first sample after the joint resolves // (the clip's neutral root height). { extern float gBTEyeBobY; extern float gBTEyeSwayX; static Joint *s_rootJt = 0; static int s_rootTried = 0; static float s_bobBase = 0.0f, s_swayBase = 0.0f; static int s_bobBased = 0; if (!s_rootTried) { s_rootTried = 1; s_rootJt = ResolveJoint("jointlocal"); } if (s_rootJt) { const Point3D rt = s_rootJt->GetTranslation(); if (!s_bobBased) { s_bobBased = 1; s_bobBase = rt.y; s_swayBase = rt.x; } gBTEyeBobY = rt.y - s_bobBase; gBTEyeSwayX = rt.x - s_swayBase; // lateral weight shift (the swagger) } } static float s_smlog = 0.0f; s_smlog += dt; static double s_advSum = 0.0, s_legSum = 0.0; s_advSum += adv; s_legSum += legAdv; // BT_GAIT_TRACE=1 (task #64): log the gait state EVERY frame (not 1 Hz) // so a frame-level stutter at a transition is visible. static const int s_gtrace = getenv("BT_GAIT_TRACE") ? 1 : 0; if (s_smlog >= 1.0f || s_gtrace) { s_smlog = 0.0f; extern float gBTEyeBobY; static Joint *s_dbgRoot = 0; static int s_dbgTried = 0; if (!s_dbgTried) { s_dbgTried = 1; s_dbgRoot = ResolveJoint("jointlocal"); } Point3D dbgRt(0,0,0); if (s_dbgRoot) dbgRt = s_dbgRoot->GetTranslation(); DEBUG_STREAM << "[sync] advSum=" << (float)s_advSum << " legSum=" << (float)s_legSum << " posZ=" << localOrigin.linearPosition.z << " rootZ=" << dbgRt.z << " rootX=" << dbgRt.x << "\n" << std::flush; MechControlsMapper *s_tm = MappingMapper(); // input demands (task #64) DEBUG_STREAM << "[gaitSM] adv=" << adv << " legAdv=" << legAdv << " cycleSpeed=" << bodyCycleSpeed << " legCycle=" << legCycleSpeed << " state=" << bodyAnimationState << " legState=" << legAnimationState << " kfCur=" << bodyAnimation.currentFrame << " legFrm=" << legAnimation.currentFrame << " spd=" << (s_tm ? s_tm->speedDemand : 0.0f) << " turn=" << (s_tm ? s_tm->turnDemand : 0.0f) << " bob=" << gBTEyeBobY << "\n" << std::flush; } } else { if (gCurrentGait != wantGait) { ResourceDescription::ResourceID *clip = ResolveAnimationClip("blh", suffix); if (clip) { // loop callback: SequenceController::Advance calls it at end-of-clip // to re-arm the same clip (bring-up loop; see Mech::LoopBodyClip). bodyAnimation.SelectSequence(*clip, (void *)&Mech::LoopBodyClip, 0, 0); gCurrentGait = wantGait; DEBUG_STREAM << "[gait] SequenceController -> blh" << suffix << " id=" << (long)*clip << " (" << (wantGait ? "run" : "walk") << ")" << std::endl; } } adv = bodyAnimation.Advance(dt, 1); // inline clip-select path } // Motion direction comes FROM the animation: reverse clips carry // negative root translation, so adv is already signed. (dir kept // only for the legacy non-SM path, whose forward clips are unsigned.) const float dir = (s_gaitCutover && BTEnvOn("BT_GAIT_SM", 1)) ? 1.0f : ((throttle < 0.0f) ? -1.0f : 1.0f); // --- AUTHENTIC world-step (Mech::IntegrateMotion tail @004ab1c8) --- // The 1995 mech does NOT slide the position directly; it integrates a // VELOCITY vector rotated by the body orientation. VERIFIED mapping: the // 1995 motion transform { Point3D @0x260; Quaternion @0x26c } IS the engine // `localOrigin` (Origin = { linearPosition; angularPosition }, ORIGIN.h:15; // FUN_0040ab44 builds the matrix from BOTH halves). So reproduce the real // integrator: localVelocity = {0,0,-adv/dt} (forward = local -Z), rotate it // into world space by the orientation via the now-backed world-step transform // FUN_00408744, then position += worldVelocity*dt. Result == facing*adv, but // through the authentic velocity->rotate->integrate model (runs FUN_00408744 // live). Remaining full-IntegrateMotion work: velocity STORAGE for dead-reckon, // orientation integration, and the AdvanceBodyAnimation gait state machine // (+ LoadLocomotionClips) in place of the inline clip select above. const Scalar invDt = (dt > 0.0f) ? (1.0f / dt) : 0.0f; // VELOCITY SMOOTHING (task #15, the binary's IntegrateMotion model): // SequenceController::Advance returns distance only when keyframes // are CROSSED -- partial frames interpolate the joints but add zero // distance (binary-faithful lumping). Integrating adv/dt directly // stutters the ground speed while the legs sweep smoothly (feet // appear to glide/skate). The original smoothed this through its // persistent-velocity integration; reproduce it: velocity = the // accumulated distance over the elapsed time since the last // keyframe contribution, HELD across zero-distance frames, hard // zero when Standing (and after 0.3s of silence, e.g. paused clip). // FOOT-PLANT BY CONSTRUCTION (v5 -- task #49, disasm-corrected): // display AND travel BOTH come from the LEG channel (the binary's // master perf advances the leg and writes -dist/dt into // localVelocity @+0x1cc; the leg writes the pose last). The v4 // reading ("raw FUN_004ab430:15076 takes the travel from the body // advance") was WRONG -- 0x4ab312's IntegrateMotion stores its // -dist/dt into the PROJECTED velocity (+0x2a0), the dead-reckoning // feed, not local travel. v3 (travel=leg, display=body) foot-slipped // because display and travel were DIFFERENT channels; v4 unified on // the body, which planted feet but let the out-of-phase LEG pose show // through wherever the body didn't write (the visible stutter). v5 // unifies on the leg exactly as the binary does: display == travel by // construction, live-demand channel authoritative, body drift local- // invisible. (Knockdown still staggers BOTH channels, so a hard // impact freezes travel as before.) const Scalar travelAdv = s_realControls ? legAdv : adv; const Scalar localAdv = travelAdv * dir; linearSpeed = (localAdv < 0.0f ? -localAdv : localAdv) * invDt; // forward ground speed -> LinearSpeed gauge Vector3D localVel(0.0f, 0.0f, -localAdv * invDt); // exact frame distance as velocity Matrix34 orient; // rotation from the heading (set @ line ~626) Matrix34::FromQuaternion(&orient, &localOrigin.angularPosition); Vector3D worldVel; FUN_00408744(&worldVel, (Scalar *)&localVel, &orient); // worldVel = orient * localVel localOrigin.linearPosition.x += worldVel.x * dt; localOrigin.linearPosition.y += worldVel.y * dt; localOrigin.linearPosition.z += worldVel.z * dt; localToWorld = localOrigin; gBodyAnimLog += dt; if (gBodyAnimLog >= 1.0f) { gBodyAnimLog = 0.0f; DEBUG_STREAM << "[gait] adv=" << travelAdv << " (proj=" << adv << ") pos=(" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.z << ")" << std::endl; } } else { if (gBodyAnim == 0 || gCurrentGait != wantGait) // first time, or gait changed { ResourceDescription::ResourceID *clip = ResolveAnimationClip("blh", suffix); if (clip) { if (gBodyAnim == 0) gBodyAnim = new AnimationInstance(this); // LOOP callback (not NULL) -- Animate invokes it at clip end // (JMOVER ~1592); NULL there crashes on a null member ptr. gBodyAnim->SetAnimation( *clip, reinterpret_cast(&Mech::OnBodyAnimFinished)); gCurrentGait = wantGait; gBodyAnimReady = 1; DEBUG_STREAM << "[anim] gait -> blh" << suffix << " id=" << (long)*clip << " (" << (wantGait ? "run" : "walk") << ")" << std::endl; } else { gBodyAnimReady = -1; DEBUG_STREAM << "[anim] blh" << suffix << " unresolved -- not moving\n" << std::flush; } } if (gBodyAnimReady == 1 && gBodyAnim != 0) { // Animate advances the clip, writes the joint DCS (legs cycle) AND returns // the clip's per-frame root-translation distance (the [RootTranslation] // integral -- CLAUDE.md section 7). Drive the body by exactly that distance // so travel == foot stride (feet plant); negative throttle backs it up. Scalar adv = gBodyAnim->Animate(dt, True); // Motion direction comes FROM the animation: reverse clips carry // negative root translation, so adv is already signed. (dir kept // only for the legacy non-SM path, whose forward clips are unsigned.) const float dir = (s_gaitCutover && BTEnvOn("BT_GAIT_SM", 1)) ? 1.0f : ((throttle < 0.0f) ? -1.0f : 1.0f); localOrigin.linearPosition.x += fx * adv * dir; localOrigin.linearPosition.z += fz * adv * dir; linearSpeed = (dt > 0.0f) ? ((adv < 0.0f ? -adv : adv) / dt) : 0.0f; // LinearSpeed gauge localToWorld = localOrigin; // rebuild with the new position gBodyAnimLog += dt; if (gBodyAnimLog >= 1.0f) { gBodyAnimLog = 0.0f; DEBUG_STREAM << "[anim] " << (wantGait ? "run" : "walk") << (dir < 0 ? " (reverse)" : "") << " adv=" << adv << " loops=" << gBodyAnimLoops << " pos=(" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.z << ")" << std::endl; } } } // close the BT_GAIT_CUTOVER else (STEP 1-2 path) } // close the gait-ticks-always gate } // close the every-frame gait scope (task #15 fix) // DIAG (turn-hitch hunt): flag any frame that took abnormally long -- the // hang shows up as a big dt on the NEXT tick. Correlate with [loadobj]. if (dt > 0.2f) DEBUG_STREAM << "[spike] dt=" << dt << " turn=" << turn << " thr=" << throttle << "\n" << std::flush; // --- 1 Hz world-position log while moving (proves the body walks) ----- gDriveLogAccum += dt; if ((throttle != 0.0f || turn != 0.0f) && gDriveLogAccum >= 1.0f) { gDriveLogAccum = 0.0f; DEBUG_STREAM << "[drive] pos=(" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.y << ", " << localOrigin.linearPosition.z << ") hdg=" << gDriveHeading << " thr=" << throttle << " turn=" << turn << " dt=" << dt << "\n" << std::flush; } // --- COLLISION (engine Mover flow; gated BT_COLLISION) ------------------ // After the drive moved localOrigin, resolve penetration against the world's // collision solids (terrain buttes/hills + buildings) with the engine's OWN // pipeline (MOVER.cpp): reposition the collision volume to the new transform, // gather overlapping solids, and let ProcessCollisionList push localOrigin back // out of anything it entered (it writes localOrigin.linearPosition + reads // worldLinearVelocity). This is what stops the mech phasing through objects and // sinking through the ground. The dead Mech::Simulate used a 1995 heightfield // terrain-follow (FUN_0040e5f0); the 2007 engine models terrain AS collision // solids, so this single path covers both terrain-follow AND object collision. // Guarded on a real collision volume -> no-op for volume-less mechs. // --- VELOCITY STORAGE (the MP update writer; ALWAYS maintained) --------- // The engine Mover::WriteUpdateRecord (MOVER.cpp:740) publishes // localVelocity + worldLinearVelocity in every network update record -- // the data remote pods dead-reckon from. Keep them live each frame: // world velocity from the frame's position delta; local velocity = // forward speed on local -Z (the mech's facing axis) + the yaw rate. { const Scalar invDtV = (dt > 0.0001f) ? (1.0f / dt) : 0.0f; worldLinearVelocity.x = (localOrigin.linearPosition.x - collisionOldPos.x) * invDtV; worldLinearVelocity.y = (localOrigin.linearPosition.y - collisionOldPos.y) * invDtV; worldLinearVelocity.z = (localOrigin.linearPosition.z - collisionOldPos.z) * invDtV; Scalar fwdSpeed = (Scalar)sqrtf( worldLinearVelocity.x * worldLinearVelocity.x + worldLinearVelocity.z * worldLinearVelocity.z); localVelocity.linearMotion = Vector3D(0.0f, worldLinearVelocity.y, -fwdSpeed); localVelocity.angularMotion = Vector3D(0.0f, turn * authTurnRate, 0.0f); // MASTER twin of [replmov]: the velocity this mech PUBLISHES vs its own // leg state -- compare the master ramp to the replicant response. if (getenv("BT_REPL_MOV") && (fwdSpeed > 0.01f || throttle != 0.0f)) DEBUG_STREAM << "[mastervel] fwdSpeed=" << fwdSpeed << " thr=" << throttle << " legState=" << (int)legStateAlarm.GetLevel() << " pos=(" << localOrigin.linearPosition.x << "," << localOrigin.linearPosition.z << ")" << " dt=" << dt << "\n" << std::flush; // CRASH motion suppression (spec: the binary zeroes localVelocity while // crashed). While the stagger clip (legState 0x20) plays, freeze the // velocity so a knocked-down mech does not creep back into the obstacle and // re-detect an impact every frame (impactVel = localVelocity below). Then // run the post-recovery refractory so it can't instantly re-knockdown. if ((int)legStateAlarm.GetLevel() == 0x20) { worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f); localVelocity.linearMotion = Vector3D(0.0f, 0.0f, 0.0f); } } if (GroundReal()) { // AUTHENTIC GROUND MODEL (task #15, ground-model-decode): the binary's // probe/snap/response block (FUN_004a9b5c @4aa630-4aab5f) -- see the // AuthenticGroundAndCollide banner below. NO gravity, NO floor clamp, // NO step guard in this path; a probe MISS (h==-1) does NOTHING (Y // holds -- byte-faithful, also correct over authentic no-solid content // like the rav canyon backdrops / arena detail pieces). AuthenticGroundAndCollide(dt, collisionOldPos); } else if (BTEnvOn("BT_COLLISION", 1) && GetCollisionVolumeCount() > 0) { // GRAVITY / terrain-settle: the collision below only pushes the mech OUT of // solids -- there is no downward force, so a floating mech never comes down. // Press it down each frame; the collision then holds it at the terrain surface // (gravity down + collision up = equilibrium ON the ground). A FLOOR CLAMP at // the spawn/ground Y is the safety net: on a map with NO terrain solid the mech // rests at ground level instead of falling forever. On real terrain (modeled as // solids) the collision catches it above the floor -> it follows hills/valleys. // kGravityRate world-units/sec (tunable via BT_GRAVITY). static Scalar s_groundY = 0.0f; static int s_groundYSet = 0; static const Scalar kGravityRate = getenv("BT_GRAVITY") ? (Scalar)atof(getenv("BT_GRAVITY")) : 20.0f; if (!s_groundYSet) { s_groundY = localOrigin.linearPosition.y; s_groundYSet = 1; } localOrigin.linearPosition.y -= kGravityRate * dt; // fall const Scalar invDtC = (dt > 0.0f) ? (1.0f / dt) : 0.0f; worldLinearVelocity.x = (localOrigin.linearPosition.x - collisionOldPos.x) * invDtC; worldLinearVelocity.y = (localOrigin.linearPosition.y - collisionOldPos.y) * invDtC; worldLinearVelocity.z = (localOrigin.linearPosition.z - collisionOldPos.z) * invDtC; MoveCollisionVolume(); // reposition the volume to localToWorld BoxedSolidCollisionList *cols = GetCurrentCollisions(); if (cols) { Point3D before = localOrigin.linearPosition; frameEntryWorldVelocity = worldLinearVelocity; // collision-damage guard (see mech.hpp) Damage collisionDamage; // filled by ProcessCollisionList (unused) ProcessCollisionList(cols, dt, collisionOldPos, &collisionDamage); // pushes localOrigin out Scalar dx = localOrigin.linearPosition.x - before.x; Scalar dz = localOrigin.linearPosition.z - before.z; if (dx*dx + dz*dz > 0.01f) // log only real object/wall pushes (not ground settle) DEBUG_STREAM << "[collide] pushed out by (" << dx << ", " << (localOrigin.linearPosition.y - before.y) << ", " << dz << ") pos=(" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.z << ")" << std::endl; } // Floor clamp: never sink below the base ground (safety on solid-less maps). if (localOrigin.linearPosition.y < s_groundY) localOrigin.linearPosition.y = s_groundY; localToWorld = localOrigin; // rebuild after gravity/collision/clamp } // LOD EYEPOINT (authentic): feed the renderer the MECH's eyepoint as the // LOD-distance reference. The pod's eyepoint sat ON the mech (cockpit), // so turning in place never changed an object's LOD distance; our chase // camera ORBITS the mech +-40u as it turns, which swept objects near // their 0x2046 band edges in and out = "scenery blinks with viewing // angle" / floor flicker at the arena fringe. { extern void BTSetLodEye(float, float, float); BTSetLodEye((float)localOrigin.linearPosition.x, (float)localOrigin.linearPosition.y + 7.0f, // cockpit height (float)localOrigin.linearPosition.z); } // SHADOW TILT (task #20 + #15): pose 'jointshadow' (the *_tshd proxy's // terrain-angle channel; SKL "apply terrain angle to pitch and roll") to the // LOCAL ground slope each frame. The ground decode (#15) is now live, so // instead of the old flat-up placeholder we sample the collision surface at // the mech's XZ and two world-axis offsets, build the surface normal from the // height gradient, and rotate it into the mech's local frame (upright, yaw = // gDriveHeading). On a slope the shadow then LIES ON the ground; the old flat // quad was buried in the hillside and Z-culled -> "the shadow disappears on // elevation" (and the missing ground-contact cue made the feet read as sunk, // though the origin is measurably ON the surface). A probe miss (mech at the // edge of its collision node) falls back to flat. Gated BT_SHADOW_TILT (=0 // restores the flat placeholder for A/B). { Vector3D shadowNormal(0.0f, 1.0f, 0.0f); static int s_shTilt = -1; if (s_shTilt < 0) { const char *sv = getenv("BT_SHADOW_TILT"); s_shTilt = (sv == 0 || sv[0] != '0') ? 1 : 0; } BoundingBoxTreeNode *gnode = (GetCollisionVolumeCount() > 0 && collisionTemplate != 0) ? GetMoverCollisionRoot() : 0; if (s_shTilt && gnode != 0) { const Scalar D = 12.0f; // probe offset (small -> stays in the node) const Scalar baseY = localOrigin.linearPosition.y + collisionTemplate->minY + 50.0f; const Scalar cx = localOrigin.linearPosition.x; const Scalar cz = localOrigin.linearPosition.z; Scalar hC = -1.0f, hX = -1.0f, hZ = -1.0f; Point3D qC(cx, baseY, cz), qX(cx + D, baseY, cz), qZ(cx, baseY, cz + D); BoundingBox *bC = gnode->FindBoundingBoxUnder(qC, &hC); BoundingBox *bX = gnode->FindBoundingBoxUnder(qX, &hX); BoundingBox *bZ = gnode->FindBoundingBoxUnder(qZ, &hZ); if (bC != 0 && bX != 0 && bZ != 0) { // surfaceY = probeY - downDistance; then add the render-only // VISUAL lift at each probe (task #49b): the quad is drawn on // the VISIBLE terrain (btvisgnd conform), which runs 0..~2u // above the collision solid by DIFFERENT amounts across a // slope -- the VISUAL gradient, not the collision gradient, // is the surface the quad must hug. Lift 0 (no data/gated // off) degrades to the collision gradient. extern float BTVisualGroundLift(float x, float y, float z); Scalar yC = baseY - hC, yX = baseY - hX, yZ = baseY - hZ; yC += BTVisualGroundLift((float)cx, (float)yC, (float)cz); yX += BTVisualGroundLift((float)(cx+D), (float)yX, (float)cz); yZ += BTVisualGroundLift((float)cx, (float)yZ, (float)(cz+D)); Vector3D wn(-(yX - yC) / D, 1.0f, -(yZ - yC) / D); Scalar len = (Scalar)sqrtf(wn.x * wn.x + wn.y * wn.y + wn.z * wn.z); if (len > 1e-6f) { wn.x /= len; wn.y /= len; wn.z /= len; } // world -> mech-local: rotate by -yaw about Y. Use the TRUE // heading from localToWorld (engine convention MATRIX.cpp:196: // Z basis = (sin y, 0, cos y) -> yaw = atan2(z.x, z.z)) -- NOT // the gDriveHeading scalar mirror, which is seeded to 0 and // drifts from the real pose (the task-#48 goto-steering bug // class; a wrong yaw here swings the tilt as the mech turns). UnitVector zAxisS; localToWorld.GetFromAxis(Z_Axis, &zAxisS); const Scalar shYaw = (Scalar)atan2f((float)zAxisS.x, (float)zAxisS.z); const Scalar cth = (Scalar)cosf((float)shYaw); const Scalar sth = (Scalar)sinf((float)shYaw); shadowNormal.x = wn.x * cth - wn.z * sth; shadowNormal.y = wn.y; shadowNormal.z = wn.x * sth + wn.z * cth; if (getenv("BT_SHADOW_LOG")) { static float s_shLog = 0.0f; s_shLog += dt; if (s_shLog >= 1.0f) { s_shLog = 0.0f; DEBUG_STREAM << "[shtilt] wn=(" << wn.x << "," << wn.y << "," << wn.z << ") local=(" << shadowNormal.x << "," << shadowNormal.y << "," << shadowNormal.z << ") yaw=" << shYaw << "\n" << std::flush; } } } } UpdateShadowJoint(shadowNormal); } // --- VISUAL-GROUND CONFORM (PORT ADDITION, presentation only; btvisgnd.cpp; // gate BT_VISUAL_GROUND, default ON) --------------------------------- // The sim rides the coarse 1995 collision solids (authentic snap: origin.y // = solid surfaceY); the VISUAL terrain runs 0..~2.1u above them on slopes, // clipping the feet. Invisible in 1995 (cockpit-only view); visible in our // external camera. Lift the RENDER matrix to the visible surface sampled // from the actual terrain triangles under the mech. localOrigin (collision, // aim, damage, dead-reckoning) is NEVER touched; next frame's drive rebuilds // localToWorld from localOrigin, so the lift is per-frame and render-only. { extern float BTVisualGroundLift(float x, float y, float z); const float vLift = BTVisualGroundLift( localOrigin.linearPosition.x, localOrigin.linearPosition.y, localOrigin.linearPosition.z); if (vLift != 0.0f) { localToWorld(3, 1) = localToWorld(3, 1) + vLift; if (GroundLog()) { static float s_vgAccum = 0.0f; s_vgAccum += dt; if (s_vgAccum >= 1.0f) { s_vgAccum = 0.0f; DEBUG_STREAM << "[visgnd] lift=" << vLift << " at (" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.y << ", " << localOrigin.linearPosition.z << ")" << std::endl; } } } } // --- MASTER UPDATE EMISSION (P6 multiplayer; the VTV pattern, RP/VTV.cpp: // 840-861) -------------------------------------------------------- // Predict where remote pods believe this mech is (run OUR OWN dead // reckoner: projectedOrigin advances from the last-broadcast state) and // ForceUpdate() when the truth diverges -- position error, orientation // deviation, or the 2-second heartbeat. ForceUpdate marks the update- // model bits; the engine update pump then WriteUpdateRecord()s (our // localVelocity/worldLinearVelocity are maintained every frame above) // and the InterestManager broadcasts to all remote hosts. if (GetInstance() == MasterInstance && deadReckoner != 0 && application != 0 && application->GetApplicationState() == Application::RunningMission) { // (RunningMission gate: broadcasting motion during mission CREATION kept // the PEER's event queue busy, so its CreatingMission->LoadingMission // quiet-timeout never fired -- the pods deadlocked pre-launch. The real // system never moves before launch, so the gate is faithful.) (this->*deadReckoner)(); Vector3D error; error.Subtract( projectedOrigin.linearPosition, localOrigin.linearPosition); Quaternion angular_deviation; angular_deviation.Subtract( projectedOrigin.angularPosition, localOrigin.angularPosition); // Position deadband (binary @0x4aad35: |pos - lastSent|^2 vs the // model's UpdatePositionDiffrence @0x768) + the 2s heartbeat. // Guard against an unstreamed/zero deadband with the old stand-in. { Scalar posDb = (updatePositionDeadband > 0.0f) ? updatePositionDeadband : 0.04f; // LINEAR "came-to-rest" trigger -- the symmetric analog of the // angular (live yaw-rate==0 && replicated yaw-rate!=0) resync // trigger in the type-4 gate below. The position deadband alone // does NOT catch a stop: once the master halts, its localOrigin is // pinned and its OWN dead-reckon projection is re-based to it each // frame, so error->0 and no pose record fires -- leaving the peer // holding the last WALKING velocity (updateVelocity), which the // replicant keeps dead-reckoning + animating as a phantom walk // until the 2s heartbeat. Fire a pose record the frame the live // horizontal speed collapses while the last-SENT speed was still // non-zero, so the replicant gets velocity=0 immediately. const Scalar liveSpdSq = (Scalar)(localVelocity.linearMotion.x * localVelocity.linearMotion.x + localVelocity.linearMotion.z * localVelocity.linearMotion.z); const Scalar sentSpdSq = (Scalar)(updateVelocity.linearMotion.x * updateVelocity.linearMotion.x + updateVelocity.linearMotion.z * updateVelocity.linearMotion.z); const Logical cameToRest = (liveSpdSq < 0.01f && sentSpdSq > 0.01f); // DENSE MOTION SEND (replicant-smoothness): the position deadband // fires IRREGULARLY (only after ~0.55u of drift), but the replicant's // dead-reckoner predicts the next record one PAST-interval ahead and // lerps its localOrigin toward that fixed horizon target -- so when the // deadband cadence != the predicted cadence, the peer decelerates // toward a stale target then lurches when a record finally lands // (measured: dead-reckoned ground speed surging 5<->27 while the master // walks a steady 6.13). Sending a pose record EVERY frame while moving // makes the cadence regular + dense (records ~1 frame apart), so the // horizon prediction matches and the lerp tracks smoothly. On a LAN // this is a handful of small packets/frame -- negligible. static const int s_denseTx = getenv("BT_NO_DENSE_TX") ? 0 : 1; const Logical moving = (liveSpdSq > 0.25f); if ( error.LengthSquared() > posDb || lastPerformance - lastUpdate > 2.0f || cameToRest || (s_denseTx && moving) ) { ForceUpdate(); // type 0: pose record if (getenv("BT_WIRE")) DEBUG_STREAM << "[tx0] errSq=" << error.LengthSquared() << " posDb=" << posDb << " hb=" << (float)(lastPerformance - lastUpdate) << (cameToRest ? " REST" : "") << " lvLin=(" << localVelocity.linearMotion.x << "," << localVelocity.linearMotion.z << ")\n" << std::flush; } } // ORIENTATION rides ONLY the type-4 resync record (the authentic // case-0 writer/reader save-restore strips it from the pose // record). Binary triggers (@0x4aac2b / @0x4aac6c): // |localOrigin.angular.y - projectedOrigin.angular.y| > @0x770 // |localVelocity.angular.y - projectedVelocity.angular.y| > @0x76c // or (live yaw-rate == 0 && replicated yaw-rate != 0) // -- the quaternion Y component and the yaw rate, against the // model's UpdateTurnDegreeDiffrence (deg->rad) and // UpdateTurnVelocityDiffrence. [T1 expressions; zero-deadband // guard falls back to the old quat-w stand-in] { // SCALAR PEER-YAW MIRROR (replaces the quaternion projectedOrigin mirror): // the peer renders yaw ~= angMirrorYaw + angMirrorRate*(now - angMirrorTime) // (re-based by the type-4 writer on every send, mech.cpp case 4). The old // quaternion mirror was ALSO recomputed each frame by the master's own // reckoner from lastUpdate timing it does not control, so it drifted in // slow pi-waves and FLOODED angle resyncs in bursts (measured maxAng~=pi) // -- the walk+turn record churn. Scalars: wrap-safe, self-timed, exact // for the constant-rate spin the peer's exact integrator reproduces. Scalar angDb = (updateTurnAngleDeadband > 0.0f) ? updateTurnAngleDeadband : -1.0f; Scalar velDb = (updateTurnVelocityDeadband > 0.0f) ? updateTurnVelocityDeadband : -1.0f; Logical resync = False; int rzn = 0; // 1=angleDrift 2=velDrift 4=cameToRestAng 8=mirrorInvalid Scalar angDrift = 0.0f; if (angMirrorValid) { YawPitchRoll _yprL; _yprL = localOrigin.angularPosition; const Scalar mirrorYaw = angMirrorYaw + angMirrorRate * (Scalar)(lastPerformance - angMirrorTime); Scalar _dyaw = (Scalar)_yprL.yaw - mirrorYaw; while (_dyaw > 3.14159265f) _dyaw -= 6.28318531f; while (_dyaw < -3.14159265f) _dyaw += 6.28318531f; angDrift = (_dyaw < 0.0f) ? -_dyaw : _dyaw; } else { resync = True; rzn |= 8; // no baseline yet -> establish one } // Compare the live yaw rate against the LAST-SENT rate (updateVelocity, // which the type-4 writer copies straight from localVelocity, mech.cpp:2107) // -- NOT projectedVelocity, which the master's dead-reckoner recomputes in a // different representation/sign so the scalar-.y compare always read the full // 2x mismatch and FLOODED a resync every frame (measured: byVel=55/55, // velDrift=2.618=2x the 1.309 spin rate). The flood reset the peer's // dead-reckon horizon every frame, pinning its slerp at ~half rate -> the // "rotates slow then jumps" spin. updateVelocity is the value the peer // actually extrapolates with, in the same units, so a steady spin now drifts // 0 (no resync) and the peer extrapolates smoothly; a real rate change still // diverges past velDb and fires. // NB: the Abs() macro (STYLE.H:118) is UNPARENTHESIZED -- // Abs(a-b) mis-expands to (a-b>0 ? a-b : -a-b) == -(a+b) on the false // branch, NOT |a-b|. For a steady spin (localVy==updVy) that yields // -(2*rate): on a reverse spin it is +2*rate and FLOODED a resync every // frame (the confirmed root of the half-rate/freeze). Diff into a temp and // take an explicit abs so the macro only ever sees a single token. const Scalar velDiff = (Scalar)localVelocity.angularMotion.y - (Scalar)updateVelocity.angularMotion.y; const Scalar velDrift = (velDiff < 0.0f) ? -velDiff : velDiff; // ANGULAR SIGN hunt (BT_ANGSIGN, 0.2s): instantaneous gate values, to // catch which of local/update/projected angular-Y carries the wrong sign. if (getenv("BT_ANGSIGN")) { static float s_as = 0.0f; s_as += dt; if (s_as >= 0.2f) { s_as = 0.0f; DEBUG_STREAM << "[angsign] localVy=" << (Scalar)localVelocity.angularMotion.y << " updVy=" << (Scalar)updateVelocity.angularMotion.y << " projVy=" << (Scalar)projectedVelocity.angularMotion.y << " velDrift=" << velDrift << " angDrift=" << angDrift << " turn=" << turn << "\n" << std::flush; } } if (angDb > 0.0f) { if (angDrift > angDb) { resync = True; rzn |= 1; } if (velDrift > velDb) { resync = True; rzn |= 2; } if ((Scalar)localVelocity.angularMotion.y == 0.0f && (Scalar)updateVelocity.angularMotion.y != 0.0f) { resync = True; rzn |= 4; } } else if (Abs(angular_deviation.w) < 0.997f) { resync = True; // stand-in when unstreamed } if (resync) { ForceUpdate(1 << MechResyncUpdateModelBit); // type 4 } // SPIN diagnosis (BT_SPIN, once/sec): how DENSE are the orientation // (type-4) records while turning? A stuttery peer spin == too sparse. if (getenv("BT_SPIN")) { static float sAcc = 0.0f; static int sResync = 0, sFrames = 0, sAng = 0, sVel = 0, sRest = 0; static float sMaxAng = 0.0f, sMaxVel = 0.0f; sAcc += dt; sFrames++; if (resync) sResync++; if (rzn & 1) sAng++; if (rzn & 2) sVel++; if (rzn & 4) sRest++; if (angDrift > sMaxAng) sMaxAng = angDrift; if (velDrift > sMaxVel) sMaxVel = velDrift; if (sAcc >= 1.0f) { DEBUG_STREAM << "[spin-tx] resyncs=" << sResync << "/" << sFrames << " byAngle=" << sAng << " byVel=" << sVel << " byRest=" << sRest << " maxAng=" << sMaxAng << "(db" << angDb << ")" << " maxVel=" << sMaxVel << "(db" << velDb << ")" << " liveYaw=" << (Scalar)localVelocity.angularMotion.y << " updYaw=" << (Scalar)updateVelocity.angularMotion.y << "\n" << std::flush; sAcc = 0.0f; sResync = 0; sFrames = 0; sAng = 0; sVel = 0; sRest = 0; sMaxAng = 0.0f; sMaxVel = 0.0f; } } } // Commanded-speed deadband (binary @0x4aac88): the mapper's live // speedDemand vs the last-replicated bodyTargetSpeed -> the tiny // type-2 record. This is the AUTHENTIC replicant-gait feed. if (MappingMapper() != 0 && MappingMapper()->speedDemand != bodyTargetSpeed) { ForceUpdate(1 << MechSpeedUpdateModelBit); // type 2 } // Heat/impact-state change (binary @0x4aab75: heatAlarm level vs // the frame-entry snapshot @0x780) -> the type-7 record. if ((int)heatAlarm.GetLevel() != heatLevelSnapshot) { ForceUpdate(1 << MechImpactUpdateModelBit); // type 7 } heatLevelSnapshot = (int)heatAlarm.GetLevel(); // @0x780 snapshot if (getenv("BT_REPL_LOG")) { static float s_emitLog = 0.0f; s_emitLog += dt; if (s_emitLog >= 1.0f) { s_emitLog = 0.0f; DEBUG_STREAM << "[emit] updateModel=" << (int)updateModel << " errSq=" << error.LengthSquared() << " lastPerf=" << (float)lastPerformance << " lastUpd=" << (float)lastUpdate << " interesting=" << (int)IsInteresting() << "\n" << std::flush; } } } // FIRING ARC -- now an EXPLICIT OPT-IN presentation clamp only (task #36). // AUTHENTIC: the binary has NO aim/arc test -- Emitter::FireWeapon engages // the LOCKED target whenever HasActiveTarget (part_013.c:7758); the skill // is ACQUIRING the lock (crosshair on the enemy -> pick ray -> designate). // The old ±30°-default cone was a stand-in from before the acquisition // existed. Set BT_FIRE_ARC= to re-enable the cone (+ the mech's // real torso reach) as an external-camera presentation clamp; unset = the // authentic no-arc model. bool targetInArc = true; { static Scalar s_baseRad = -2.0f; if (s_baseRad < -1.5f) { const char *av = getenv("BT_FIRE_ARC"); s_baseRad = (av != 0) ? (Scalar)((double)atof(av) * 3.14159265358979 / 180.0) : -1.0f; } if (s_baseRad >= 0.0f && gEnemyMech != 0) { UnitVector zAxisA; localToWorld.GetFromAxis(Z_Axis, &zAxisA); Vector3D fA(-(Scalar)zAxisA.x, -(Scalar)zAxisA.y, -(Scalar)zAxisA.z); Scalar fl = (Scalar)Sqrt(fA.x*fA.x + fA.y*fA.y + fA.z*fA.z); if (fl < 1e-4f) fl = 1.0f; fA.x /= fl; fA.y /= fl; fA.z /= fl; Point3D ep = ((Mech *)gEnemyMech)->localOrigin.linearPosition; Vector3D toE(ep.x - localOrigin.linearPosition.x, ep.y - localOrigin.linearPosition.y, ep.z - localOrigin.linearPosition.z); Scalar tl = (Scalar)Sqrt(toE.x*toE.x + toE.y*toE.y + toE.z*toE.z); if (tl < 1e-4f) tl = 1.0f; Scalar d = (fA.x*toE.x + fA.y*toE.y + fA.z*toE.z) / tl; Scalar half = s_baseRad + GetHorizontalFiringReach(); if (half > 3.14159265f) half = 3.14159265f; targetInArc = (d >= (Scalar)cos((double)half)); } } { // (The old fwd/muzzle-collection + straight-ahead `aim` block that // lived here fed the pre-#33 single-visual beam; the per-weapon // emitter beams below carry their own live muzzle + endpoint, so it // was dead code and was removed with the task-#36 acquisition work.) // resolve the "explode" effect once (also used by the target block). if (gExplodeReady == 0) { gExplodeReady = -1; if (application != 0 && application->GetResourceFile() != 0) { ResourceDescription *exp = application->GetResourceFile()->FindResourceDescription( "explode", (ResourceDescription::ResourceType)1, -1); if (exp != 0) { gExplodeRes = exp->resourceID; gExplodeReady = 1; DEBUG_STREAM << "[fire] explode effect resolved id=" << (long)gExplodeRes << "\n" << std::flush; } else DEBUG_STREAM << "[fire] 'explode' effect not found\n" << std::flush; } } // AUTHENTIC PER-WEAPON BEAMS (task #33): each Emitter/PPC draws ITS OWN // beam from its live sim state. The state machine is the REAL one: // FireWeapon arms beamFlag + dischargeTimer (= the weapon's authored // DischargeTime); ServiceDischarge clears it when the window expires; // the recharge gate is the weapon's own authored RechargeRate. The // muzzle resolves LIVE from the weapon's real mount segment (the beam // origin tracks the gun as the mech moves), the endpoint is the fire's // stored world hit point, and the colour is the weapon's authored // PipColor. Volley-vs-stagger patterns, cadence and colours all now // emerge from each mech's real loadout (the BLH mounts 3 lasers + 2 // PPCs) instead of the old hardcoded single-look stagger, so every // mech type fires like its data says. (Old stagger/keepalive block // removed; see the git history for the bring-up scaffolding.) DrawWeaponBeams(dt); // task #51: extracted (runs for replicants too) } // --- TARGETING: the WORLD-PICK model (task #41, the reconciliation of // ALL the evidence). The boresight ray picks whatever is DOWNRANGE: // 1. the enemy mech's collision volume -> aimed target (hull point // -> STEP-6 zone under the boresight; hotbox + lock ring); // 2. else the TERRAIN (BTGroundRayHit) -> the ground point becomes // the target -- the beam fires at the scenery ("firing at // nothing", seen in the pod demo videos), no damage; // 3. else (sky, nothing within range) -> NO target, and the // weapon's own double gate (FUN_004baa88:7689 + FUN_004bace8: // 7727 -- both require mech+0x388 != 0) refuses the discharge. // Binary evidence for non-mech targets: HudSimulation :5620 // explicitly handles a target WITHOUT damage zones (target->0x120 // == 0) -- dead code if only mechs were ever targeted. The pick is // AUTOMATIC every frame (0x388 has 11 reads / 0 direct stores in // CODE -- written indirectly, never a manual player lock). Entity *hotTarget = 0; // the enemy mech under the boresight Point3D hotPoint; // picked world point on its hull Entity *pickTarget = 0; // what the boresight ray hit (mech/terrain) Point3D pickPoint; // where it hit static int gAimNoRay = 0, gAimGround = 0, gAimHits = 0; // 1Hz diagnostics { extern int BTGetAimRay(float rx, float ry, float outStart[3], float outDir[3]); extern Entity *gBTTerrainEntity; // captured by MakeEntityRenderables extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut); Entity *cand[32]; const int nc = BTGetTargetCandidates((Entity *)this, cand, 32); // Candidate diagnostics + the cross-pod PROOF hook run INDEPENDENT of // the aim ray (they don't need screen geometry -- and in -net mode the // aim projection may not be live, gap-map item 5). static float s_candLog = 0.0f; s_candLog += dt; const int candLog = (getenv("BT_MP_LOG") && s_candLog >= 1.0f); if (candLog) s_candLog = 0.0f; if (candLog) DEBUG_STREAM << "[mp-self] MY mech entityID=" << GetEntityID() << " inst=" << (int)GetInstance() << " (" << nc << " candidates)\n" << std::flush; if (candLog) for (int ci = 0; ci < nc; ++ci) { Mech *m = (Mech *)cand[ci]; if (m == 0) continue; Point3D mp = m->localOrigin.linearPosition; DEBUG_STREAM << "[mp-cand] " << (void*)m << " entityID=" << m->GetEntityID() << " ownerID=" << (int)m->GetOwnerID() << " inst=" << (int)m->GetInstance() << " classID=" << (int)m->GetClassID() << " pos=(" << mp.x << "," << mp.y << "," << mp.z << ")" << " zones=" << m->damageZoneCount << "\n" << std::flush; } // CRIT-PROPAGATION PROBE (BT_CRIT_PROBE=, task #2): every 4s // hammer ONE named zone of the LOCAL mech with a heavy hit until the // zone is destroyed -- drives Zone::TakeDamage -> RecurseSegmentTable // -> SendSubsystemDamage over the now-BOUND critical plugs, without // waiting for combat to randomly destroy a non-vital zone. Diag // only, off by default. { static int s_critZone = -2; static float s_critT = 0.0f; if (s_critZone == -2) { const char *cz = getenv("BT_CRIT_PROBE"); s_critZone = cz ? atoi(cz) : -1; } if (s_critZone >= 0 && s_critZone < damageZoneCount && isPlayerMech && !IsMechDestroyed()) { s_critT += dt; if (s_critT >= 4.0f) { s_critT = 0.0f; Mech__DamageZone *z = Zone(s_critZone); if (z != 0) { Damage dmg; dmg.damageType = Damage::ExplosiveDamageType; dmg.damageAmount = 40.0f; dmg.burstCount = 1; dmg.impactPoint = localOrigin.linearPosition; DEBUG_STREAM << "[crit-probe] hitting zone " << s_critZone << "\n" << std::flush; z->TakeDamage(dmg); } } } } // CROSS-POD DAMAGE TEST HOOK (BT_MP_FORCE_DMG, task #47): once a second, // dispatch an unaimed TakeDamage at the first live REPLICANT through the // SAME virtual Entity::Dispatch path a beam hit uses -- Entity::Dispatch // reroutes a replicant's message to the owning master over the wire, the // master's now-valid mech resolves + applies it (cylinder lookup). Lets a // 2-node test exercise cross-pod delivery without lining up a boresight. // Off by default; the interactive beam path needs no such hook. if (getenv("BT_MP_FORCE_DMG")) { static float s_fd = 0.0f; s_fd += dt; if (s_fd >= 1.0f) { s_fd = 0.0f; for (int ci = 0; ci < nc; ++ci) { Mech *m = (Mech *)cand[ci]; if (m == 0 || m->GetInstance() != ReplicantInstance || m->IsMechDestroyed() || m->damageZoneCount <= 0) continue; Damage dmg; dmg.damageType = Damage::ExplosiveDamageType; dmg.damageAmount = kShotDamage; dmg.burstCount = 1; dmg.impactPoint = m->localOrigin.linearPosition; Entity::TakeDamageMessage td( Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), GetEntityID(), -1, dmg); if (getenv("BT_MP_LOG")) DEBUG_STREAM << "[mp-force] " << (void*)m << " cross-pod TakeDamage id=" << m->GetEntityID() << " -> ownerID=" << (int)m->GetOwnerID() << "\n" << std::flush; m->Dispatch(&td); break; } } } float rs[3], rd[3]; if (!BTGetAimRay(gBTAimX, gBTAimY, rs, rd)) { ++gAimNoRay; } else { Point3D rayStart(rs[0], rs[1], rs[2]); Vector3D rayDir(rd[0], rd[1], rd[2]); // MP TARGETING (task #46): walk EVERY other living mech (the solo // dummy AND every peer replicant, from the live-mech registry) and // pick the CLOSEST one the boresight ray strikes -- generalising // the solo gEnemyMech. float bestDist = 1e30f; for (int ci = 0; ci < nc; ++ci) { Mech *m = (Mech *)cand[ci]; if (m == 0 || m->IsMechDestroyed()) continue; Point3D hp; if (!m->PickRayHit(rayStart, rayDir, 4000.0f, &hp)) continue; float dx = hp.x - rs[0], dy = hp.y - rs[1], dz = hp.z - rs[2]; float d = dx*dx + dy*dy + dz*dz; if (d < bestDist) { bestDist = d; hotTarget = cand[ci]; hotPoint = hp; } } if (hotTarget != 0) { pickTarget = hotTarget; pickPoint = hotPoint; ++gAimHits; } else { // the ground downrange of the guns (max = the pod's HUD // range scale; past it the shot is a sky shot -> no target) extern bool BTGroundRayHit(float,float,float, float,float,float, float, float*,float*,float*); float hx, hy, hz; if (gBTTerrainEntity != 0 && BTGroundRayHit(rs[0], rs[1], rs[2], rd[0], rd[1], rd[2], 1200.0f, &hx, &hy, &hz)) { pickTarget = gBTTerrainEntity; pickPoint.x = hx; pickPoint.y = hy; pickPoint.z = hz; ++gAimGround; } } } // The Reticle struct (the mech's TargetReticle attribute): position, // pick result. targetDamageZone stays -1 -- the zone ROLL happens at // damage delivery (the authentic percent-table roll, STEP 6). targetReticle.reticlePosition.x = gBTAimX; targetReticle.reticlePosition.y = gBTAimY; targetReticle.targetEntity = pickTarget; targetReticle.targetDamageZone = -1; if (pickTarget != 0) targetReticle.rayIntersection = pickPoint; // the engine-Entity target slots the whole weapon path reads if (pickTarget != 0) { MECH_TARGET_ENTITY(this) = pickTarget; MECH_TARGET_SUBIDX(this) = -1; MECH_TARGET_POS(this) = pickPoint; // beam endpoint = the pick } else { MECH_TARGET_ENTITY(this) = 0; // sky: no target, no discharge MECH_TARGET_SUBIDX(this) = -1; } } // HUD feeds: the range caret + the hotbox (world point + state) + the // recovered-Execute instruments (compass, twist tape, group mask). // The range caret tracks the PICK (authentic: :5639 computes it from // mech+0x37c whatever the target is -- a terrain pick reads the ground // distance); the hotbox + lock exist ONLY for a mech target (:5620 -- // a target with no damage zones gets neither). { extern void BTSetHudTargetRange(Scalar range); // RANGE RATE LIMIT (HudSimulation :5652-5670 [T1]): the DISPLAYED // range slides toward the true pick range at 500 m/s -- shown += // clamp(true - shown, +-dt*500) -- so the caret sweeps smoothly as // the boresight crosses near/far ground instead of teleporting. // (Applies to the no-target 1200 default too.) static float sShownRange = 1200.0f; float trueRange = 1200.0f; // no target: the binary default Entity *des = MECH_TARGET_ENTITY(this); if (des != 0 && des != hotTarget) { // terrain pick: range to the ground point; no hotbox, no ring. Point3D tp = MECH_TARGET_POS(this); float hddx = tp.x - localOrigin.linearPosition.x; float hddz = tp.z - localOrigin.linearPosition.z; trueRange = sqrtf(hddx*hddx + hddz*hddz); gBTHudLockState = 0; } else if (des != 0) { Mech *dm = (Mech *)des; Point3D dp = dm->localOrigin.linearPosition; float hddx = dp.x - localOrigin.linearPosition.x; float hddz = dp.z - localOrigin.linearPosition.z; trueRange = sqrtf(hddx*hddx + hddz*hddz); gBTHudLockWorld[0] = dp.x; // the HOTBOX point: top-centre gBTHudLockWorld[1] = dp.y + (float)dm->CylinderReferenceHeight(); gBTHudLockWorld[2] = dp.z; // AUTHENTIC LOCK (HudSimulation, part_013.c:5619-5634 [T1]): // the fire-control LOCK needs (a) a working targeting computer // -- your own HUD's host zone below 75% damage -- and (b) a // live targeted zone -- its damage below 1.0 (a whole-mech // target checks zone 0). So a shot-up cockpit loses the lock // light, and a destroyed wreck's dead zone can't be re-locked. int lock = 1; { MechSubsystem *hud = (MechSubsystem *)GetHudSubsystem(); if (hud != 0 && hud->GetDamageZoneProxy() != 0) { Mech__DamageZone *hz = (Mech__DamageZone *)hud->GetDamageZoneProxy(); if (hz->damageLevel >= 0.75f) // _DAT_004b7ec4 lock = 0; } int tz = MECH_TARGET_SUBIDX(this); if (tz < 0) tz = 0; // whole-mech -> zone 0 if (lock && tz < dm->damageZoneCount && dm->Zone(tz) != 0 && dm->Zone(tz)->damageLevel >= 1.0f) // _DAT_004b7ec8 lock = 0; } // 1 = target held (hotbox draws); 2 = LOCKED (box + spin ring). // The binary keeps these separate: the box follows HotBoxVector, // the ring follows the Lock attr. gBTHudLockState = lock ? 2 : 1; } else { gBTHudLockState = 0; // sky: no target (trueRange stays 1200, } // the binary default @part_013.c:5637) // the 500 m/s slide toward trueRange (see the banner above) { float maxStep = (float)dt * 500.0f; if (maxStep < 0.0f) maxStep = -maxStep; float step = trueRange - sShownRange; if (step > maxStep) step = maxStep; if (step < -maxStep) step = -maxStep; sShownRange += step; BTSetHudTargetRange((Scalar)sShownRange); } gBTHudHeading = gDriveHeading; // CompassHeading (attr 0xD) gBTHudTwist = (float)TorsoHeading(); // RotationOfTorsoHorizontal (attr 4) gBTHudTwistLimit = (float)GetHorizontalFiringReach();// HorizontalTorsoLimit (attrs 5/6) gBTHudGroupMask = (int)targetReticle.reticleElementMask & 0xF; gBTHudPrimary = ((int)targetReticle.reticleElementMask & 0x20) != 0; // task #58: publish the live twist for renderer-side DIAGNOSTICS // (correlating [eyefwd] against the twist). The cockpit eye does // NOT consume this -- it inherits the twist authentically through // jointtorso's HingeRenderable in the draw traversal (see the note // in DPLEyeRenderable::Execute). { extern float gBTEyeTwist; gBTEyeTwist = gBTHudTwist; } } // task #6 ORDER FIX: the scripted block must run BEFORE the fire-push // block below -- it writes gBTLaserKey/gBTConfigKey, which the earlier // keyboard poll zeroes each frame while the window is unfocused; the // old placement AFTER the push block meant the push never saw the // scripted press. // task #6 scripted verify (BT_CONFIG_TEST=1, headless): drives the same // gBTConfigKey/gBTLaserKey states the keyboard would -- // t=[8,11) hold the configure button (session open) // t=[9.5,9.7) one Trigger press edge -> ChooseButton toggle // t>=13 0.2s Trigger pulses -> does the weapon fire now? if ((Entity *)this == application->GetViewpointEntity() && getenv("BT_CONFIG_TEST")) { // frame-count driven (this env throttles background windows to a // few fps -- wall/sim seconds are useless for scripting) static int s_cfgFrame = 0; ++s_cfgFrame; (void)dt; // Schedule LATE (frame 2000+): the sim ticks many frames per second // and the first-run schedule (30-120) fired before the enemy spawn // completed -- targetInArc false -> the fire press never pushed. gBTConfigKey = (s_cfgFrame >= 2000 && s_cfgFrame < 2600) ? 1 : 0; int fireOn = (s_cfgFrame >= 2300 && s_cfgFrame < 2320) ? 1 : 0; // ONE press edge if (s_cfgFrame >= 3000) fireOn = ((s_cfgFrame / 60) & 1); // fire pulses after exit gBTLaserKey = fireOn; } // E8: pulse the three fire channels per frame (1,0,1,0...) so each // weapon sim's CheckFireEdge sees clean rising edges. UNCONDITIONAL -- // NOT inside the enemy block: firing needs only the world PICK (task // #41: the beam goes to the scenery downrange with no enemy alive; the // weapon's OWN HasActiveTarget gate refuses only a true sky shot). // Leaving these inside `if (gEnemyMech)` froze the channels after the // wreck buried -> no trigger edges -> "can't fire after the kill" // (user-reported regression). targetInArc is the explicit BT_FIRE_ARC // presentation clamp (default true). BT_AUTOFIRE holds all three. { // AUTHENTIC FIRE INPUT (task #5): key states become PRESS/RELEASE // edges pushed into the LBE4 buttonGroup channels -- the pod's own // joystick path (L4CTRL Execute: Update(&v, mode) with v = // +(button+1) press / -(button+1) release). The streamed per-mech // controls map binds these buttons DIRECTLY onto each grouped // weapon's TriggerState@0x31C; CheckFireEdge does the rest. This // retires the gBT*Trigger globals + the 1,0 pulse hack. // Desktop map: SPACE/'1' -> Trigger(0x40) [the main fire group], // '2' -> ThumbLow(0x46), '3'/CTRL -> ThumbHigh(0x47) [missiles on // most mechs]. BT_AUTOFIRE pulses the Trigger (edge per 2 frames). // targetInArc stays the opt-in BT_FIRE_ARC presentation clamp. LBE4ControlsManager *cm = (LBE4ControlsManager *)application->GetControlsManager(); ModeMask modeMask = application->GetModeManager()->GetModeMask(); static int s_prevBtn[4] = {0, 0, 0, 0}; static int s_afPhase = 0; s_afPhase ^= 1; // DIAG (BT_AF_PERIOD=): throttle autofire cadence -- pulse the trigger // only briefly every seconds, to test a slower-than-max fire rate (the // coolant/jam mechanic is fire-rate dependent: at max spam the weapon overheats // regardless; at a realistic cadence coolant priority decides the jam). static float s_afClock = 0.0f; s_afClock += dt; static const float s_afPeriod = getenv("BT_AF_PERIOD") ? (float)atof(getenv("BT_AF_PERIOD")) : 0.0f; int afGate = 1; if (s_afPeriod > 0.0f) afGate = (fmodf(s_afClock, s_afPeriod) < 0.6f) ? 1 : 0; const int autofire = (gBTDrive.fireForced && targetInArc && afGate) ? s_afPhase : 0; const int buttons[4] = { LBE4ControlsManager::ButtonJoystickTrigger, // 0x40 LBE4ControlsManager::ButtonJoystickPinky, // 0x45 LBE4ControlsManager::ButtonJoystickThumbLow, // 0x46 LBE4ControlsManager::ButtonJoystickThumbHigh, // 0x47 }; // DEV: BT_AF_MISSILE=1 pulses the missile group (ThumbHigh). INDEPENDENT // of BT_AUTOFIRE (which drives the main gun group) so a headless rig can // exercise MISSILES ONLY -- isolates missile damage/splash from laser+PPC // fire (task #62 TTK verification). static const int s_afMissile = getenv("BT_AF_MISSILE") ? 1 : 0; const int autofireMsl = (s_afMissile && targetInArc) ? s_afPhase : 0; int want[4]; want[0] = ((gBTLaserKey && targetInArc) || autofire) ? 1 : 0; want[1] = (gBTPinkyKey && targetInArc) ? 1 : 0; // key '4' (was unmapped) want[2] = (gBTPPCKey && targetInArc) ? 1 : 0; want[3] = ((gBTMissileKey && targetInArc) || autofireMsl) ? 1 : 0; if (cm != 0) { for (int b = 0; b < 4; ++b) { if (want[b] != s_prevBtn[b]) { s_prevBtn[b] = want[b]; ControlsButton v = (ControlsButton)(want[b] ? (buttons[b] + 1) : -(buttons[b] + 1)); cm->buttonGroup[buttons[b]].ForceUpdate(&v, modeMask); // diag: bypass the prev-diff gate if (getenv("BT_FIRE_LOG")) { static int s_pushN = 0; if ((++s_pushN % 60) == 1) DEBUG_STREAM << "[push] btn=0x" << std::hex << buttons[b] << std::dec << " v=" << (int)v << " mode=0x" << std::hex << (int)modeMask << std::dec << " #" << s_pushN << " thisMech=" << (void *)this << " viewpoint=" << (void *)application->GetViewpointEntity() << std::endl; } } } } } // task #12 scripted verify (BT_GENSEL_TEST=1): one SelectGeneratorD // press at frame ~2500 -- the PPC re-taps from its authored GeneratorA // onto GeneratorD; the charging I^2R then lands on GenD ([heat-t]). if ((Entity *)this == application->GetViewpointEntity() && getenv("BT_GENSEL_TEST")) { static int s_gsFrame = 0; ++s_gsFrame; gBTGenSelKey = (s_gsFrame >= 600 && s_gsFrame < 610) ? 7 : 0; } // task #13 scripted verify (BT_VALVE_TEST=1): one MoveValve press at // frame ~600 -- Condenser1's valveState cycles 1 -> 5, so the flow // redistribution gives it 5/10 of the total coolant flow ([valve] log // + its bank conduction speeds up 5x). if ((Entity *)this == application->GetViewpointEntity() && getenv("BT_VALVE_TEST")) { static int s_vtFrame = 0; ++s_vtFrame; gBTValveKey = (s_vtFrame >= 600 && s_vtFrame < 610) ? 1 : 0; } // task #13 dev harness: the COOLANT VALVE lever. 'C' edge -> dispatch // MoveValve (id 4, the Condenser table @0x50E52C) to the selected // condenser -- the same press payload the pod's engineering-screen aux // buttons deliver. if ((Entity *)this == application->GetViewpointEntity()) { static int s_prevValve = 0; if (gBTValveKey != s_prevValve) { s_prevValve = gBTValveKey; if (gBTValveKey != 0) { Subsystem *condenser = 0; const char *slotEnv = getenv("BT_VALVE_SLOT"); int wantSlot = (slotEnv != 0) ? atoi(slotEnv) : -1; for (int s = 1; s < GetSubsystemCount(); ++s) { Subsystem *sub = GetSubsystem(s); if (sub == 0) continue; if (wantSlot >= 0 ? (s == wantSlot) : ((int)sub->GetClassID() == 0xBBD)) // Condenser { condenser = sub; break; } } if (condenser != 0) { if (getenv("BT_FIRE_LOG")) DEBUG_STREAM << "[valve-tx] MoveValve -> " << condenser->GetName() << std::endl; ReceiverDataMessageOf msg( 4 /*Condenser::MoveValveMessageID*/, sizeof(ReceiverDataMessageOf), (ControlsButton)1 /*press*/); condenser->Dispatch(&msg); } } } } // task #12 dev harness: the POWER-ROUTING buttons. F5..F9 edge -> // dispatch SelectGeneratorA..D / ToggleGeneratorMode (ids 4..8) to the // selected weapon -- the same ReceiverDataMessageOf // press payload the pod's aux-panel EventMappings deliver. The // handler chain is PoweredSubsystem's (weapons inherit via MechWeapon). if ((Entity *)this == application->GetViewpointEntity()) { static int s_prevGenSel = 0; if (gBTGenSelKey != s_prevGenSel) { int pressedID = gBTGenSelKey; // 0 on release s_prevGenSel = gBTGenSelKey; if (pressedID != 0) { Subsystem *weapon = 0; const char *slotEnv = getenv("BT_CONFIG_SLOT"); int wantSlot = (slotEnv != 0) ? atoi(slotEnv) : -1; for (int s = 1; s < GetSubsystemCount(); ++s) { Subsystem *sub = GetSubsystem(s); if (sub == 0) continue; int cid = (int)sub->GetClassID(); if (wantSlot >= 0 ? (s == wantSlot) : (cid == 0xBC8 || cid == 0xBCA || cid == 0xBCE || cid == 0xBD0 || cid == 0xBD4)) { weapon = sub; break; } } if (weapon != 0) { if (getenv("BT_FIRE_LOG")) { extern void BTGenSelProbe(); BTGenSelProbe(); DEBUG_STREAM << "[gensel-tx] id=" << pressedID << " -> " << weapon->GetName() << std::endl; } ReceiverDataMessageOf msg( pressedID /*4..8 (powersub.hpp ids)*/, sizeof(ReceiverDataMessageOf), (ControlsButton)1 /*press*/); weapon->Dispatch(&msg); } } } } // task #6 dev harness: the config-session BRACKET. 'G' edge -> // dispatch ConfigureMappables (id 9) press/release to the selected // weapon -- the exact ReceiverDataMessageOf payload // (+-(element+1)) the pod's streamed aux-panel EventMapping delivers. // Everything downstream is the AUTHENTIC chain: EnterConfiguration // flips the global mode to Mapping(0x8000) (normal fire mappings go // dormant for the hold), the fire keys deliver ChooseButton (id 10) // through the temp 0x8000 event mappings pushed by the SAME live-mode // button path above, and the release restores NonMapping(0x10000). if ((Entity *)this == application->GetViewpointEntity()) { static int s_prevCfg = 0; if (gBTConfigKey != s_prevCfg) { s_prevCfg = gBTConfigKey; Subsystem *weapon = 0; const char *slotEnv = getenv("BT_CONFIG_SLOT"); int wantSlot = (slotEnv != 0) ? atoi(slotEnv) : -1; for (int s = 1; s < GetSubsystemCount(); ++s) // slot 0 = the MAPPER, never a weapon { Subsystem *sub = GetSubsystem(s); if (sub == 0) continue; // Pick by WEAPON classID (0xBC8 Emitter / 0xBCA ProjectileWeapon / // 0xBCE GaussRifle / 0xBD0 MissileLauncher / 0xBD4 PPC). The old // (simulationFlags & 0x8) heuristic matched the MAPPER at slot 0: // its MakeViewpointEntity resource is stack-built with only // name/classID/size set, so its flags are STACK GARBAGE -- and a // config message dispatched to the mapper lands in the mapper's // OWN id space (the aux-preset Fail trap) = the abort popup. int cid = (int)sub->GetClassID(); if (wantSlot >= 0 ? (s == wantSlot) : (cid == 0xBC8 || cid == 0xBCA || cid == 0xBCE || cid == 0xBD0 || cid == 0xBD4)) { weapon = sub; break; } } if (weapon == 0 && getenv("BT_FIRE_LOG")) { DEBUG_STREAM << "[config] NO WEAPON matched (count=" << GetSubsystemCount() << ")" << std::endl; for (int s2 = 0; s2 < GetSubsystemCount() && s2 < 33; ++s2) { Subsystem *p = GetSubsystem(s2); if (p != 0) DEBUG_STREAM << " [cfg-scan] " << s2 << " " << p->GetName() << " flags=0x" << std::hex << (int)p->simulationFlags << std::dec << std::endl; } } if (weapon != 0) { const int kElem = 0x0e; // an aux-panel element (as authored) ReceiverDataMessageOf msg( 9 /*MechWeapon::ConfigureMappablesMessageID (mechweap.hpp)*/, sizeof(ReceiverDataMessageOf), (ControlsButton)(gBTConfigKey ? (kElem + 1) : -(kElem + 1))); weapon->Dispatch(&msg); if (getenv("BT_FIRE_LOG")) DEBUG_STREAM << "[config] " << (gBTConfigKey ? "ENTER" : "EXIT") << " session on " << weapon->GetName() << " mode now=0x" << std::hex << (int)application->GetModeManager()->GetModeMask() << std::dec << std::endl; } } } { // The VICTIM under the boresight (task #46: any peer mech, not just // the solo gEnemyMech). Range/log/fire all key off THIS mech. Entity *victim = hotTarget; Point3D victimPos = (victim != 0) ? ((Mech *)victim)->localOrigin.linearPosition : localOrigin.linearPosition; float ddx = victimPos.x - localOrigin.linearPosition.x; float ddy = victimPos.y - localOrigin.linearPosition.y; float ddz = victimPos.z - localOrigin.linearPosition.z; float range = (float)Sqrt((double)(ddx*ddx + ddy*ddy + ddz*ddz)); // THE AUTHENTIC RANGE GATE (FireWeapon @004bace8 :7758 [T1]): damage // applies when dist <= the weapon's EFFECTIVE range = (1 - host-zone // damage) x its AUTHORED WeaponRange (BLH: lasers 500, missiles 800, // PPCs 900 m -- the [hud] pip dump) -- the per-weapon targetWithinRange // flag UpdateTargeting computes each frame. Any live weapon in reach // lands the aggregate shot. int anyWeaponInRange = 0; for (int wi = 0; wi < GetSubsystemCount(); ++wi) { Subsystem *ws = GetSubsystem(wi); if (ws == 0 || !ws->IsDerivedFrom(MechWeapon::ClassDerivations)) continue; if (*(Logical *)((MechWeapon *)ws)->WithinRangePtr()) { anyWeaponInRange = 1; break; } } gTargetLogAccum += dt; if (gTargetLogAccum >= 1.0f) { gTargetLogAccum = 0.0f; DEBUG_STREAM << "[target] aim=(" << gBTAimX << "," << gBTAimY << ")" << (victim != 0 ? " MECH under boresight (aimed)" : (pickTarget != 0 ? " terrain downrange (beam at scenery)" : " sky (no target, no discharge)")) << " range=" << range << (anyWeaponInRange ? " IN WEAPON RANGE" : "") << " [mechPicks=" << gAimHits << " groundPicks=" << gAimGround << " noRay=" << gAimNoRay << "]" << "\n" << std::flush; gAimHits = 0; gAimNoRay = 0; gAimGround = 0; } // Resolve the "explode" effect resource once. if (gExplodeReady == 0) { gExplodeReady = -1; if (application != 0 && application->GetResourceFile() != 0) { ResourceDescription *exp = application->GetResourceFile()->FindResourceDescription( "explode", (ResourceDescription::ResourceType)1, -1); if (exp != 0) { gExplodeRes = exp->resourceID; gExplodeReady = 1; DEBUG_STREAM << "[fire] explode effect resolved id=" << (long)gExplodeRes << "\n" << std::flush; } else { DEBUG_STREAM << "[fire] 'explode' effect not found\n" << std::flush; } } } // THE PHANTOM-SHOT BLOCK IS RETIRED (task #11, user-reported): this // bring-up stand-in spawned an explosion at the victim on its OWN // 0.3s cadence whenever the fire key was down -- matched the old // one-frame recharge, but once the AUTHENTIC electrical model // landed (task #10: 2-5s recharges + the generator thermal breaker) // it kept painting hits while no weapon was discharging ("enemy // shows fire even though I'm not producing lasers"). The authentic // impact visual flows from each REAL discharge: Emitter::FireWeapon // -> SendDamageMessage -> SubsystemMessageManager explosion // bundling -> SubmitExplosion (messmgr.cpp). This block now keeps // only its presentation roles above (boresight pick -> the target // slots the weapons read, and the [target] log). } } // --- Tick the subsystem roster (BT analog of Entity::PerformAndWatch) ----- // The shipped engine's Entity::PerformAndWatch walks subsystemArray[] and // calls PerformAndWatch on every non-null, non-replicant-disabled subsystem // (MUNGA/ENTITY.cpp ~698-790; RP's VTV ticks ITS roster the identical way). // Our drivable override bypasses the unsafe Mech::Simulate, but the // subsystem roster must still tick each frame so heat/weapons/sensors/gyro/ // power/etc. run their per-frame Performance. We mirror the engine loop // here instead of calling Simulate: each Subsystem is a Simulation whose // base PerformAndWatch (engine, safe) computes its OWN time slice from its // own lastPerformance and dispatches activePerformance -- the reconstructed // *::*Simulation method the subsystem's ctor installed via SetPerformance. // Sentinels: index 0 is always NULL; index 1 is the resolved voltage bus; // the real streamed subsystems live at id>=2. Null + executable guards // make all three cases safe. (Per-subsystem behaviour deepening is a // separate wave; here we only make the TICK PATH live.) int subsystemsTicked = 0; // performed this frame (executable) int subsystemsPresent = 0; // non-null roster occupancy (excl. mapper) if (subsystemArray != 0 && subsystemCount > 0) { for (int i = 0; i < subsystemCount; ++i) { Subsystem *subsystem = subsystemArray[i]; if (subsystem == 0) continue; if (i != 0) // slot 0 = the mapper (task #7) ++subsystemsPresent; if (!subsystem->IsNonReplicantExecutable()) continue; // The controls-mapping subsystem (roster slot 0 via Mech::SetMapping // Subsystem -- the [0x10d] mirror is GONE, task #7) is TICKED under BT_REAL_CONTROLS // -- its InterpretControls chain is now reconciled: FillPilotArray reads // the local player via application->GetMissionPlayer() (the old wild // application+0x6c read was THE bypass-causing AV), and the main tick // @004afd10 reads the owner through declared members (reverseStride // Length/walkStrideLength/forwardThrottleScale, the real Torso analog // axes, the real HUD freeAimSlew). Without the env the historic skip // stands (default behavior unchanged). { static const int s_realControlsTick = BTEnvOn("BT_REAL_CONTROLS", 1); if (!s_realControlsTick && i == 0) // slot 0 = the mapper (task #7) continue; } subsystem->PerformAndWatch(till, update_stream); ++subsystemsTicked; } } // One-shot: report how many subsystems the tick dispatched to on the FIRST // frame, before any DoNothingOnce subsystems latch NeverExecute and opt out. if (!gTickFirstLogged) { gTickFirstLogged = 1; DEBUG_STREAM << "[tick] first frame: dispatched to " << subsystemsTicked << " executable subsystem(s) of " << subsystemsPresent << " present / roster " << subsystemCount << "\n" << std::flush; // DEV: BT_ROSTER=1 dumps the LOADOUT -- every roster subsystem's class // name + id (settles "is this mech supposed to mount weapon X?" from the // shipped subsystem stream, not assumptions). if (getenv("BT_ROSTER")) { for (int ri = 0; ri < subsystemCount; ++ri) { Subsystem *rs = GetSubsystem(ri); if (rs == 0) continue; Derivation *rd = rs->GetDerivation(); // Resolve the authored splash radius for every weapon so we can // confirm the GameModel +0x50 plumbing yields real values without // needing a live missile hit + bystander (task #62 verification). Scalar splash = -1.0f; if (rs->IsDerivedFrom(*MechWeapon::GetClassDerivations())) splash = BTResolveSplashRadius((Entity *)this, ri); DEBUG_STREAM << "[roster] " << ri << " classID=" << (int)rs->GetClassID() << " " << (rd ? rd->className : "?") << " splashRadius=" << splash << "\n" << std::flush; } } } // 1 Hz confirmation that the tick path is live (N subsystems simulated). gTickLogAccum += dt; if (gTickLogAccum >= 1.0f) { gTickLogAccum = 0.0f; DEBUG_STREAM << "[tick] subsystems simulated: " << subsystemsTicked << " (executable) of " << subsystemsPresent << " present / roster " << subsystemCount << "\n" << std::flush; // DEV: BT_SPLASH_TEST=1 -- deterministic splash verification (task #62). // Once per second the LOCAL mech synthesizes a missile detonation AT the // enemy's position (directVictim=0, so the enemy IS a splash candidate) // using its first missile launcher. Exercises the FULL path (radius // resolve -> candidate walk -> distance-scaled burst -> damage delivery) // without depending on the live targeting/autofire path. static const int s_splashTest = getenv("BT_SPLASH_TEST") ? 1 : 0; if (s_splashTest && gEnemyMech != 0 && (Entity *)this != gEnemyMech) { int mslIdx = -1; for (int i = 0; i < subsystemCount; ++i) { Subsystem *s = GetSubsystem(i); if (s != 0 && s->IsDerivedFrom(MissileLauncher::ClassDerivations)) { mslIdx = i; break; } } if (mslIdx >= 0) { // Detonate as a NEAR-MISS 15 units from the enemy (a realistic // splash geometry: the round bursts beside the mech, not at its // exact center), so the burst falloff exercises the typical case. Point3D ep = ((Mech *)gEnemyMech)->localOrigin.linearPosition; Point3D burst = ep; burst.x += 15.0f; Damage sd; sd.damageType = Damage::ExplosiveDamageType; sd.damageAmount = 0.03f; // small test dose sd.burstCount = 1; sd.impactPoint = burst; extern void BTApplySplashDamage(Entity *, int, const Point3D &, Entity *, const Damage &); BTApplySplashDamage((Entity *)this, mslIdx, burst, 0, sd); } } } // // GYRO JOINT-WRITE DISPATCH (task #56, byte-verified @0x4aaf18-0x4aaf89): // the binary Mech master performance tail pushes the gyro's integrated state // into the skeleton every frame AFTER the animation pass + subsystem tick: // gyro->swayBias = mech sway accumulator (the mech+0x3F0 overspeed model is // NOT yet reconstructed -- feed 0, flagged); // if (!deathAnimationLatched) { if (legAnimationState != 0) WriteEyeJoint(); // WriteMechJoint(); } // WriteMechJoint drives 'jointeye' (translation = the eye spring offset + // hit bounce, rotation = body tip); siteeyepoint rides that joint, so this // IS what moves the cockpit eye. Runs after the roster tick above (the gyro // Performance integrated this frame) and after the gait channel wrote the // joints (WriteEyeJoint is multiplicative on the animated rotation). // if (GetInstance() == MasterInstance) { extern void GyroFrameJointWrite(Subsystem *, Scalar, int, int); static int s_gyrodbg = 0; if (getenv("BT_GYRO_LOG") && s_gyrodbg++ == 0) DEBUG_STREAM << "[gyro] dispatch: gyro=" << (void *)gyroSubsystem << " legAnim=" << legAnimationState << " death=" << deathAnimationLatched << "\n" << std::flush; GyroFrameJointWrite(gyroSubsystem, 0.0f /* mech+0x3F0 model TBD */, legAnimationState, deathAnimationLatched); } else if (getenv("BT_GYRO_LOG")) { static int s_gyrodbg2 = 0; if (s_gyrodbg2++ == 0) DEBUG_STREAM << "[gyro] dispatch SKIPPED: instance=" << (int)GetInstance() << " != master\n" << std::flush; } // Keep the simulation/networking bookkeeping consistent (this is exactly // what the base "no time / stasis" early-out does). MASTER ONLY (2026-07-14): // replication is master-authoritative -- a replicant must never serialize. // The port's replicant runs the leg SM for JOINTS (task #50 accommodation), // whose transitions call ForceUpdate() and mark updateModel; serializing those // emitted derived/uninitialized state back into the stream (and the type-3 // writer re-dispatches SetBodyAnimation on the WRITER -- the real-clock crash // rode this path with legAnimationState still 0xCDCDCDCD). Discard replicant // marks instead. if (GetInstance() != ReplicantInstance) WriteSimulationUpdate(update_stream); else updateModel = 0; // drop accommodation-path marks } //########################################################################### //########################################################################### // Mech::AuthenticGroundAndCollide // // THE AUTHENTIC 1995 GROUND MODEL -- the ground/collision half of the MASTER // per-frame performance FUN_004a9b5c (@4aa630-4aab5f, decoded from raw asm by // the ground-model-decode workflow; task #15). Replaces the bring-up // gravity+push-out+floor-clamp baseline when GroundReal() is on. // // The model (no gravity exists anywhere in the mech): // 1. MoveCollisionVolume() -- rebuilds localToWorld from localOrigin, places // the collision cylinder, and (masters) re-finds containedByNode in the // zone's BoundingBoxTree (engine Mover::MoveCollisionVolume, MOVER.cpp:782). // 2. GROUND PROBE: q = origin + (0, collisionTemplate->minY, 0) -- the // ctor-LIFTED template bottom (BLH: 2.0 + 0.37969 = 2.37969 above the feet). // 3. HEIGHT QUERY: FindBoundingBoxUnder(q, &h) -- the box-tree downward query // (BOXTREE.cpp:843); h = distance from q down to the highest solid top // under the column; h == -1.0f = MISS. // 4. THE SNAP (master gate h > 1e-4, const @0x4ab16c): origin.y -= (h - lift) // => origin.y = surfaceY EXACTLY. Absolute placement each frame: walks // up-slope within the lift window (implicit step allowance), drops // instantly on walk-offs. On MISS: NOTHING -- Y holds (no gravity to // accumulate => the old y=13301 runaway is structurally impossible). // 5. COLLISIONS: GetCurrentCollisions -> ProcessCollisionList (which calls // the Mech::ProcessCollision override below per contact). // 6. RESPONSE POLICY on the accumulated damage: // == 0.00123f crushable-CulturalIcon sentinel: the frame's move STANDS // (restore the POST-SNAP saves; the bounce is undone). // > 0 BLOCKING hit (walls/cliffs/buildings): FULL FRAME REJECTION // -- velocity zeroed, origin restored to START-OF-FRAME. // Walls block by rejection, never by slide/climb. // // Deliberate deviations from the binary (each flagged by the verify pass): // - GetCollisionVolumeCount()>0 gate + containedByNode NULL-guard cover the // WHOLE block (the 1995 code was unconditional and would have crashed for // volume-less mechs; GetCurrentCollisions derefs the node unchecked). // - impactVel is saved with the post-snap saves (binary saves it after the // gather @4aa716 -- provably equivalent, the gather doesn't touch it). // - localToWorld is NOT re-synced after a plain snap (binary behavior: it // refreshes on the next frame's MoveCollisionVolume or a response path). // // DEFERRED (bound together; see the ProcessCollision banner): the pre-list // collisionTemporaryState zero (@4aa741) + state tail; the 0x4a4/0x4a8/0x4b4 // caches; the self TakeDamageMessage; SetLegAnimation(0x20) crash anim on // impactVel^2 > 40 (crash-clip slot mapping unverified -- logged instead); // the gyro crunch feed (Gyroscope is a stub). //########################################################################### //########################################################################### void Mech::AuthenticGroundAndCollide(Scalar dt, const Point3D &old_position) { if (GetCollisionVolumeCount() <= 0 || collisionVolume == 0 || collisionTemplate == 0) return; // 1. place the volume + refresh containedByNode (@4aa630) MoveCollisionVolume(); BoundingBoxTreeNode *node = GetMoverCollisionRoot(); if (node == 0) { if (GroundLog()) { static int s_once = 0; if (!s_once++) DEBUG_STREAM << "[ground] NO collision tree node -- " "zone tree unavailable; block skipped\n" << std::flush; } return; } // 2-3. ground probe + height query (@4aa633-4aa67d) Point3D q = localOrigin.linearPosition; q.y += collisionTemplate->minY; // the LIFTED template bottom Scalar h = -1.0f; BoundingBox *floorBox = node->FindBoundingBoxUnder(q, &h); (void)floorBox; // 4. the snap (@4aa685-4aa6cc) if (h > 0.0001f) // master gate, const @0x4ab16c { Scalar d = h - collisionTemplate->minY; // signed: down OR bounded up localOrigin.linearPosition.y -= d; // => origin.y = surfaceY exactly collisionVolume->minY -= d; // keep the placed volume in sync collisionVolume->maxY -= d; } // 5. collisions (@4aa6cf-4aa764) // ASSISTANT GUARD (defensive; matches the authentic invariant): the engine // GetCurrentCollisions iterates collisionAssistant UNCHECKED (MOVER.cpp:894; // Check() compiles out). In the real game exactly ONE master exists per pod // -- the viewpoint mech, which MakeViewpointEntity gave an assistant // (btl4app.cpp:591); every other mech was a replicant (no collision half). // A master WITHOUT an assistant (the BT_SPAWN_ENEMY dummy before its // StartCollisionAssistant) is a configuration the original never had -- // ground it (probe/snap above) but skip the collision half. if (collisionAssistant == 0) return; Vector3D savedWorldVel = worldLinearVelocity; // POST-SNAP saves Point3D savedPos = localOrigin.linearPosition; BoxedSolidCollisionList *cols = GetCurrentCollisions(); Vector3D impactVel = localVelocity.linearMotion; // CONTACT EDGE (player only): a knockdown fires only on a FRESH strike (not blocked // last frame). While the mech stays pressed against the obstacle it just BLOCKS -- // no repeated knockdowns. gWasBlocked is a single global, so gate it to the // viewpoint mech; other masters (the stationary dummy, MP replicant-driven masters) // keep the original per-fresh-block behavior and never touch the shared state. const bool isPlayer = (application != 0 && (Entity *)this == application->GetViewpointEntity()); const int freshBlock = isPlayer ? (gBlockCooldown <= 0.0f ? 1 : 0) : 1; if (isPlayer && gBlockCooldown > 0.0f) gBlockCooldown -= dt; // decay the out-of-contact window // BINARY-TAIL-DEFERRED: collisionTemporaryState = 0 here (@4aa741). // COLLISION-DAMAGE ECONOMY GUARD: snapshot the TRUE frame motion for the // per-contact velocity restore in Mech::ProcessCollision (see mech.hpp) -- // the list walk below reflects worldLinearVelocity at every StaticBounce, // and a multi-solid frame compounds those reflections into a garbage // velocity that priced mech-vs-mech damage 4x-40x too high (mp_a.log:32651). frameEntryWorldVelocity = savedWorldVel; // ram contact-edge decay: once contact with the last victim has been // broken for the linger window, re-arm the one-bump-one-hit gate if (ramContactLinger > 0.0f) { ramContactLinger -= dt; if (ramContactLinger <= 0.0f) ramLastVictim = 0; } Damage dmg; // ctor zeroes damageAmount if (cols != 0) ProcessCollisionList(cols, dt, old_position, &dmg); // 6. response policy (@4aa76c-4aab5f) if (dmg.damageAmount == 0.00123f) // crushable icon: move STANDS { worldLinearVelocity = savedWorldVel; localOrigin.linearPosition = savedPos; MoveCollisionVolume(); dmg.damageAmount = 0.0f; // Binary @4aa7ce-4aa871 (task #56): the gyro CRUNCH. n = Normalize( // dmg.damageForce) (the delta-v across the bounce, filled by the engine // Mover::ProcessCollisionList -- no new plumbing), then a torque along // n @ 0.4 (@4aa81e, 0x3ecccccd) + an upward impulse @ 0.2 (@4aa86c, // 0x3e4ccccd). Normalize is unguarded in the binary too. if (gyroSubsystem != 0) { extern void GyroApplyDamageTorque (Subsystem *, Scalar, Scalar, Scalar, Scalar); extern void GyroApplyDamageImpulse(Subsystem *, Scalar, Scalar, Scalar, Scalar); Vector3D n; n.Normalize(dmg.damageForce); GyroApplyDamageTorque (gyroSubsystem, n.x, n.y, n.z, 0.4f); // @4aa81e GyroApplyDamageImpulse(gyroSubsystem, 0.0f, 1.0f, 0.0f, 0.2f); // @4aa86c } if (GroundLog()) DEBUG_STREAM << "[ground] CRUNCH (crushable icon) at (" << savedPos.x << ", " << savedPos.z << ")" << std::endl; } else if (dmg.damageAmount > 0.0f) // blocking hit: FULL FRAME REJECTION { worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f); localOrigin.linearPosition = old_position; MoveCollisionVolume(); if (isPlayer) gBlockCooldown = kBlockHysteresis; // (re)arm the in-contact window // DEFERRED: self TakeDamageMessage{0x64, zone=-1} (inert today) + // material/normal/approach caches + collisionState indicators. Scalar iv2 = impactVel.x * impactVel.x + impactVel.y * impactVel.y + impactVel.z * impactVel.z; if (iv2 > 40.0f) // crash-anim threshold @0x4ab184 { // CRASH / KNOCKDOWN (binary @4aaae2-4aab0b): bind the bump clip // (animationClips[0x20] = "bmp"; slot verified 0x5cc+0x80 == 0x64c) // on the LEG channel -- the mech plays the fall/stagger instead of // grinding into the wall; LegClipFinished case 32 drops back to // Standing at end-of-clip (slot1 @0x4a6b4d, binary-verified). The // two action-request calls are the binary's FUN_004a4c54(this,1) // then (this,0x20) -- TWO calls, not one OR'd (fidelity verdict). // Guard 1: only when the bmp clip resolved (no substitute clip). // Guard 2 (BRING-UP, marked): don't REBIND while the crash clip is // already playing -- the bmp clip carries ~6.5 u/s of root motion // that presses back into the wall, and without the actionRequestFlags // consumers (bits 1/0x20 -- likely the drive suppressor during the // stagger, NOT yet reconstructed) the re-trigger restarts the clip // every frame. Remove this guard when the flag consumers land. if (animationClips[0x20] != 0 && legStateAlarm.GetLevel() != 0x20 && freshBlock) // only on a FRESH strike, not continuous grinding { SetLegAnimation(0x20); // FUN_004a7fc4(this, 0x20) // SYNC THE DISPLAY CHANNEL (fix the persistent post-bump foot-slip): the // two-channel gait draws the legs from the BODY channel but moves the mech // from the LEG channel. Staggering only the leg channel froze the motion // while the body kept animating -> the channels desynced PERMANENTLY (the // [sync] log: advSum 229 vs legSum 112 = displayed legs run ~2x the real // travel = foot slip that lasts until a full stop resyncs both to stand). // Stagger the BODY channel too so both freeze + recover from the SAME bmp // clip IN PHASE. BodyClipFinished case 32 recovers it (mech2.cpp:308). SetBodyAnimation(0x20); ForceUpdate(1); // FUN_004a4c54(this, 1): pose record ForceUpdate(0x20); // FUN_004a4c54(this, 0x20): the // type-5 KNOCKDOWN record -- the // peer's replicant staggers too if (GroundLog()) { static int s_binds = 0; DEBUG_STREAM << "[knock] BIND #" << ++s_binds << " iv2=" << iv2 << "\n" << std::flush; } } else if (GroundLog()) { static int s_noclip = 0; if (!s_noclip++) DEBUG_STREAM << "[ground] crash-anim trigger but " "bmp clip unresolved -- knockdown skipped\n" << std::flush; } if (GroundLog()) DEBUG_STREAM << "[ground] BLOCK + CRASH (iv2=" << iv2 << " clip=" << animationClips[0x20] << ")" << std::endl; } else if (GroundLog()) DEBUG_STREAM << "[ground] BLOCK dmg=" << dmg.damageAmount << " at (" << old_position.x << ", " << old_position.z << ")" << std::endl; } // 1-Hz telemetry (verification: h / y / hit-miss); INSIDE-SOLID detector: // h clamps to EXACTLY 0 when the probe point is at/inside a solid surface // (every FindDistanceBelowBounded is Max(...,0)-clamped in BT content) -- // a sustained h==0 streak means the mech is buried in a solid. if (GroundLog()) { static float s_accum = 0.0f; static int s_zeroStreak = 0; if (h == 0.0f) { if (++s_zeroStreak == 10) DEBUG_STREAM << "[ground] INSIDE-SOLID streak at (" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.y << ", " << localOrigin.linearPosition.z << ")" << std::endl; } else s_zeroStreak = 0; s_accum += (float)dt; if (s_accum >= 1.0f) { s_accum = 0.0f; DEBUG_STREAM << "[ground] h=" << h << (h == -1.0f ? " (MISS)" : (h == 0.0f ? " (ZERO/inside)" : " (hit)")) << " lift=" << collisionTemplate->minY << " pos=(" << localOrigin.linearPosition.x << ", " << localOrigin.linearPosition.y << ", " << localOrigin.linearPosition.z << ")" << std::endl; } } } //########################################################################### //########################################################################### // Mech::ProcessCollision (vtable slot +0x3c) // // @004abb40 -- THE REAL per-contact collision responder (task #15, // ground-model-decode workflow; raw part_012.c:15206-15416). The earlier // draft here ("ResolveWeaponImpact") misread this function as a weapon sweep; // it is the override of the ENGINE protected virtual Mover::ProcessCollision // (MOVER.h:359-365), called per contact by Mover::ProcessCollisionList. // // Semantics (all raw-verified): // - BoxedSolid resolver (collisionVolume->ProcessCollision, BOXDISKS.cpp) -- // on a miss NOTHING runs (the binary's entire body is inside the hit branch). // - StaticBounce computes the positional resolution + collision damage. // - Owner classification: a Mover owner -> separating-contact gate + (deferred) // mech-vs-mech damage dispatch; a CulturalIcon owner -> separating gate + // (deferred) crunch dispatch + the 0.00123f WALK-THROUGH SENTINEL when the // icon has no StoppingCollisionVolume flag (crushable trees/props). // - Plain UnscalableTerrain owners (ground/hills/cliff tiers/canyon walls) // match NEITHER branch: their StaticBounce damage stands and the caller // (AuthenticGroundAndCollide) rejects the whole frame -- walls/cliffs BLOCK. // // DEFERRED (marked, to add together in one fidelity pass): // - the TakeDamageMessage dispatches (self/other-mech/icon-crunch): zone==-1 // is dropped by the engine base handler today (the Mech cylinder-lookup // override is unreconstructed) AND the binary message carries an inflictor // GLOBAL (DAT_0050b9ac) + inline name fields not yet mapped -- do NOT fake. // - the collisionTemporaryState tail (:15406-15413) -- BINARY-TAIL-DEFERRED, // bound with the pre-list zero @4aa741 in AuthenticGroundAndCollide. // - the 0x4a4/0x4a8/0x4b4 caches (material / local-frame surface normal / // approach speed) -- HUD/telemetry feeds, deferred with the indicators. // // Gated: falls through to the engine base when GroundReal() is off, so the // baseline BT_COLLISION push-out path is byte-identical. //########################################################################### //########################################################################### // // Build a zone==-1 (unaimed) collision TakeDamageMessage from a resolved contact // and dispatch it to 'victim'. The victim turns the WORLD impact point into a // damage zone -- a Mech via its cylinder table (STEP 6), an icon via its base // handler (crushable props have no zones -> the base handler no-ops). Faithful // to the binary's mech-vs-mech / icon-crunch dispatches (:15324-15401): those // built a raw DamageMessage{id=0x64, inflictor=DAT_0050b9ac, zone=-1}; we use the // engine Entity::TakeDamageMessage ctor (as the weapon path does) with this mech // as the inflictor. The impact point is the world centre of the overlap slice. // static void BTDispatchCollisionDamage( Mech *inflictor, Entity *victim, const Damage *resolved, BoxedSolidCollision &collision) { Damage dmg; dmg.damageType = Damage::CollisionDamageType; // RAM ECONOMY NORMALIZATION [T3, evidence-forced 2026-07-12]: StaticBounce // prices the kinetic loss with the AUTHORED moverMass (~1.3e6 units) -- // ~59,000 "points" for a 10 m/s ram, against zones whose armor absorbs // 1 point per point (scale = 1/armorPoints, armor 50-140: the [zone-armor] // dump). The BINARY dispatches this raw number [T1 @part_012:15324] -- // but on the pod network it only ever hit the local REPLICANT of the // victim, where it evaporated (the master never heard it; MECH.CPP:986 // merely warns). Our MP forwards replicant damage (task #47, required // for weapons), which surfaced the dormant 59K as a one-shot ram-kill. // Normalize by 1e-3 (the mass-unit/armor-point scale): a walking bump = // a few points, a full charge = tens -- the armor-table economy. dmg.damageAmount = resolved->damageAmount * 0.001f; dmg.surfaceNormal = resolved->surfaceNormal; dmg.impactPoint = Point3D( (collision.collisionSlice.minX + collision.collisionSlice.maxX) * 0.5f, (collision.collisionSlice.minY + collision.collisionSlice.maxY) * 0.5f, (collision.collisionSlice.minZ + collision.collisionSlice.maxZ) * 0.5f); dmg.burstCount = 1; Entity::TakeDamageMessage take_damage( Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), inflictor->GetEntityID(), -1 /*unaimed -> receiver's cylinder resolves*/, dmg); victim->Dispatch(&take_damage); if (GroundLog()) DEBUG_STREAM << "[collide] dmg=" << resolved->damageAmount << " -> victim class=" << (int)victim->GetClassID() << " (zone==-1, resolved by receiver)\n" << std::flush; // RAM ECONOMY TELEMETRY (the MP ram one-shot investigation): every // dispatched ram, with the exact velocity StaticBounce priced it from -- // the decisive evidence channel for any future out-of-economy amount. if (getenv("BT_MP_NET")) { const Vector3D &v = inflictor->GetWorldLinearVelocity(); DEBUG_STREAM << "[collide-tx] " << inflictor->GetEntityID() << " rams " << victim->GetEntityID() << " dmg=" << resolved->damageAmount << " mass=" << inflictor->moverMass << " e=" << inflictor->elasticityCoefficient << " |v|=" << (Scalar)sqrtf(v.x*v.x + v.y*v.y + v.z*v.z) << " at(" << dmg.impactPoint.x << "," << dmg.impactPoint.y << "," << dmg.impactPoint.z << ")" << std::endl; } } void Mech::ProcessCollision( Scalar time_slice, BoxedSolidCollision &collision, const Point3D &old_position, Damage *damage) { if (!GroundReal()) { Mover::ProcessCollision(time_slice, collision, old_position, damage); return; } // COLLISION-DAMAGE ECONOMY GUARD (see mech.hpp frameEntryWorldVelocity): // this runs once PER CONTACT in the frame's ProcessCollisionList walk, and // the StaticBounce below reflects worldLinearVelocity (+= delta_v) every // time. Restore the TRUE frame-entry motion first so a multi-solid frame // (mech + terrain solids + debris) cannot compound the reflections into an // amplified velocity: every contact -- and every dispatched mech-vs-mech // damage amount -- is priced at the mech's real approach speed, which is // all the 1995 binary's StaticBounce ever saw (its ground was a heightfield // probe, never a collision-list entry). The post-list velocity is // overwritten by the response policy / next frame's derive regardless. worldLinearVelocity = frameEntryWorldVelocity; // --- the BoxedSolid resolver (:15302-15304); miss => nothing runs ------- Scalar penetration = 0.0f; if (!collisionVolume->ProcessCollision(collision, worldLinearVelocity, lastCollisionList, &damage->surfaceNormal, &penetration)) { return; } if (penetration > time_slice) // Max_Clamp (:15306-15308) penetration = time_slice; Scalar r = penetration / time_slice; Scalar elasticity = elasticityCoefficient; // :15310 (engine Mover member) Scalar friction = frictionCoefficient; // :15311 Simulation *ownerSim = collision.GetTreeVolume()->GetOwningSimulation(); // tagPointer (:15312) damage->damageAmount = StaticBounce(old_position, time_slice, r, damage->surfaceNormal, &elasticity, minimumBounceSpeed, &friction); // :15313-15315 Entity *owner = (Entity *)ownerSim; // CONTACT TELEMETRY (task #15 verification): identify every resolved contact. if (GroundLog()) { BoxedSolid *ws = collision.GetTreeVolume(); DEBUG_STREAM << "[contact] solidType=" << (int)ws->solidType << " mat=" << (int)ws->materialType << " ownerClass=" << (owner ? (int)owner->GetClassID() : -1) << " slice X[" << collision.collisionSlice.minX << "," << collision.collisionSlice.maxX << "] Y[" << collision.collisionSlice.minY << "," << collision.collisionSlice.maxY << "] Z[" << collision.collisionSlice.minZ << "," << collision.collisionSlice.maxZ << "] pen=" << penetration << " dmg=" << damage->damageAmount << "\n" << std::flush; } // --- Mover owner (another mech / vehicle) (:15316-15358) ---------------- if (owner != 0 && owner->IsDerivedFrom(*Mover::GetClassDerivations())) { Vector3D rel; Vector3D ownerVel = owner->GetWorldLinearVelocity(); // virtual (+0x20) rel.Subtract(worldLinearVelocity, ownerVel); // Separating-contact gate. MEMBER dot (Normal::operator*, NORMAL.h:29) // -- NEVER the free Dot() recon stub (a variadic no-op returning 0 // that would silently defeat this gate). if (damage->surfaceNormal * rel < -1.0e-4f) // _DAT_004ac044 (:15320-15323) return; // Mech-vs-mech (:15324-15358): the binary gates on owner ClassID == Mech // (0xbb9) then dispatches the collision DamageMessage (zone==-1) to the // other mech. STEP 6 (its cylinder table) now resolves the zone, so this // is UNBLOCKED (was deferred: zone==-1 used to be dropped). if (owner->IsDerivedFrom(*Mech::GetClassDerivations())) { // RAM CONTACT EDGE (see mech.hpp): dispatch damage only on a FRESH // contact with this victim; while pressed, refresh the linger so // sustained contact (held stick / respawn overlap) stays a BLOCK, // not a 60Hz damage grinder -- the binary's bounce-separation made // this edge implicit; our gait-derived velocity needs it explicit. if (owner == ramLastVictim && ramContactLinger > 0.0f) { ramContactLinger = 0.35f; // still pressed: refresh, no damage } else { ramLastVictim = owner; ramContactLinger = 0.35f; BTDispatchCollisionDamage(this, owner, damage, collision); } } } // --- CulturalIcon owner (buildings/trees/props) (:15361-15404) ---------- if (owner != 0 && owner->IsDerivedFrom(*CulturalIcon::GetClassDerivations())) { Logical stopping = ((CulturalIcon *)owner)->IsStoppingCollisionVolume(); // flags & 0x8000 (:15362) // Separating gate vs a STATIC icon (owner velocity == {0,0,0}, inlined // by the 1995 compiler) (:15364-15368). if (damage->surfaceNormal * worldLinearVelocity < -1.0e-4f) return; // Crunch (:15369-15401): dispatch the collision damage to the icon // (zone==-1) BEFORE the walk-through sentinel overwrites the amount. // Buildings with damage zones resolve via their handler; crushable props // have none -> the base handler no-ops. UNBLOCKED by STEP 6. BTDispatchCollisionDamage(this, owner, damage, collision); if (!stopping) damage->damageAmount = 0.00123f; // walk-through sentinel 0x3aa137f4 (:15402-15404) } // BINARY-TAIL-DEFERRED: collisionTemporaryState tail (:15406-15413) -- // implement together with the per-frame zero (@4aa741) and the named // StateIndicator members. } //########################################################################### //########################################################################### // FeedHeatCapacityGauge / FeedHeatLevelGauge (cockpit) // // @004ac04c / @004ac064 // // Two-line gauge callbacks: push the current heat capacity / heat level into // the value field (+0xc) of the object pointed to by this+0x2ec. Registered // as a per-frame gauge value source. // // NOTE: this+0x2ec is read by Simulate as a terrain "groundRef" (its +8 is a // base height), so labelling it a GraphicGauge here is uncertain -- the two // uses share a byte offset. The heatLevel/heatCapacity *sources* (0x518/0x51c) // are certain Mech members; the *sink* identity is best-effort. //########################################################################### //########################################################################### void Mech::FeedHeatCapacityGauge() { *(Scalar *)(*(int *)(this + 0x2ec) + 0xc) = heatCapacity; // 0x51c -> +0xc } void Mech::FeedHeatLevelGauge() { *(Scalar *)(*(int *)(this + 0x2ec) + 0xc) = heatLevel; // 0x518 -> +0xc } //########################################################################### //########################################################################### // LookupDamageState (static; keyword parse) // // @004ac194 // // Map a damage-zone state keyword to its enum value, using the (name,value) // table at .rdata:0050de74. Returns 1 and writes *out on a match, else 0. // Recognized keywords (from the table): // Destroyed=0 Damaged=1 CoolantLeaking=2 Overheating=3 // AmmoBurning=4 Jammed=5 BadPower=6 //########################################################################### //########################################################################### // // Damage-state keyword table @0x50de74 (shared with mechsub's status states). // struct DamageStateEntry { const char *name; int value; }; static const DamageStateEntry kDamageStateTable[] = { { "Destroyed", 0 }, { "Damaged", 1 }, { "CoolantLeaking", 2 }, { "Overheating", 3 }, { "AmmoBurning", 4 }, { "Jammed", 5 }, { "BadPower", 6 }, { 0, 0 } }; /*static*/ Logical Mech::LookupDamageState(const char *keyword, int *out) { for (const DamageStateEntry *e = kDamageStateTable; // &PTR_s_Destroyed_0050de74 e->name != 0; ++e) { if (Strcmp(keyword, e->name) == 0) // FUN_004d4b58 { *out = e->value; return True; } } return False; } //===========================================================================// // End of recovered mech4.cpp slice. //===========================================================================// // // DrawWeaponBeams (task #51 extraction) -- the per-weapon beam render walk // (task #33), extracted from the player-only drive block so it runs for // EVERY mech: the local player, the solo dummy, and MP REPLICANTS (whose // emitters now carry live replicated discharge state via the Emitter update // records; without this the peer's beams applied but never drew). // void Mech::DrawWeaponBeams(Scalar dt) { extern void BTPushBeam(float,float,float, float,float,float, unsigned, float, float); const Scalar ttl1 = (dt > 1e-4f) ? dt : 1e-4f; // one-frame life (redrawn while on) static int s_beamStateLog = 0; int energyOrdinal = -1; // Nth energy weapon (port assignment) for (int wi = 0; wi < GetSubsystemCount(); ++wi) { Subsystem *ws = GetSubsystem(wi); if (ws == 0) continue; // EXACT class filter: Emitter (0xBC8=3016) or PPC (0xBD4=3028). // The derivation check matched too broadly here (the recon // derivation chains are shared stubs for some subsystems -- // a Sensor and the MissileLauncher passed and drew garbage // from misinterpreted offsets). const int wcid = (int)ws->GetClassID(); if (wcid != 3016 && wcid != 3028) continue; ++energyOrdinal; Emitter *em = (Emitter *)ws; // replicant beams: age the replicated discharge locally (a lost // beam-END record otherwise pins the beam on forever -- see // Emitter::ReplicantServiceBeam) if (GetInstance() == Entity::ReplicantInstance) em->ReplicantServiceBeam(ttl1); if (!em->BeamOn()) continue; Point3D mz; em->MuzzlePoint(mz); // LIVE muzzle (tracks the gun) // MOUNT FALLBACK: when the weapon's mount segment doesn't // resolve, GetMuzzlePoint returns the mech ORIGIN (feet). // Assign this weapon its own gun-port segment by roster // ordinal (the same port set the old visual used) so each // energy weapon keeps a stable muzzle on the arms. if (mz.y - localOrigin.linearPosition.y < 1.0f) { static const char *const kGunPorts[] = { "siterugunport", "sitelugunport", "siterdgunport", "siteldgunport", "siterbgunport", "sitelbgunport" }; // PER-MECH cache (task #51): the walk now runs for EVERY mech // (player + replicants); the old process-wide statics would serve // the PLAYER's segment pointers as the replicant's muzzles. Key // the slot by owner and re-resolve on mismatch. static EntitySegment *s_portCache[64]; static int s_portTried[64]; static Mech *s_portOwner[64]; if (energyOrdinal >= 0 && energyOrdinal < 64) { if (!s_portTried[energyOrdinal] || s_portOwner[energyOrdinal] != this) { s_portTried[energyOrdinal] = 1; s_portOwner[energyOrdinal] = this; s_portCache[energyOrdinal] = GetSegment( CString(kGunPorts[energyOrdinal % 6])); } if (s_portCache[energyOrdinal] != 0) { AffineMatrix mw; mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(), localToWorld); mz = mw; // Point3D = matrix translation } } } const Point3D &bend = em->BeamEndpoint(); // the fire's world hit point // authored per-weapon colour; unset (-1) -> the ER-laser red RGBColor pc = em->PipColor(); float r = (float)pc.Red, g = (float)pc.Green, b = (float)pc.Blue; if (r < 0.0f || g < 0.0f || b < 0.0f) { r = 0.78f; g = 0.08f; b = 0.02f; } // PPC (classID 0xBD4 = 3028): a thicker, brighter bolt than a laser tube. const int isPPC = ((int)em->GetClassID() == 3028); // ONE draw per beam, at the model's NATURAL width: the weapon's // own tube (ERMLASER radius 0.22u, PPC bolt 0.62u) IS the beam // -- the old inflated two-layer glow/core (3.0x + 0.9x widths) // drew fat cartoon cylinders 13x the authored size. The tint // modulates the scrolling grit; thin natural tubes stay under // saturation without a hand-dimmed core. extern void BTPushBeamKind(float,float,float, float,float,float, unsigned, float, float, int); unsigned tint = (((unsigned)(40.0f + r * 215.0f) & 0xFF) << 16) | (((unsigned)(40.0f + g * 215.0f) & 0xFF) << 8) | ((unsigned)(40.0f + b * 215.0f) & 0xFF); // PHANTOM-BEAM FORENSICS (BT_FIRE_LOG): identify every drawn beam -- // owner/instance, roster slot, class, discharge state, colour, ends. if (getenv("BT_FIRE_LOG")) { static int s_bd = 0; if ((++s_bd % 30) == 1) // ~2Hz per sustained beam DEBUG_STREAM << "[beam-draw] mech=" << GetEntityID() << " inst=" << (int)GetInstance() << " slot=" << wi << " cid=" << wcid << " timer=" << em->DischargeTimer() << " pip=(" << r << "," << g << "," << b << ")" << " mz=(" << mz.x << "," << mz.y << "," << mz.z << ")" << " end=(" << bend.x << "," << bend.y << "," << bend.z << ")" << std::endl; } BTPushBeamKind(mz.x, mz.y, mz.z, bend.x, bend.y, bend.z, tint, ttl1, 1.0f /* natural model width */, isPPC ? 1 : 0 /* ppc.bgf : ermlaser.bgf */); if (getenv("BT_BEAM_LOG") && (s_beamStateLog++ % 31) == 0) // 31: coprime with the 5-beam volley (a %30 sampler aliased to one weapon) DEBUG_STREAM << "[beam] " << (isPPC ? "PPC" : "laser") << " #" << wi << " mz=(" << mz.x << "," << mz.y << "," << mz.z << ") end=(" << bend.x << "," << bend.y << "," << bend.z << ") rgb=(" << r << "," << g << "," << b << ")" << std::endl; } }