fix(physics): movement-parity fixes - adjusted catch-up cap, autorun retail semantics, AP-30 retired

Ports CMotionInterp::get_adjusted_max_speed (0x00527D00, byte-decoded:
bare rate unless RunForward; forward_speed x 4.0 when running;
current_speed_factor proven a ctor-constant 1.0 at 0x00528C34) and swaps
all five interpolation catch-up call sites to it - retail's
fUseAdjustedSpeed_ static (.data 0x0081F418 = 1) makes this the live
branch, so standing/walking remotes now catch up at ~2x runRate instead
of 4x too fast (the #41/#165 presentation family). Autorun now hard-
forces Run for its duration and cancels on every fresh forward press
(CommandInterpreter::HandleNewForwardMovement 0x006b3d60 is literally
SetAutoRun(0,1)); the old test pin codified the divergence. AP-30
retired: retail Frame::is_equal genuinely uses the 0.0002 epsilon - the
row recorded a non-divergence. Three catch-up test pins re-baselined to
retail semantics with citations. Full Release suite 9,983/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 15:04:17 +02:00
parent 2123b44e8c
commit c0afcacbb2
10 changed files with 174 additions and 32 deletions

View file

@ -87,14 +87,22 @@ public sealed class DispatcherMovementInputSourceTests
}
[Fact]
public void ForwardDoesNotCancelAutorunAndResetDoes()
public void ForwardCancelsAutorun_AndResetClearsIt()
{
// Movement parity audit 2026-07-30 — retail
// CommandInterpreter::HandleNewForwardMovement (0x006b3d60) is
// literally `SetAutoRun(0, 1)`: EVERY fresh forward press disables
// autorun and hands control back to the key. The previous pin
// ("ForwardDoesNotCancelAutorun") codified the divergence.
var source = CreateSource();
source.HandlePressedAction(InputAction.MovementRunLock);
Assert.False(source.HandlePressedAction(InputAction.MovementForward));
Assert.True(source.AutoRunActive);
source.HandlePressedAction(InputAction.MovementForward);
Assert.False(source.AutoRunActive);
source.HandlePressedAction(InputAction.MovementRunLock);
Assert.True(source.AutoRunActive);
source.ResetSession();
Assert.False(source.AutoRunActive);
}

View file

