diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 0ac9f70d..d9fcca7d 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -119,7 +119,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 92 active rows (AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
+## 3. Documented approximation (AP) — 91 active rows (AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@@ -150,7 +150,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
-| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`ShellDrawLiftZ`); `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`DrawPortalDepthWrite`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
index af2ca7a5..977102b4 100644
--- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs
+++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
@@ -68,7 +68,13 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
StrafeRight: dispatcher.IsActionHeld(InputAction.MovementStrafeRight),
TurnLeft: dispatcher.IsActionHeld(InputAction.MovementTurnLeft),
TurnRight: dispatcher.IsActionHeld(InputAction.MovementTurnRight),
- Run: !walking,
+ // Movement parity audit (2026-07-30): retail's autorun hard-forces
+ // Run for its whole duration (ACCmdInterp autorun dispatch always
+ // sends RunForward-class state); the live walk/run toggle only
+ // applies to ordinary held-key movement. Recomputing `!walking`
+ // unconditionally let a walk-mode toggle demote an active autorun
+ // to walking — impossible in retail.
+ Run: !walking || AutoRunActive,
Jump: dispatcher.IsActionHeld(InputAction.MovementJump));
}
@@ -84,7 +90,12 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
AcDream.Runtime.RuntimeMovementCommand.ToggleRunLock);
if (AutoRunActive && action is (
- InputAction.MovementBackup
+ // Movement parity audit (2026-07-30): retail's
+ // ACCmdInterp::HandleNewForwardMovement drops autorun on EVERY
+ // fresh forward press edge, not only on backward/stop/strafe —
+ // a new W press while autorunning hands control back to the key.
+ InputAction.MovementForward
+ or InputAction.MovementBackup
or InputAction.MovementStop
or InputAction.MovementStrafeLeft
or InputAction.MovementStrafeRight))
diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs
index 6a7385be..732684d7 100644
--- a/src/AcDream.App/Input/PlayerModeController.cs
+++ b/src/AcDream.App/Input/PlayerModeController.cs
@@ -325,7 +325,7 @@ internal sealed class PlayerModeController :
playerGuid,
playerEntity).Radius,
inContact: () => capturedController.BodyInContact,
- minterpMaxSpeed: () => capturedController.Motion.GetMaxSpeed(),
+ minterpMaxSpeed: () => capturedController.Motion.GetAdjustedMaxSpeed(),
curTime: () => capturedController.SimTimeSeconds,
physicsTimerTime: () => capturedController.SimTimeSeconds,
getObjectA: _motionBindings.ResolvePhysicsHost,
diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
index 0f0ba09b..db8a635b 100644
--- a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
+++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
@@ -171,7 +171,7 @@ internal sealed class LiveEntityMotionRuntimeController
getVelocity: () => mtBody.Velocity,
getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
inContact: () => mtBody.OnWalkable,
- minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(),
+ minterpMaxSpeed: () => rmT.Motion.GetAdjustedMaxSpeed(),
curTime: NowSeconds,
physicsTimerTime: NowSeconds,
getObjectA: ResolvePhysicsHost,
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index 21111451..4cb9eba7 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -2617,16 +2617,13 @@ public sealed class MotionInterpreter : IMotionDoneSink
///
///
///
- /// Consequence: the dead-reckoning catch-up speed
- /// (InterpolationManager::adjust_offset 0x00555d30, pc:353122)
- /// is 2 × get_max_speed() ≈ 23.5 m/s for a run-rate-2.94
- /// (run-skill-200) character — that IS retail's value. An earlier
- /// doc-comment here claimed the bare rate (~5.9 m/s catch-up) was
- /// retail-correct and blamed the ×4 for the multi-second 1-Hz blip on
- /// observed retail remotes; that reading trusted the BN x87 dropout
- /// and is refuted by the binary. If the blip recurs, its root cause is
- /// elsewhere (node-fail handling / progress-quantum abandonment /
- /// position-queue feed — the #41 family), NOT this multiply.
+ /// Consequence note SUPERSEDED 2026-07-30 (movement parity audit): the
+ /// dead-reckoning catch-up cap is 2 ×
+ /// , not 2 × this — the
+ /// fUseAdjustedSpeed_ static defaults the 0x00555dbe branch to
+ /// the ADJUSTED accessor (bare rate unless actively RunForward). This
+ /// method remains retail-faithful for its other consumers; only the
+ /// catch-up-cap sites moved to the adjusted sibling below.
///
///
public float GetMaxSpeed()
@@ -2641,6 +2638,52 @@ public sealed class MotionInterpreter : IMotionDoneSink
return RunAnimSpeed * rate;
}
+ ///
+ /// Retail CMotionInterp::get_adjusted_max_speed (0x00527D00) — the
+ /// accessor InterpolationManager's catch-up cap ACTUALLY uses,
+ /// because the static InterpolationManager::fUseAdjustedSpeed_
+ /// initializes to 0x1 (.data 0x0081F418, pseudo-C:1102675), making
+ /// the get_max_speed branch at 0x00555dbe dead by default.
+ ///
+ ///
+ /// Byte decode of 0x00527D00 (movement parity audit + P-review,
+ /// 2026-07-30): compute rate = InqRunRate() / /
+ /// 1.0 fallback chain, then cmp [this+0x4C], 0x44000007
+ /// (RunForward) — NOT RunForward returns the BARE rate (no ×4);
+ /// RunForward DISCARDS the rate (fstp st0) and returns
+ /// interpreted_state.forward_speed ÷ current_speed_factor ×
+ /// RunAnimSpeed(4.0). current_speed_factor (+0x6C) is written
+ /// exactly once in the entire binary — CMotionInterp::Create
+ /// (0x00528C34) stores 0x3F800000 = 1.0f — so the division is a
+ /// vestigial constant and is not ported as a field.
+ ///
+ ///
+ ///
+ /// Consequence: retail caps remote catch-up at 2 × THIS — a
+ /// standing/walking/turning remote catches up at ~2×runRate (≈5.9 m/s at
+ /// run-rate 2.94), not 2× (≈23.5 m/s). The
+ /// former unconditional at the catch-up sites
+ /// made non-running remotes reconverge ~4× faster than retail (the
+ /// #41/#165 presentation family).
+ ///
+ ///
+ public float GetAdjustedMaxSpeed()
+ {
+ if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
+ {
+ // 0x00527d31-0x00527d40: fstp st0 (discard rate); fld forward_speed;
+ // fdiv current_speed_factor (ctor-constant 1.0); fmul 4.0.
+ return InterpretedState.ForwardSpeed * RunAnimSpeed;
+ }
+
+ // Not RunForward: the bare rate — NO RunAnimSpeed multiply
+ // (0x00527d2a jnz falls straight to ret with rate in st0).
+ float rate = 1.0f;
+ if (WeenieObj is not null && !WeenieObj.InqRunRate(out rate))
+ rate = MyRunRate;
+ return rate;
+ }
+
// R3-W5: the former `ApplyMotionToInterpretedState` private helper
// (a hand-rolled switch approximating InterpretedMotionState.ApplyMotion)
// is DELETED per the plan (closes J3/J4) — the merged DoInterpretedMotion
diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
index 4300bf4e..c6da0114 100644
--- a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
@@ -251,7 +251,7 @@ internal sealed class RuntimeRemotePhysicsUpdater
rm.PositionManagerDeltaScratch;
pmDelta.Origin = scaledRootMotionLocalOrigin;
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
- float maxSpeedNpc = rm.Motion.GetMaxSpeed();
+ float maxSpeedNpc = rm.Motion.GetAdjustedMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
? _physics.Engine.SampleTerrainNormal(
rm.Body.Position.X,
@@ -288,7 +288,7 @@ internal sealed class RuntimeRemotePhysicsUpdater
rm.PositionManagerDeltaScratch;
pmDelta.Origin = scaledRootMotionLocalOrigin;
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
- float maxSpeedNpc = rm.Motion.GetMaxSpeed();
+ float maxSpeedNpc = rm.Motion.GetAdjustedMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
? _physics.Engine.SampleTerrainNormal(
rm.Body.Position.X,
@@ -682,7 +682,7 @@ internal sealed class RuntimeRemotePhysicsUpdater
rm.Body.Orientation,
positionDelta,
rm.Interp,
- rm.Motion.GetMaxSpeed(),
+ rm.Motion.GetAdjustedMaxSpeed(),
positionDelta,
inContact: rm.Body.InContact);
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
diff --git a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs
index ae5a06e9..96660bfa 100644
--- a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs
+++ b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs
@@ -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);
}
diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs
index f17b57af..3ff794ea 100644
--- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs
+++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs
@@ -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),
diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
index 0bbadff2..ce362c03 100644
--- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs
@@ -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);
}
diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
index cfecd6a1..982c7788 100644
--- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
@@ -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()
{