MP: FIX peer spin hang/divergence -- exact quaternion integration + frequent orientation (task #50)

Answers 'how did the original handle this?' from the decomp (subagent hunt):
the 1995 binary's replicant reckoner (FUN_004ab1c8 -> FUN_00409f58, part_000.c:9359)
integrates heading EXACTLY: build a unit axis-angle rotation quaternion from
angularVelocity*dt ({axis*sin(t/2), cos(t/2)}) and Hamilton-multiply it onto the
heading (FUN_00409d9c) -- exact for any timestep, stays on the unit sphere.  It
further carries the full orientation quaternion in the FREQUENT pose record
(FUN_0040a938, 7-float pose), so the dead-reckon gap stays tiny.

Our reconstruction diverged two ways, both fixed:
 1. ReconQuatIntegrate (mechrecon.hpp) -- the reconstruction of FUN_00409f58 -- was
    STUBBED as , a crude small-angle VECTOR add.  Restored to
    the real exact axis-angle composition.  (A 'no stand-ins' violation: the comment
    even wrongly claimed Quaternion::Add == FUN_00409f58.)
 2. The engine Mover reckoner (MOVER.cpp AcceleratedDeadReckoner/LinearDeadReckoner)
    also did the vector Add on projectedOrigin.angularPosition -> over a long peer
    record gap it diverged to ~180deg then snapped (the reported spin HANG/hesitation).
    Routed both through a new ExactAngularProject() helper (same exact math).
 3. Orientation only rode the sparse type-4 resync; during a PURE spin the linear
    dense-send never fires (not translating), so the gap ballooned (~1.6s) and the now-
    exact projection sat far ahead -> the slerp jumped.  Added an ANGULAR dense-send
    (resync every frame while |yawRate|>0.1), mirroring the original's frequent-
    orientation model -> gap stays tiny -> smooth.

Verified live-autonomous (BT_AUTODRIVE+BT_FORCE_TURN + BT_RENDHDG render-rate probe):
the ~180deg divergence + multi-radian snaps are GONE (rendered maxStep 0.05-0.10 rad,
no jumps).  User confirms: no frame hang/hesitation.  MOVER.cpp change is strictly
more correct (exact==crude for the small per-frame master case; only large-gap peer
extrapolation changes), so walking is unaffected.

KNOWN REMAINING (separate, smaller): the turn-STEP leg animation (trn clip, mech2.cpp
advance_normally) runs at a FIXED idleStrideScale cadence that does not scale with the
rotation rate, so the legs lag/skip vs the (now-correct) body rotation.  Next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-14 17:05:16 -05:00
co-authored by Claude Opus 4.8
parent 9eff043973
commit a8eb8a427f
3 changed files with 69 additions and 12 deletions
+32 -8
View File
@@ -11,6 +11,34 @@
#include "line.h"
#include "app.h"
#include "notation.h"
#include <math.h>
//
// EXACT axis-angle rotation composition -- matches the 1995 BT binary's angular
// integrator (FUN_00409f58): build a unit rotation quaternion from the rotation
// VECTOR `rotVec` (angle = |rotVec|, axis = rotVec/angle) as { axis*sin(angle/2),
// cos(angle/2) } and Hamilton-multiply it onto `base`. The dead-reckoner previously
// did `out.Add(base, rotVec)` -- adding a scaled angular-velocity vector to the heading
// quaternion. That is only a small-angle approximation: fine per-frame (tiny angle),
// but over a long replicant dead-reckon gap it DIVERGES (the heading drifts to ~180deg
// then snaps -- the spinning-peer hesitation). This composition is exact for any angle
// and stays on the unit sphere.
//
static void ExactAngularProject(Quaternion &out, const Quaternion &base, const Vector3D &rotVec)
{
const Scalar ang = rotVec.Length();
if (ang > 1.0e-6f)
{
const Scalar h = 0.5f * (Scalar)fmodf((float)ang, 6.2831853f); // half of angle mod 2pi
const Scalar s = (Scalar)(sinf((float)h) / ang); // sin(angle/2)/angle
const Quaternion dq(rotVec.x * s, rotVec.y * s, rotVec.z * s, (Scalar)cosf((float)h));
out.Multiply(base, dq); // base (X) dq
}
else
{
out = base;
}
}
//#############################################################################
//############################### Mover #################################
@@ -395,10 +423,8 @@ Logical
//-------------------------------
//
position_delta.Multiply(updateVelocity.angularMotion, time_slice);
projectedOrigin.angularPosition.Add(
updateOrigin.angularPosition,
position_delta
);
ExactAngularProject(projectedOrigin.angularPosition, // was .Add (diverging vector-add)
updateOrigin.angularPosition, position_delta);
projectedVelocity = updateVelocity;
Check_Fpu();
@@ -460,10 +486,8 @@ Logical
updateVelocity.angularMotion,
time_slice
);
projectedOrigin.angularPosition.Add(
updateOrigin.angularPosition,
position_delta
);
ExactAngularProject(projectedOrigin.angularPosition, // was .Add (diverging vector-add)
updateOrigin.angularPosition, position_delta);
//
//-----------------------------------
+12 -1
View File
@@ -1930,7 +1930,7 @@ void
{
if (dt > 0.0001f && dt < 0.5f)
{
DeadReckon(dt); // engine: reckoner + lerp
DeadReckon(dt); // engine: reckoner (exact angular) + lerp
localToWorld = localOrigin;
// RENDER-vs-SIM decoupling probe: publish the heading the RENDER will
@@ -3672,6 +3672,17 @@ void
{
resync = True; // stand-in when unstreamed
}
// ANGULAR dense-send: the original refreshed orientation in its FREQUENT
// pose record so the peer's dead-reckon gap stayed tiny (decomp: 7-float
// pose carries the quaternion, refreshed every broadcast). Our orientation
// rides only the type-4 resync, and during a PURE spin the linear dense-send
// never fires (not translating), so the gap balloons (~1.6s) and even the
// exact integrator's projection is far ahead -> the slerp jumps. Send the
// resync every frame while rotating to keep updateOrigin.angular fresh
// (small gap -> smooth), mirroring the original's frequent-orientation model.
static const int s_denseRot = getenv("BT_NO_DENSE_TX") ? 0 : 1;
if (s_denseRot && Abs(localVelocity.angularMotion.y) > 0.1f)
resync = True;
if (resync)
{
ForceUpdate(1 << MechResyncUpdateModelBit); // type 4
+25 -3
View File
@@ -435,11 +435,33 @@ namespace Vector
//
// P3 GAIT CUTOVER: backed by the real engine Quaternion (were no-op stubs).
// ReconQuatIdentity(q, &id) -> q = Quaternion::Identity
// ReconQuatIntegrate(out,in,d) -> out = in integrated by angular delta d
// (engine Quaternion::Add(source, Vector3D) == FUN_00409f58)
// ReconQuatIntegrate(out,in,d) -> out = in composed with an EXACT rotation by
// angular delta d (= the real FUN_00409f58).
// All callers pass (Quaternion*,Quaternion*) / (Quaternion*,Quaternion*,Vector3D*).
inline void ReconQuatIdentity(Quaternion *q, const Quaternion * /*id*/) { *q = Quaternion::Identity; }
inline void ReconQuatIntegrate(Quaternion *out, const Quaternion *in, const Vector3D *delta) { out->Add(*in, *delta); }
// EXACT axis-angle quaternion integration -- the real FUN_00409f58 (part_000.c:9359),
// verified from the decomp. A prior stand-in wrongly used `out->Add(*in,*delta)` (a
// small-angle VECTOR add): correct every-frame on the master (tiny angle) but it
// DIVERGES over a long dead-reckon gap -- the spinning-peer heading drifting to ~180deg
// then snapping. The binary builds the unit rotation quaternion from the rotation vector
// `delta` (angle=|delta|, axis=delta/angle) as { axis*sin(angle/2), cos(angle/2) } and
// Hamilton-multiplies it onto `in` (FUN_00409d9c: out = in (X) deltaQuat).
inline void ReconQuatIntegrate(Quaternion *out, const Quaternion *in, const Vector3D *delta)
{
const Scalar ang = delta->Length();
if (ang > 1.0e-6f)
{
const Scalar half = (Scalar)(fmodf((float)ang, 6.2831853f) * 0.5f); // reduce2pi, then halve
const Scalar s = (Scalar)(sinf((float)half) / ang); // sin(angle/2)/angle
const Quaternion dq((Scalar)(delta->x * s), (Scalar)(delta->y * s),
(Scalar)(delta->z * s), (Scalar)cosf((float)half));
out->Multiply(*in, dq); // in (X) dq
}
else
{
*out = *in;
}
}
template<class...A> inline Recon ReconQuatSlerp(A&&...) { return Recon(); }
template<class...A> inline Recon ComputeImpactDamage(A&&...) { return Recon(); }