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

@ -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 |

View file

@ -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))

View file

@ -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,

View file

@ -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,

View file

@ -2617,16 +2617,13 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// </para>
///
/// <para>
/// Consequence: the dead-reckoning catch-up speed
/// (<c>InterpolationManager::adjust_offset</c> 0x00555d30, pc:353122)
/// is <c>2 × get_max_speed()</c> ≈ 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 <c>2 ×
/// <see cref="GetAdjustedMaxSpeed"/></c>, not 2 × this — the
/// <c>fUseAdjustedSpeed_</c> 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.
/// </para>
/// </summary>
public float GetMaxSpeed()
@ -2641,6 +2638,52 @@ public sealed class MotionInterpreter : IMotionDoneSink
return RunAnimSpeed * rate;
}
/// <summary>
/// Retail <c>CMotionInterp::get_adjusted_max_speed</c> (0x00527D00) — the
/// accessor <c>InterpolationManager</c>'s catch-up cap ACTUALLY uses,
/// because the static <c>InterpolationManager::fUseAdjustedSpeed_</c>
/// initializes to <c>0x1</c> (.data 0x0081F418, pseudo-C:1102675), making
/// the <c>get_max_speed</c> branch at 0x00555dbe dead by default.
///
/// <para>
/// Byte decode of 0x00527D00 (movement parity audit + P-review,
/// 2026-07-30): compute rate = InqRunRate() / <see cref="MyRunRate"/> /
/// 1.0 fallback chain, then <c>cmp [this+0x4C], 0x44000007</c>
/// (RunForward) — NOT RunForward returns the BARE rate (no ×4);
/// RunForward DISCARDS the rate (<c>fstp st0</c>) and returns
/// <c>interpreted_state.forward_speed ÷ current_speed_factor ×
/// RunAnimSpeed(4.0)</c>. <c>current_speed_factor</c> (+0x6C) is written
/// exactly once in the entire binary — <c>CMotionInterp::Create</c>
/// (0x00528C34) stores <c>0x3F800000</c> = 1.0f — so the division is a
/// vestigial constant and is not ported as a field.
/// </para>
///
/// <para>
/// Consequence: retail caps remote catch-up at <c>2 ×</c> THIS — a
/// standing/walking/turning remote catches up at ~2×runRate (≈5.9 m/s at
/// run-rate 2.94), not 2×<see cref="GetMaxSpeed"/> (≈23.5 m/s). The
/// former unconditional <see cref="GetMaxSpeed"/> at the catch-up sites
/// made non-running remotes reconverge ~4× faster than retail (the
/// #41/#165 presentation family).
/// </para>
/// </summary>
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

View file

@ -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);

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