@ -300,9 +300,14 @@ public sealed class RemotePhysicsUpdaterTests
ownerClockEpoch: binding.Record.ObjectClockEpoch);
// InterpolationManager::adjust_offset clamps the 1 m correction to
// maxSpeed (8 m/s) * 0.1 s = 0.8 m and replaces the authored 10 m
// root origin rather than adding to it.
Assert.InRange(motion.Body.Position.X, 10.79f, 10.81f);
// 2 x GetAdjustedMaxSpeed x 0.1 s. Movement parity audit 2026-07-30:
// retail's cap uses get_adjusted_max_speed (fUseAdjustedSpeed_ = 1,
// .data 0x0081F418) — for this fixture's Ready interpreted state that
// is the BARE rate (1.0), so 2 m/s x 0.1 s = 0.2 m, and the clamped
// correction replaces the authored 10 m root origin rather than
// adding to it. (The former 10.8 pin encoded the old unconditional
// GetMaxSpeed cap — 4x retail for a non-running remote.)
Assert.InRange(motion.Body.Position.X, 10.19f, 10.21f);
Assert.InRange(
MathF.Abs(Quaternion.Dot(
Quaternion.Normalize(target),

View file

@ -216,12 +216,22 @@ public sealed class LiveEntityAnimationSchedulerTests
LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid);
float startX = entity.Position.X;
scheduler.Tick(
0.1f,
entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 1,
liveCenterY: 1);
// Movement parity audit 2026-07-30: retail's catch-up cap is
// 2 x GetAdjustedMaxSpeed — the BARE rate (1.0) for this fixture's
// Ready interpreted state, i.e. 0.2 m per 0.1 s tick (the old
// single-tick > 0.5 m expectation encoded the unconditional
// GetMaxSpeed cap, 4x retail for a non-running remote). The intent
// pinned here is CONSUMPTION: drive enough ticks for the 1 m
// correction to be fully absorbed.
for (int i = 0; i < 6; i++)
{
scheduler.Tick(
0.1f,
entity.Position,
localHiddenPartPoseDirty: false,
liveCenterX: 1,
liveCenterY: 1);
}
Assert.True(entity.Position.X > startX + 0.5f);
Assert.True(remote.Interp.IsActive || entity.Position.X >= startX + 0.99f);
@ -354,7 +364,10 @@ public sealed class LiveEntityAnimationSchedulerTests
Assert.Contains(record.ProjectionKey!.Value, schedules.Keys);
Assert.Equal(1, rootPublishes);
float distance = animation.Entity.Position.X - startX;
Assert.InRange(distance, 0.79f, 0.81f);
// Movement parity audit 2026-07-30: 2 x GetAdjustedMaxSpeed (bare
// rate 1.0 for a Ready mover) x 0.1 s = 0.2 m per tick — the former
// 0.8 pin encoded the old unconditional GetMaxSpeed cap.
Assert.InRange(distance, 0.19f, 0.21f);
Assert.Same(remote.Body, record.PhysicsBody);
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
}

View file

@ -666,6 +666,69 @@ public sealed class MotionInterpreterTests
Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.75f, speed, precision: 4);
}
// =========================================================================
// GetAdjustedMaxSpeed (CMotionInterp::get_adjusted_max_speed @ 0x00527D00)
// Movement parity audit 2026-07-30 — the accessor InterpolationManager's
// catch-up cap actually uses (fUseAdjustedSpeed_ = 0x1, .data 0x0081F418).
// Byte-decoded: NOT RunForward → bare rate; RunForward → forward_speed ×
// RunAnimSpeed (current_speed_factor is a ctor-constant 1.0, 0x00528C34).
// =========================================================================
[Theory]
[InlineData(MotionCommand.WalkForward)]
[InlineData(MotionCommand.WalkBackward)]
[InlineData(MotionCommand.Ready)]
public void GetAdjustedMaxSpeed_NotRunForward_ReturnsBareRunRate(uint command)
{
// The 4x-too-fast walking-remote catch-up fix: a non-RunForward mover's
// adjusted max speed is the BARE rate — no RunAnimSpeed multiply
// (0x00527d2a jnz falls straight to ret with rate in st0).
var weenie = new FakeWeenie { RunRate = 2.94f };
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = command;
Assert.Equal(2.94f, interp.GetAdjustedMaxSpeed(), precision: 4);
}
[Fact]
public void GetAdjustedMaxSpeed_RunForward_ReturnsForwardSpeedTimesRunAnimSpeed()
{
// RunForward DISCARDS the queried rate (fstp st0) and returns
// forward_speed / current_speed_factor(=1.0) * 4.0.
var weenie = new FakeWeenie { RunRate = 99f }; // must be ignored
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 2.5f;
Assert.Equal(
2.5f * MotionInterpreter.RunAnimSpeed,
interp.GetAdjustedMaxSpeed(),
precision: 4); // 10.0 — NOT 99×4
}
[Fact]
public void GetAdjustedMaxSpeed_NoWeenie_FallsBackToLiteralOne()
{
// 0x00527d0b: no weenie → fld [0x007928b0] = 1.0 (NOT my_run_rate).
var interp = MakeInterp(weenie: null);
interp.MyRunRate = 3.5f;
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
Assert.Equal(1.0f, interp.GetAdjustedMaxSpeed(), precision: 4);
}
[Fact]
public void GetAdjustedMaxSpeed_InqRunRateFails_FallsBackToMyRunRate()
{
// 0x00527d1d-0x00527d23: InqRunRate false → fld [this+0x7c] my_run_rate.
var weenie = new FakeWeenie { RunRate = 5f, InqRunRateResult = false };
var interp = MakeInterp(weenie: weenie);
interp.MyRunRate = 2.4f;
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
Assert.Equal(2.4f, interp.GetAdjustedMaxSpeed(), precision: 4);
}
[Fact]
public void GetMaxSpeed_NoWeenie_ReturnsLiteralOneTimesRunAnimSpeed()
{