MP: FIX walk+turn peer skip -- incremental heading integration + scalar yaw mirror (task #50)

The user-keyboard regime (steering WHILE walking) fired BOTH dense record streams
at once and exposed the last divergence: our peer heading used the engine
Mover::DeadReckon slerp-toward-projection, whose angular projection reads the
SHARED lastUpdate/nextUpdate timebase.  The dense type-0 pose stream resets that
timebase every frame while walking while RESTORING a stale orientation (the
authentic case-0 strip, verified against FUN_004a1232 case 0) -- so the angular
target barely advances from a stale base and the slerp DRAGS the heading back
every frame.  Measured: peer yaw advancing at ~40% rate with half the frames
stepping BACKWARD.  Pure-spin and pure-walk tests never showed it (single
stream) -- why autonomous looked smooth while keyboard play skipped.

AUTHENTIC FIX (decomp FUN_004ab1c8 -> FUN_004ab188/FUN_00409f58): the original
replicant integrates its heading INCREMENTALLY from the CURRENT pose -- exact
rotation of (replicated yaw rate * dt) composed on each frame -- and re-anchors
on type-4 receipt.  It never slerps toward a projected angular target.
 - mech4.cpp peer branch: save heading, let DeadReckon own LINEAR only, then
   integrate heading incrementally (ReconQuatIntegrate); on angSyncLatch (new
   type-4) re-anchor to updateOrigin.
 - mech.hpp/mech.cpp: angSyncLatch member (angular analog of poseSyncLatch),
   armed by ReadUpdateRecord case 4.
 - SCALAR peer-yaw mirror (angMirrorYaw/Rate/Time, re-based in the type-4
   writer): replaces the quaternion projectedOrigin mirror for the ANGLE
   deadband -- the old one was recomputed each frame by the master's own
   reckoner from timing it does not control and false-fired in pi-waves
   (measured maxAng~=pi bursts -> periodic resync floods).
 - Dense-rot type-4 send REMOVED (was masking the old crude projection; not
   authentic; churned the shared horizon).  Orientation now rides the sparse
   angle/velocity deadband resyncs exactly as the binary's.

Verified live-autonomous:
 - pure spin: 59/59 perfectly regular peer yaw steps; master resyncs 0/s with
   mirror drift ~5e-7 (records near-silent, authentic sparse model).
 - walk+turn circle (the user regime): peer sim yaw monotonic at exactly the
   master's rate (0.00556/frame @ 0.327 rad/s), no backward steps, no stalls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-14 18:52:15 -05:00
co-authored by Claude Opus 4.8
parent b7be95b584
commit d78e77bf84
3 changed files with 83 additions and 47 deletions
+14
View File
@@ -915,6 +915,10 @@ Mech::Mech(
Wword(0xe6) = 0;
poseSyncLatch = 0; // @0x77c (was absorbed Wword(0x1df))
heatLevelSnapshot = 0; // @0x780 (type-7 deadband baseline)
angMirrorYaw = 0.0f; // scalar peer-yaw mirror (port addition)
angMirrorRate = 0.0f;
angMirrorValid = 0;
angSyncLatch = 0; // peer heading re-anchor (type-4 receive)
Wword(0xfe) = Wword(0xfc) = Wword(0x105) = 0;
mechName.Copy(&DAT_004e0f8c); // FUN_00408e90(this+0xd8,"")
Wword(0x160) = Wword(0x15f) = 0;
@@ -1893,6 +1897,7 @@ void
}
}
bodyTargetSpeed = record->speedDemand; // rec+0x28
angSyncLatch = 1; // arm the peer heading re-anchor
}
break;
@@ -2114,6 +2119,15 @@ void
// master perf; the writer-side re-base is the coherent reconstruction.)
projectedOrigin.angularPosition = localOrigin.angularPosition;
projectedVelocity.angularMotion = localVelocity.angularMotion;
// SCALAR peer-yaw mirror re-base (see mech.hpp): what the peer will now
// extrapolate -- this yaw, at this rate, from this moment.
{
YawPitchRoll _my; _my = localOrigin.angularPosition;
angMirrorYaw = (Scalar)_my.yaw;
angMirrorRate = (Scalar)localVelocity.angularMotion.y;
angMirrorTime = lastPerformance;
angMirrorValid = 1;
}
record->speedDemand = speedDemand; // rec+0x28
bodyTargetSpeed = speedDemand;
}
+14
View File
@@ -618,6 +618,20 @@ public:
Scalar updatePositionDeadband; // binary @0x768 -- type-0 pose trigger
Scalar updateTurnVelocityDeadband; // binary @0x76c -- type-4 yaw-rate trigger
Scalar updateTurnAngleDeadband; // binary @0x770 -- type-4 orientation trigger (rad)
// SCALAR peer-yaw mirror (port addition, replicant-spin work): the master's
// estimate of the yaw the PEER is currently rendering -- re-based on every
// type-4 send (writer case 4), advanced analytically as base + rate*elapsed.
// Replaces the quaternion projectedOrigin mirror for the ANGLE deadband: that
// one is recomputed by the master's own reckoner each frame from lastUpdate
// timing it does not control, and its false pi-drift waves FLOODED type-4
// resyncs (measured maxAng~=pi bursts). Scalars, wrap-safe, self-timed.
Scalar angMirrorYaw; // yaw at the last type-4 send (rad)
Scalar angMirrorRate; // yaw rate sent with it (rad/s)
Time angMirrorTime; // lastPerformance at that send
int angMirrorValid; // 0 until the first type-4 send
int angSyncLatch; // peer: type-4 arrived -> re-anchor heading (the
// angular analog of poseSyncLatch; consumed by
// the replicant's incremental heading integrator)
// AUTHENTIC GROUND MODEL ctor products (task #15, ground-model-decode;
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
// after the layout-locked fields so nothing shifts.
+55 -47
View File
@@ -1930,7 +1930,36 @@ void
{
if (dt > 0.0001f && dt < 0.5f)
{
DeadReckon(dt); // engine: reckoner (exact angular) + lerp
// 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
@@ -3615,44 +3644,34 @@ void
// UpdateTurnVelocityDiffrence. [T1 expressions; zero-deadband
// guard falls back to the old quat-w stand-in]
{
// PEER-ESTIMATE MIRROR (2026-07-14, replicant-chop fix): advance the
// projection by the last-SENT angular velocity each frame -- this is
// what the replicant's dead-reckoner is doing with the last record.
// The gate below then measures the peer's TRUE drift, so a steady
// turn (matching velocity) sends almost nothing and the replicant
// extrapolates smoothly, instead of the stale mirror firing a
// re-base record EVERY frame (the stall/snap chop). The type-4
// writer re-bases this mirror on each send (mech.cpp case 4).
{
Vector3D angStepM;
angStepM.Multiply(updateVelocity.angularMotion, dt);
projectedOrigin.angularPosition.Add(
projectedOrigin.angularPosition, angStepM);
// Adding a scaled angular-velocity VECTOR to a quaternion denormalizes
// it (the reckoner MOVER.cpp:463 does the same); a non-unit quaternion's
// extracted yaw is garbage (~pi), which spiked angDrift and FLOODED angle
// resyncs in bursts -> peer horizon reset -> uneven (max/avg ~2.8x) render.
projectedOrigin.angularPosition.Normalize();
}
// 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
// TRUE yaw drift in RADIANS (wrap-safe), to match the radian deadband
// angDb. The prior `Abs(localOrigin.angularPosition.y -
// projectedOrigin.angularPosition.y)` compared raw QUATERNION y-components
// (sin(yaw/2)-ish, unitless) against a radian deadband -- both a units
// mismatch and wrap-unsafe: at the +-pi heading wrap the quaternion-y diff
// spikes toward its max (~2) and fired byAngle=56/56 EVERY frame (measured),
// flooding resyncs at every wrap. Take the yaw difference in euler space
// and unwrap it to [-pi,pi] so a wrap is not seen as a ~2pi drift.
YawPitchRoll _yprL, _yprP;
_yprL = localOrigin.angularPosition;
_yprP = projectedOrigin.angularPosition;
Scalar _dyaw = (Scalar)(_yprL.yaw - _yprP.yaw);
while (_dyaw > 3.14159265f) _dyaw -= 6.28318531f;
while (_dyaw < -3.14159265f) _dyaw += 6.28318531f;
const Scalar angDrift = Abs(_dyaw);
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
@@ -3698,17 +3717,6 @@ 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