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() {