diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index ec87756a..e34eb1f9 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -102,7 +102,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 72 rows
+## 3. Documented approximation (AP) — 73 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -179,9 +179,10 @@ accepted-divergence entries (#96, #49, #50).
---
-| AP-74 | **K-fix18 `skipTransitionLink`** post-Q4 expression: `SetCycle(..., skipTransitionLink:true)` strips the just-dispatched transition links via `CSequence.RemoveAllLinkAnimations` so Falling engages instantly on jump start; retail gets the same strip from `LeaveGround` → `CPhysicsObj::RemoveLinkAnimations` (0x0050fe20) fired by the physics layer detecting ground departure (R2-Q4 rehost of the 2026-04-26 K-fix18) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` skip branch); GameWindow :4830/:10201 callers | Same primitive retail uses, fired from the caller instead of the ground-transition event; pending-queue tick counts intentionally not zeroed (retail's RemoveLinkAnimations doesn't touch the manager queue — the countdown chain absorbs the stall) | If a code path sets the flag outside a genuine ground-departure, links retail would play get eaten; queue completion for the stripped entry reports late (next sentinel) | `LeaveGround` 0x00528b00 → `RemoveLinkAnimations` 0x0050fe20; retire in R3-W4 (flag + call sites deleted, LeaveGround fires it) |
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
+| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
+| AP-78 | **`MotionInterpreter.RemoveLinkAnimations`/`InitializeMotionTables` are no-op `Action?` seams** — retail's `CPhysicsObj::RemoveLinkAnimations` (0x0050fe20, called from `HitGround`/`LeaveGround`) and `CPhysicsObj::InitializeMotionTables` (called from `enter_default_state`) have no real implementation bound at the Core layer; this is ALSO the retail mechanism K-fix18's App-side `SetCycle(skipTransitionLink:true)` hack (AP-74) stands in for — R3-W4 ports the CALL SITES (`HitGround`/`LeaveGround`/`EnterDefaultState` all invoke the seam), but the App-side binding to the entity's `AnimationSequencer`/`CSequence` core, and the K-fix18 hack's deletion, are a separate (orchestrator) commit | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`RemoveLinkAnimations`, `InitializeMotionTables` properties) | Matches the existing `UnstickFromObject`/`InterruptCurrentMovement` `Action?` seam convention (R3-W2/W3); Core has no GL/sequencer dependency to bind against (Code Structure Rule #2) | Until the App layer binds `RemoveLinkAnimations`, `HitGround`/`LeaveGround`/`EnterDefaultState` silently skip the link-strip — no different from before R3-W4 (K-fix18 still covers it) until the orchestrator's binding + K-fix18 deletion land together | `CPhysicsObj::RemoveLinkAnimations` 0x0050fe20; `CPhysicsObj::InitializeMotionTables`; retire (seam→binding) when the orchestrator wires the App-side call, AP-74 retires in the SAME commit that deletes K-fix18 |
## 4. Temporary stopgap (TS) — 36 rows (TS-37 is a retired-row historical note, not an active count)
@@ -219,10 +220,10 @@ accepted-divergence entries (#96, #49, #50).
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping) is a dedicated follow-up slice | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0 |
-| TS-34 | `adjust_motion`'s creature guard is a no-op — retail returns early for a non-creature weenie (`!IsCreature()`), but acdream's `IWeenieObject` has no `IsCreature()`, so the guard always proceeds | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`adjust_motion`) | Correct today: the only caller of the local `adjust_motion`/`apply_raw_movement` path is the player (a creature); no non-creature drives it | If a non-creature weenie is ever routed through `adjust_motion` (e.g. a missile with a weenie), its motion would be wrongly normalized instead of skipped | `CMotionInterp::adjust_motion` 0x00528010 creature guard; add `IsCreature` to IWeenieObject |
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain; the WRITE side (per-cell contact-plane constraint tracking — a mover pinned between opposing walkable surfaces / doorway jamming) doesn't exist yet | A body that retail would consider fully constrained (and thus refuse to jump, 0x47) never gets blocked here — jumps succeed in geometry retail would reject. Low practical risk today: the constrained-mover scenario (doorway wedge, opposing-wall pin) is rare and mostly a physics-digest topic already tracked separately | `CPhysicsObj::IsFullyConstrained` 0x0050f730; `jump_is_allowed` 0x005282b0 |
| TS-36 | `MotionInterpreter.InterruptCurrentMovement` is a no-op `Action?` seam — retail's `CPhysicsObj::interrupt_current_movement` (called unconditionally at the top of `jump()` and inside `StopCompletely`) has no real implementation to call yet | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`InterruptCurrentMovement`, R3-W3; called from `jump()`) | R4 (MoveToManager) is where `cancel_moveto`/in-flight-transition-interrupt actually lands; until then there is no in-flight transition to cancel (no MoveToManager exists), so the no-op is behaviorally inert, not a masked bug | Once MoveToManager exists (R4) and a jump fires mid-auto-walk/mid-moveto without this seam being wired to the real cancel, the queued transition would keep running alongside the new jump — silent double-motion | `CPhysicsObj::interrupt_current_movement`; `jump` 0x00528780 @305800 |
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
+| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
---
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index fabb4b4c..80c35f44 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -353,6 +353,11 @@ public sealed class PlayerMovementController
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
_motion = new MotionInterpreter(_body, _weenie);
+ // R3-W4 (A3): the local player's movement is input-driven —
+ // movement_is_autonomous true so apply_current_movement's dual
+ // dispatch routes apply_raw_movement (IsThePlayer && autonomous).
+ // R3-W6 refines this per-motion (server-controlled MoveTo clears it).
+ _body.LastMoveWasAutonomous = true;
}
public void SetCharacterSkills(int runSkill, int jumpSkill)
@@ -1008,12 +1013,13 @@ public sealed class PlayerMovementController
var jumpResult = _motion.jump(_jumpExtent);
if (jumpResult == WeenieError.None)
{
- // Capture jump_v_z BEFORE LeaveGround() — that call resets
- // JumpExtent back to 0 (faithful to retail's FUN_00529710),
- // after which GetJumpVZ() returns 0 because the extent
- // gate at the top of the function fires.
+ // R3-W4 (J7): the manual LeaveGround call is DELETED —
+ // jump() clears OnWalkable, and the SAME frame's
+ // grounded→airborne edge (section 5's transition detection)
+ // fires _motion.LeaveGround() exactly where retail's
+ // transition sweep does. Capture jump_v_z NOW: the edge's
+ // LeaveGround resets JumpExtent to 0 later this frame.
float jumpVz = _motion.GetJumpVZ();
- _motion.LeaveGround();
outJumpExtent = _jumpExtent;
// D6.2: get_state_velocity() is now correct for all directions
// (apply_raw_movement normalized backward/strafe above), so the
@@ -1266,6 +1272,16 @@ public sealed class PlayerMovementController
}
+ // R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's
+ // LeaveGround (0x00528b00) — jump launches (jump() cleared
+ // OnWalkable earlier this frame) AND walk-off-a-ledge both route
+ // here, replacing the old manual jump-block call (and giving
+ // ledge departures the momentum fallback + link strip they never
+ // had). Edge = grounded last frame, airborne now; the landing
+ // branch above already fires HitGround on the opposite edge.
+ if (!_body.OnWalkable && !_wasAirborneLastFrame)
+ _motion.LeaveGround();
+
_wasAirborneLastFrame = !_body.OnWalkable;
UpdateCellId(resolveResult.CellId, "resolver");
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 396b662f..6ca78cb5 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -412,6 +412,11 @@ public sealed class GameWindow : IDisposable
{
public AcDream.Core.Physics.PhysicsBody Body;
public AcDream.Core.Physics.MotionInterpreter Motion;
+ /// R3-W4: the persistent per-remote dispatch sink (created
+ /// once by EnsureRemoteMotionBindings; also bound as
+ /// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch
+ /// cycles through the motion-table backend).
+ public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink;
/// Last UpdatePosition timestamp — drives body.update_object sub-stepping.
public double LastServerPosTime;
/// Last known server position — kept for diagnostics / HUD.
@@ -4176,6 +4181,45 @@ public sealed class GameWindow : IDisposable
}
}
+ ///
+ /// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
+ /// the persistent (ObservedOmega turn
+ /// callbacks), Motion.DefaultSink (so
+ /// apply_current_movement's interpreted branch dispatches cycles
+ /// — the retail mechanism behind the deleted K-fix18 forced-Falling),
+ /// and the RemoveLinkAnimations/InitializeMotionTables
+ /// seams (retail CPhysicsObj::RemoveLinkAnimations 0x0050fe20 /
+ /// InitializeMotionTables). Idempotent; safe from both the UM path and
+ /// the VectorUpdate path regardless of arrival order.
+ ///
+ private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
+ RemoteMotion rm, AnimatedEntity ae)
+ {
+ if (ae.Sequencer is null)
+ return rm.Sink;
+ if (rm.Sink is not null)
+ return rm.Sink;
+
+ var rmForSink = rm;
+ rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer)
+ {
+ TurnApplied = (turnMotion, turnSpeed) =>
+ {
+ float signed = (turnMotion & 0xFFu) == 0x0E
+ ? -System.MathF.Abs(turnSpeed)
+ : turnSpeed;
+ rmForSink.ObservedOmega = new System.Numerics.Vector3(
+ 0f, 0f, -(System.MathF.PI / 2f) * signed);
+ },
+ TurnStopped = () =>
+ rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
+ };
+ rm.Motion.DefaultSink = rm.Sink;
+ rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
+ rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
+ return rm.Sink;
+ }
+
private bool RemoveLiveEntityByServerGuid(uint serverGuid)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
@@ -4623,31 +4667,13 @@ public sealed class GameWindow : IDisposable
ims.Actions = actionList;
}
- // R2-Q5: funnel dispatches go STRAIGHT into the motion-
- // table stack (GetObjectSequence + is_allowed decide) —
- // RemoteMotionSink's single-cycle pick + fallback chains
- // are deleted (AP-73 retired). The callbacks are the
- // ObservedOmega seam: seed remote rotation from the wire
- // turn this tick (retail rotates from sequence omega in
- // the per-tick apply_physics chain — R6; register row).
- AcDream.Core.Physics.Motion.MotionTableDispatchSink? sink = null;
- if (ae.Sequencer is not null)
- {
- var rmForSink = remoteMot;
- sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer)
- {
- TurnApplied = (turnMotion, turnSpeed) =>
- {
- float signed = (turnMotion & 0xFFu) == 0x0E
- ? -System.MathF.Abs(turnSpeed)
- : turnSpeed;
- rmForSink.ObservedOmega = new System.Numerics.Vector3(
- 0f, 0f, -(System.MathF.PI / 2f) * signed);
- },
- TurnStopped = () =>
- rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
- };
- }
+ // R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the
+ // motion-table stack (GetObjectSequence + is_allowed
+ // decide). The sink is PERSISTENT per remote and also
+ // bound as Motion.DefaultSink + the RemoveLink/InitTables
+ // seams (EnsureRemoteMotionBindings) so LeaveGround/
+ // HitGround dispatch through the same backend.
+ var sink = EnsureRemoteMotionBindings(remoteMot, ae);
remoteMot.Motion.MoveToInterpretedState(ims, sink);
}
}
@@ -4813,26 +4839,24 @@ public sealed class GameWindow : IDisposable
| AcDream.Core.Physics.TransientStateFlags.OnWalkable);
rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity;
- // K-fix10 (2026-04-26): force the Falling animation cycle on
- // the remote so the legs match the arc. Mirrors the local
- // player's UpdatePlayerAnimation path which sets
- // animCommand = Falling whenever !IsOnGround.
- //
- // K-fix18 (2026-04-26): pass skipTransitionLink:true so the
- // RunForward→Falling transition frames don't play first.
- // Without that flag the remote stood still for ~100 ms at
- // the start of the jump while the link drained, then
- // folded into Falling. Skipping the link makes the pose
- // engage immediately on jump start.
+ // R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism.
+ // The remote's ground departure fires LeaveGround (0x00528b00):
+ // strips pending transition links (the RemoveLinkAnimations
+ // seam) + re-applies movement through DefaultSink, whose
+ // contact-gated funnel dispatch engages Falling — no forced
+ // SetCycle, no skip flag. The wire velocity/omega are re-applied
+ // AFTER so they stay authoritative over LeaveGround's
+ // state-derived velocity write (adaptation note: retail's
+ // equivalence comes from the per-tick transition-sweep order —
+ // R6 scope).
if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent)
&& _animatedEntities.TryGetValue(ent.Id, out var ae)
&& ae.Sequencer is not null)
{
- uint style = ae.Sequencer.CurrentStyle != 0
- ? ae.Sequencer.CurrentStyle
- : 0x8000003Du; // NonCombat default
- ae.Sequencer.SetCycle(style, AcDream.Core.Physics.MotionCommand.Falling, 1.0f,
- skipTransitionLink: true);
+ EnsureRemoteMotionBindings(rm, ae);
+ rm.Motion.LeaveGround();
+ rm.Body.Velocity = update.Velocity;
+ rm.Body.Omega = update.Omega;
}
}
@@ -10241,14 +10265,11 @@ public sealed class GameWindow : IDisposable
{
animScale = s;
}
- // K-fix18 (2026-04-26): when transitioning into Falling
- // (jump start), skip the link so the legs engage Falling
- // immediately. Without this the local player visibly
- // stood still for ~100 ms at the start of every jump
- // while the RunForward→Falling transition link drained.
- // For everything else (Walk → Run, Run → Ready, etc.) we
- // keep the link so transitions stay smooth.
- bool skipLink = animCommand == AcDream.Core.Physics.MotionCommand.Falling;
+ // R3-W4: the K-fix18 skipLink flag is DELETED — the instant
+ // Falling engage comes from the controller's grounded→airborne
+ // edge firing MotionInterpreter.LeaveGround, whose
+ // RemoveLinkAnimations seam (bound at sequencer creation)
+ // strips pending links exactly where retail does.
// #45 (2026-05-06): scale sidestep speedMod to match ACE's
// wire formula. PlayerMovementController hands us a raw
@@ -10270,8 +10291,7 @@ public sealed class GameWindow : IDisposable
* 0.5f;
}
- ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale,
- skipTransitionLink: skipLink);
+ ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale);
}
// Legacy path: update the manual slerp fields (for entities without sequencer)
@@ -12965,6 +12985,13 @@ public sealed class GameWindow : IDisposable
&& playerAE.Sequencer is { } playerSeq)
{
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
+ // R3-W4: bind the player interp's retail seams to the player
+ // sequencer — LeaveGround/HitGround strip transition links here
+ // (the K-fix18 replacement); DefaultSink stays null until R3-W6
+ // (UpdatePlayerAnimation still drives the player's cycles).
+ _playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations;
+ _playerController.Motion.InitializeMotionTables =
+ () => playerSeq.Manager.InitializeState();
}
var q = playerEntity.Rotation;
diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs
index 263be47f..7555fd92 100644
--- a/src/AcDream.Core/Physics/AnimationSequencer.cs
+++ b/src/AcDream.Core/Physics/AnimationSequencer.cs
@@ -40,7 +40,7 @@ public sealed class DatCollectionLoader : IAnimationLoader
// see CSequence.cs/AnimSequenceNode.cs/FrameOps.cs). This adapter keeps every
// public member's signature (the API-migration table in the gap map is the
// contract) and re-expresses each one over the core. Invented mechanisms that
-// are still R2/R3 scope (K-fix18 skipTransitionLink, Fix B locomotion
+// were R2/R3 scope (K-fix18 skipTransitionLink DELETED in R3-W4, Fix B locomotion
// link-skip, stop-anim fallback, GetLink's reversed branch, velocity/omega
// synthesis constants) SURVIVE unchanged at the adapter level per the gap
// map's "Invented behaviors NOT in the gap list" note.
@@ -339,15 +339,7 @@ public sealed class AnimationSequencer
/// MotionCommand style / stance (e.g. NonCombat 0x003D0000).
/// Target motion command (e.g. WalkForward 0x45000005).
/// Speed multiplier applied to framerates (1.0 = normal).
- /// K-fix18 (2026-04-26): when true, do
- /// NOT enqueue the transition-link frames between the previous and
- /// new cycle. Used when the caller wants the new cycle to engage
- /// instantly — e.g. swapping to Falling on a jump start, where the
- /// RunForward→Falling link is a short "stop running" pose that
- /// makes the jump look delayed (legs stand still for ~100 ms while
- /// the link drains, then fold into Falling). Defaults to false to
- /// preserve normal smooth transitions for everything else.
- public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
+ public void SetCycle(uint style, uint motion, float speedMod = 1f)
{
EnsureInitialized();
@@ -394,17 +386,11 @@ public sealed class AnimationSequencer
uint dispatchResult = PerformMovement(
MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed));
- // K-fix18 (2026-04-26, register row survives → R3): instant-engage
- // cases (Falling on jump start) strip the just-selected transition
- // links so the new cycle starts immediately. Post-Q4 expression:
- // the retail primitive CPhysicsObj::RemoveLinkAnimations
- // (0x0050fe20 → CSequence::remove_all_link_animations) — exactly
- // what R3-W4's LeaveGround will call, at which point this flag and
- // its GameWindow call sites are deleted. The pending-queue entry's
- // tick count intentionally stays (retail's RemoveLinkAnimations
- // does not touch the manager queue; the countdown chain absorbs it).
- if (skipTransitionLink)
- _core.RemoveAllLinkAnimations();
+ // R3-W4: the K-fix18 skipTransitionLink flag is DELETED — the
+ // instant-Falling engage is now retail's own mechanism: the entity's
+ // MotionInterpreter.LeaveGround (0x00528b00) fires the
+ // RemoveLinkAnimations seam (bound to this sequencer's
+ // RemoveAllLinkAnimations) when the body leaves the ground.
// Failed dispatch (missing cycle for this style, is_allowed reject):
// retail leaves sequence AND state untouched — no synthesis either.
@@ -446,6 +432,18 @@ public sealed class AnimationSequencer
}
}
+ ///
+ /// R3-W4: the retail link-strip primitive
+ /// (CPhysicsObj::RemoveLinkAnimations 0x0050fe20 →
+ /// CSequence::remove_all_link_animations 0x00524ca0). The App
+ /// binds the entity's MotionInterpreter.RemoveLinkAnimations seam
+ /// here so HitGround/LeaveGround strip pending transition links exactly
+ /// where retail does. The pending-queue tick counts intentionally stay
+ /// (retail's primitive does not touch the manager queue; the countdown
+ /// chain absorbs it).
+ ///
+ public void RemoveAllLinkAnimations() => _core.RemoveAllLinkAnimations();
+
///
/// R2-Q5: run retail's enter_default_state analog now if it
/// hasn't run yet (idempotent). Spawn paths call this so an entity with
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index 96a21236..51740272 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -439,10 +439,12 @@ public interface IWeenieObject
///
/// Retail CWeenieObject::IsCreature. Non-creature weenies bypass
- /// the ground-contact gate in contact_allows_move (0x00528240)
- /// and the run remap in adjust_motion. Players, NPCs, and
+ /// the ground-contact gate in contact_allows_move (0x00528240),
+ /// the run remap in adjust_motion (wired R3-W4, closes J18 —
+ /// register TS-34 retired), and the /
+ /// creature gates. Players, NPCs, and
/// monsters are creatures — default true keeps existing implementers
- /// retail-correct (register TS-34 tracks the adjust_motion side).
+ /// retail-correct.
///
bool IsCreature() => true;
@@ -639,6 +641,76 @@ public sealed class MotionInterpreter : IMotionDoneSink
///
public Action? InterruptCurrentMovement { get; set; }
+ ///
+ /// R3-W4 seam standing in for retail CPhysicsObj::RemoveLinkAnimations
+ /// (0x0050fe20, decomp §9 summary table) → CPartArray →
+ /// CSequence::remove_all_link_animations — called from
+ /// / (§4a/4b) whenever
+ /// the ground-transition body actually runs. This is retail's REAL
+ /// mechanism for "leaving the ground strips any pending link
+ /// animation so Falling engages instantly" — the K-fix18 App-side
+ /// forced-SetCycle(skipTransitionLink:true) hack this seam
+ /// replaces is deleted in the orchestrator's App-side half (register
+ /// row retirement rides with that deletion, not this commit — Core
+ /// has no K-fix18 code to remove). The App layer binds this to the
+ /// entity's AnimationSequencer/CSequence core
+ /// (RemoveAllLinkAnimations-equivalent) per entity, matching the
+ /// existing /
+ /// Action? seam convention. Optional — a null binding is a
+ /// silent no-op (safe default for tests and for any physics body with
+ /// no sequencer attached yet).
+ ///
+ public Action? RemoveLinkAnimations { get; set; }
+
+ ///
+ /// R3-W4 no-op seam standing in for retail
+ /// CPhysicsObj::InitializeMotionTables, called from
+ /// (§4d, raw @306124:
+ /// CPhysicsObj::InitializeMotionTables(this->physics_obj);).
+ /// This re-seeds the physics object's MotionTableManager/motion
+ /// table stack — a manager-side (App-bound) concern, not something
+ /// MotionInterpreter itself owns state for. Register row: no-op
+ /// until the App layer binds it to the entity's motion-table
+ /// initialization; matches the seam
+ /// convention.
+ ///
+ public Action? InitializeMotionTables { get; set; }
+
+ ///
+ /// R3-W4 — retail CMotionInterp::initted (set by
+ /// , raw @306152: this->initted =
+ /// 1;). Gates and
+ /// 's entry (A3: physics_obj != 0
+ /// && initted != 0).
+ ///
+ ///
+ /// Constructor default is true, NOT retail's false
+ /// (see the final-report note on this divergence). Retail's
+ /// CMotionInterp is never used standalone — every real
+ /// construction path (MovementManager::PerformMovement's
+ /// lazy-create, MovementManager::Create) immediately calls
+ /// (retail's enter_default_state)
+ /// before the interpreter is exposed to any caller, so initted
+ /// is always 1 by the time application code can observe it.
+ /// acdream's MotionInterpreter constructor is used directly by
+ /// ~40 pre-existing tests AND by both App call sites
+ /// (PlayerMovementController, GameWindow) as a
+ /// complete, immediately-usable object with no separate
+ /// "enter default state" step — porting retail's literal
+ /// false default would silently no-op every one of those
+ /// call sites' apply_current_movement/ReportExhaustion
+ /// calls until something remembers to call
+ /// (which nothing currently does).
+ /// Defaulting true here is the C# equivalent of "the
+ /// constructor already did what enter_default_state would have
+ /// done to this flag" — remains the
+ /// verbatim, fully-testable retail method for callers (tests, and a
+ /// future App reset-to-idle path) that need the REST of its behavior
+ /// (state reset, sentinel enqueue, tail).
+ ///
+ ///
+ public bool Initted { get; set; } = true;
+
///
/// Optional accessor for the owning entity's current animation cycle
/// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
@@ -1038,12 +1110,12 @@ public sealed class MotionInterpreter : IMotionDoneSink
///
///
///
- /// Creature guard (retail weenie.IsCreature()) is UNWIRED.
- /// does not currently expose an
- /// IsCreature query, so this port always proceeds (equivalent to
- /// "no weenie" or "is a creature" — true for the player, which is the
- /// only caller as of D6.1). Flagged for D6.2 / a future IWeenieObject
- /// extension; do not fabricate a guess at the query here.
+ /// R3-W4 (closes J18, retires register TS-34): creature guard WIRED.
+ /// Retail (raw @305343): if (weenie != 0 && weenie->IsCreature() == 0)
+ /// return; — a weenie present whose IsCreature() returns
+ /// false short-circuits the ENTIRE function (no normalization, no
+ /// hold-key path). has existed
+ /// since W3; this was the one caller left un-gated.
///
///
/// Raw motion command, normalized in place.
@@ -1055,7 +1127,8 @@ public sealed class MotionInterpreter : IMotionDoneSink
///
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
- // Creature guard unwired — IWeenieObject has no IsCreature(); always proceed.
+ if (WeenieObj is not null && !WeenieObj.IsCreature())
+ return;
switch (motion)
{
@@ -1187,26 +1260,145 @@ public sealed class MotionInterpreter : IMotionDoneSink
InterpretedState.TurnCommand = tCmd; InterpretedState.TurnSpeed = tSpd;
}
- // ── FUN_00529210 — apply_current_movement ─────────────────────────────────
+ // ── FUN_00528870 — apply_current_movement ─────────────────────────────────
///
- /// Apply the current interpreted motion state as a local velocity to the PhysicsBody.
+ /// CMotionInterp::apply_current_movement (0x00528870, decomp §4e,
+ /// W0-pins.md A3, FULL BODY dual dispatch — replaces the pre-W4
+ /// direct-velocity approximation).
///
- /// Decompiled logic (FUN_00529210):
- /// if PhysicsObj == null: return
- /// FUN_00524f80() — internal animation state update
- /// If ForwardCommand == RunForward: update MyRunRate = ForwardSpeed
- /// Then delegates to DoInterpretedMotion for each active command,
- /// which ultimately calls set_local_velocity via FUN_00528960.
+ ///
+ /// Verbatim (raw 305838-305857):
+ ///
+ /// if (physics_obj != 0 && initted != 0) {
+ /// if (weenie_obj != 0) eax_2 = weenie_obj->IsThePlayer();
+ /// if ((weenie_obj == 0 || eax_2 != 0)
+ /// && movement_is_autonomous(physics_obj) != 0) {
+ /// apply_raw_movement(this, arg2, arg3);
+ /// return;
+ /// }
+ /// apply_interpreted_movement(this, arg2, arg3);
+ /// }
+ ///
+ ///
///
- /// In our physics-only port we compute the body-local velocity via
- /// get_state_velocity() and push it directly to PhysicsBody.set_local_velocity.
+ ///
+ /// A3: the dispatch gate is IsThePlayer (no weenie = treat as
+ /// the player), NOT IsCreature — a remote player weenie
+ /// (IsThePlayer()==false, IsCreature()==true) routes
+ /// INTERPRETED even when movement_is_autonomous is true. This is
+ /// the genuine divergence from ACE's server-side IsCreature gate
+ /// (ACE MotionInterp.cs:430-438) — do not copy.
+ ///
///
public void apply_current_movement(bool cancelMoveTo, bool allowJump)
+ {
+ if (PhysicsObj is null || !Initted)
+ return;
+
+ bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
+ if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
+ {
+ apply_raw_movement(cancelMoveTo, allowJump);
+ return;
+ }
+
+ ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump);
+ }
+
+ ///
+ /// CMotionInterp::apply_raw_movement (0x005287e0, decomp §4e
+ /// referenced body, raw 305817-305834), the (cancelMoveTo,
+ /// allowJump)-arg overload reached from
+ /// 's dual dispatch (A3's
+ /// "IsThePlayer + autonomous" branch), ,
+ /// , and .
+ ///
+ ///
+ /// Verbatim: copy the SEVEN raw-state fields (style + all three
+ /// axis command/speed pairs) into ,
+ /// normalize each of the three axes IN PLACE via
+ /// using THAT axis's own hold key, then
+ /// tail-call apply_interpreted_movement(this, arg2, arg3) — the
+ /// SAME retail function (0x00528600) as
+ /// (the funnel producer). This is NOT a new function: it is the
+ /// existing overload's
+ /// body (the D6.2a raw-state-orchestrator port), reached here with
+ /// itself as the source instead of an externally
+ /// supplied snapshot, plus the previously-missing
+ /// apply_interpreted_movement(arg2, arg3) tail call this
+ /// dispatch site needs.
+ ///
+ ///
+ public void apply_raw_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj is null)
return;
+ apply_raw_movement(RawState);
+ ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump);
+ }
+
+ ///
+ /// The retail apply_interpreted_movement tail reached from BOTH
+ /// dual-dispatch branches of (raw
+ /// 305855/305832 — CMotionInterp::apply_interpreted_movement,
+ /// 0x00528600, the SAME function as the funnel's
+ /// , per the raw's identical call
+ /// target at every one of its six call sites).
+ ///
+ ///
+ /// Physics-only-port body (pre-W4 approximation, SURVIVES per the
+ /// plan's "less invasive" choice — see the final-report note): this
+ /// port has no wired at this call
+ /// site (retail's real body drives DoInterpretedMotion/
+ /// StopInterpretedMotion through the animation-table backend,
+ /// exactly like already does for
+ /// the INBOUND funnel path — but that path always has a sink available
+ /// from its caller, while apply_current_movement's callers
+ /// (//
+ /// /hold-key toggles/jump) do not thread
+ /// one through). Kept as the pre-existing direct
+ /// get_state_velocity → grounded set_local_velocity write
+ /// — an acdream adaptation of root-motion-driven velocity, register row
+ /// added same commit — until R6 (root motion drives the body directly
+ /// and this local-only fallback retires in favor of a real sink here
+ /// too).
+ ///
+ ///
+ ///
+ /// R3-W4 (App-bound): the entity's persistent animation-dispatch sink.
+ /// When set, routes the
+ /// REAL (retail
+ /// apply_interpreted_movement 0x00528600 — the same function the
+ /// inbound funnel drives), so HitGround/LeaveGround/hold-key re-applies
+ /// dispatch cycles (the airborne-Falling engage) through the
+ /// motion-table backend exactly as retail does. Null (the local player
+ /// until R3-W6, interp-less entities) → the AP-77 physics-only tail.
+ ///
+ public IInterpretedMotionSink? DefaultSink { get; set; }
+
+ private void ApplyCurrentMovementInterpreted(bool cancelMoveTo, bool allowJump)
+ {
+ if (PhysicsObj is null)
+ return;
+
+ // R3-W4: with a persistent sink bound (remotes), this IS retail's
+ // apply_interpreted_movement — full dispatch through the funnel
+ // body (style → forward-or-Falling → sidestep → turn), which is
+ // what makes LeaveGround engage Falling without the deleted
+ // K-fix18 forced-SetCycle. Without a sink, fall through to the
+ // AP-77 physics-only adaptation below.
+ if (DefaultSink is not null)
+ {
+ ApplyInterpretedMovement(InterpretedState.CurrentStyle, DefaultSink,
+ cancelMoveTo, allowJump);
+ return;
+ }
+
+ _ = cancelMoveTo;
+ _ = allowJump;
+
// Decompile writes back MyRunRate when in run state (offset +0x7C).
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;
@@ -1220,10 +1412,159 @@ public sealed class MotionInterpreter : IMotionDoneSink
if (PhysicsObj.OnWalkable)
{
var localVelocity = get_state_velocity();
- PhysicsObj.set_local_velocity(localVelocity);
+ // AP-77: this adaptation's fallback write has no real opinion on
+ // "was this move autonomous" (retail's real set_local_velocity
+ // call for this dispatch path lives deep inside the animation
+ // backend this stand-in doesn't have — see the seam's doc
+ // comment). Preserve whatever LastMoveWasAutonomous already
+ // holds rather than resetting it, so a caller like LeaveGround
+ // (which just set it true via its OWN direct set_local_velocity
+ // call, autonomous=1 per raw @305763-305765) isn't clobbered by
+ // this immediately-following re-sync tail call.
+ PhysicsObj.set_local_velocity(localVelocity, PhysicsObj.LastMoveWasAutonomous);
}
}
+ // ── FUN_005288d0 — ReportExhaustion ────────────────────────────────────────
+
+ ///
+ /// CMotionInterp::ReportExhaustion (0x005288d0, decomp §4c
+ /// @305861, FULL BODY, W0-pins.md A3). Identical entry gate and
+ /// dual-dispatch predicate to ,
+ /// with HARDCODED (0, 0) dispatch args (raw 305874/305878) —
+ /// the retail caller (CPhysicsObj::report_exhaustion 0x0050fdd0
+ /// → MovementManager::ReportExhaustion 0x00524360) passes no
+ /// per-call parameters through.
+ ///
+ ///
+ /// Verbatim (raw 305861-305880):
+ ///
+ /// if (physics_obj != 0 && initted != 0) {
+ /// if (weenie_obj != 0) eax_2 = weenie_obj->IsThePlayer();
+ /// if ((weenie_obj == 0 || eax_2 != 0)
+ /// && movement_is_autonomous(physics_obj) != 0) {
+ /// apply_raw_movement(this, 0, 0);
+ /// return;
+ /// }
+ /// apply_interpreted_movement(this, 0, 0);
+ /// }
+ ///
+ ///
+ ///
+ public void ReportExhaustion()
+ {
+ if (PhysicsObj is null || !Initted)
+ return;
+
+ bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
+ if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
+ {
+ apply_raw_movement(cancelMoveTo: false, allowJump: false);
+ return;
+ }
+
+ ApplyCurrentMovementInterpreted(cancelMoveTo: false, allowJump: false);
+ }
+
+ // ── FUN_00528920 / FUN_00528970 — SetWeenieObject / SetPhysicsObject ───────
+
+ ///
+ /// CMotionInterp::SetWeenieObject (0x00528920, decomp §4f
+ /// @305884, FULL BODY, W0-pins.md A3). Assigns ,
+ /// then — if already physics-bound AND — re-applies
+ /// movement via the SAME dual-dispatch predicate as
+ /// , EXCEPT the weenie tested for
+ /// IsThePlayer is the INCOMING (not
+ /// post-assignment — they're the same value
+ /// here, but retail's raw makes the incoming-arg read explicit: a null
+ /// incoming weenie takes the SAME branch as a non-null-but-IsThePlayer
+ /// weenie, both via goto label_528946). Dispatch args are
+ /// hardcoded (1, 0).
+ ///
+ ///
+ /// Verbatim (raw 305884-305907):
+ ///
+ /// bool noPhysics = (physics_obj == 0);
+ /// weenie_obj = arg2;
+ /// if (!noPhysics && initted != 0) {
+ /// if (arg2 == 0) {
+ /// label_528946:
+ /// if (movement_is_autonomous(physics_obj) != 0) {
+ /// apply_raw_movement(this, 1, 0);
+ /// return;
+ /// }
+ /// } else if (arg2->IsThePlayer() != 0)
+ /// goto label_528946;
+ /// apply_interpreted_movement(this, 1, 0);
+ /// }
+ ///
+ ///
+ ///
+ public void SetWeenieObject(IWeenieObject? weenie)
+ {
+ WeenieObj = weenie;
+
+ if (PhysicsObj is null || !Initted)
+ return;
+
+ bool isThePlayer = weenie is null || weenie.IsThePlayer();
+ if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
+ {
+ apply_raw_movement(cancelMoveTo: true, allowJump: false);
+ return;
+ }
+
+ ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false);
+ }
+
+ ///
+ /// CMotionInterp::SetPhysicsObject (0x00528970, decomp §4g
+ /// @305911, FULL BODY, W0-pins.md A3). Assigns
+ /// unconditionally FIRST, then — if the incoming
+ /// is non-null AND —
+ /// re-applies movement via the standard dual-dispatch predicate (this
+ /// time reading , unchanged by this call).
+ /// Dispatch args are hardcoded (1, 0), matching
+ /// .
+ ///
+ ///
+ /// Verbatim (raw 305911-305932):
+ ///
+ /// this->physics_obj = arg2;
+ /// if (arg2 != 0 && initted != 0) {
+ /// if (weenie_obj != 0) eax_1 = weenie_obj->IsThePlayer();
+ /// if ((weenie_obj == 0 || eax_1 != 0)
+ /// && movement_is_autonomous(physics_obj) != 0) {
+ /// apply_raw_movement(this, 1, 0);
+ /// return;
+ /// }
+ /// apply_interpreted_movement(this, 1, 0);
+ /// }
+ ///
+ /// Note the entry gate is on the INCOMING arg2 (not the
+ /// just-assigned this->physics_obj — same value, but the raw
+ /// tests the parameter register directly), unlike
+ /// 's entry gate which tests the
+ /// PRE-assignment physics_obj snapshot (noPhysics).
+ ///
+ ///
+ public void SetPhysicsObject(PhysicsBody? physicsObj)
+ {
+ PhysicsObj = physicsObj;
+
+ if (physicsObj is null || !Initted)
+ return;
+
+ bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
+ if (isThePlayer && physicsObj.LastMoveWasAutonomous)
+ {
+ apply_raw_movement(cancelMoveTo: true, allowJump: false);
+ return;
+ }
+
+ ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false);
+ }
+
// ── FUN_00527a50 — jump_charge_is_allowed ─────────────────────────────────
///
@@ -1832,51 +2173,245 @@ public sealed class MotionInterpreter : IMotionDoneSink
return WeenieError.YouCantJumpFromThisPosition;
}
- // ── FUN_00529710 — LeaveGround ────────────────────────────────────────────
+ // ── FUN_00528b00 — LeaveGround ────────────────────────────────────────────
///
- /// Called when the body becomes airborne. Applies the leave-ground velocity
- /// and resets the jump state.
+ /// CMotionInterp::LeaveGround (0x00528b00, decomp §4b @306022,
+ /// FULL BODY — replaces the pre-W4 approximation, closes J8's
+ /// LeaveGround leg). Called when the body becomes airborne (also
+ /// tail-called unconditionally from ,
+ /// §4d).
///
- /// Decompiled logic (FUN_00529710):
- /// if PhysicsObj == null: return
- /// velocity = GetLeaveGroundVelocity()
- /// PhysicsObj.set_local_velocity(velocity)
- /// StandingLongJump = false
- /// JumpExtent = 0
+ ///
+ /// Verbatim (raw 306022-306040, quoted in full in the final-report):
+ /// creature gate (weenie_obj == 0 || IsCreature() != 0) AND
+ /// state-0x400 (Gravity) gate — SAME shape as .
+ /// When both pass: compute the launch velocity via
+ /// , apply it with
+ /// set_local_velocity(&v, autonomous=1) (the literal
+ /// 1 arg —
+ /// becomes true), reset and
+ /// to their zero/false defaults (the jump
+ /// charge is consumed the instant you actually leave the ground), then
+ /// + apply_current_movement(0,
+ /// 0) (the same re-sync performs).
+ ///
///
public void LeaveGround()
{
if (PhysicsObj is null)
return;
+ bool isCreature = WeenieObj is null || WeenieObj.IsCreature();
+ if (!isCreature)
+ return;
+
+ if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
+ return;
+
var velocity = GetLeaveGroundVelocity();
- PhysicsObj.set_local_velocity(velocity);
+ PhysicsObj.set_local_velocity(velocity, autonomous: true);
StandingLongJump = false;
JumpExtent = 0f;
+
+ RemoveLinkAnimations?.Invoke();
+ apply_current_movement(cancelMoveTo: false, allowJump: false);
}
- // ── FUN_005296d0 — HitGround ──────────────────────────────────────────────
+ // ── FUN_00528ac0 — HitGround ──────────────────────────────────────────────
///
- /// Called when the body lands on a walkable surface.
+ /// CMotionInterp::HitGround (0x00528ac0, decomp §4a @305996,
+ /// FULL BODY — replaces the pre-W4 approximation, closes J8's
+ /// HitGround leg). Called when the body lands on a walkable surface.
///
- /// Decompiled logic (FUN_005296d0):
- /// if PhysicsObj == null: return
- /// if WeenieObj != null AND NOT creature: return
- /// if Gravity flag not set: return
- /// apply_current_movement(false, true)
+ ///
+ /// Verbatim (raw 305996-306014):
+ ///
+ /// if (physics_obj != 0) {
+ /// if (weenie_obj != 0) eax_2 = weenie_obj->IsCreature();
+ /// if (weenie_obj == 0 || eax_2 != 0) {
+ /// if (physics_obj != 0 && (state & 0x400) != 0) {
+ /// RemoveLinkAnimations(physics_obj);
+ /// apply_current_movement(this, 0, 0);
+ /// }
+ /// }
+ /// }
+ ///
+ ///
+ ///
+ ///
+ /// A3 note: this creature gate uses IsCreature (vtable +0x2c),
+ /// NOT IsThePlayer (+0x14) — the anti-artifact proof for A3's
+ /// pin (BN distinguishes the two slots locally, ~0x250 bytes from the
+ /// IsThePlayer-gated functions).
+ ///
///
public void HitGround()
{
if (PhysicsObj is null)
return;
+ bool isCreature = WeenieObj is null || WeenieObj.IsCreature();
+ if (!isCreature)
+ return;
+
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
return;
- apply_current_movement(cancelMoveTo: false, allowJump: true);
+ RemoveLinkAnimations?.Invoke();
+ apply_current_movement(cancelMoveTo: false, allowJump: false);
+ }
+
+ // ── FUN_00528c80 — enter_default_state ────────────────────────────────────
+
+ ///
+ /// CMotionInterp::enter_default_state (0x00528c80, decomp §4d
+ /// @306124, FULL BODY, W0-pins.md A8, closes J10). Resets both motion
+ /// states to fresh defaults, re-initializes the physics object's
+ /// motion tables, APPENDS the canonical {0, Ready, 0} sentinel
+ /// to (NO drain — pre-existing queued
+ /// nodes survive, A8), marks , then tail-calls
+ /// unconditionally.
+ ///
+ ///
+ /// Verbatim (raw 306124-306154):
+ ///
+ /// raw_state = RawMotionState(); // fresh ctor defaults
+ /// interpreted_state = InterpretedMotionState(); // fresh ctor defaults
+ /// CPhysicsObj::InitializeMotionTables(physics_obj);
+ /// node = new MotionNode { context_id=0, motion=0x41000003, jump_error_code=0 };
+ /// pending_motions.append(node); // tail-splice, NO clear
+ /// initted = 1;
+ /// LeaveGround(this);
+ ///
+ ///
+ ///
+ ///
+ /// Retail's real construction paths (MovementManager::Create +
+ /// the lazy-create guard at every MovementManager entry point)
+ /// call this exactly once (or twice on the lazy-create double-call —
+ /// §6g, genuine retail, not "fixed") before exposing the interpreter to
+ /// any caller. acdream's constructors default to
+ /// true directly (see that property's doc comment) rather than
+ /// routing every construction through this method, so this port is the
+ /// verbatim, fully-testable "reset to default/idle state" operation —
+ /// callers that want retail's FULL reset semantics (state defaults +
+ /// sentinel + LeaveGround tail), not just the Initted flag, call
+ /// this explicitly.
+ ///
+ ///
+ public void EnterDefaultState()
+ {
+ RawState = new RawMotionState();
+ InterpretedState = InterpretedMotionState.Default();
+
+ InitializeMotionTables?.Invoke();
+
+ AddToQueue(contextId: 0, MotionCommand.Ready, jumpErrorCode: 0);
+
+ Initted = true;
+
+ LeaveGround();
+ }
+
+ // ── FUN_00528b70 / FUN_00528bb0 — set_hold_run / SetHoldKey ────────────────
+
+ ///
+ /// CMotionInterp::set_hold_run (0x00528b70, decomp §5c @306053,
+ /// FULL BODY, closes J13's set_hold_run leg). XOR toggle guard: only
+ /// acts when actually FLIPS the current
+ /// effective hold-run state (skip redundant re-application).
+ ///
+ ///
+ /// Verbatim (raw 306053-306070):
+ ///
+ /// eax = (arg2 == 0); // "run key is up"
+ /// edx = (raw_state.current_holdkey != Run);
+ /// if (eax != edx) { // XOR: state actually changes
+ /// raw_state.current_holdkey = (arg2 != 0) + 1; // false->1(None), true->2(Run)
+ /// apply_current_movement(this, arg3, 0);
+ /// }
+ ///
+ ///
+ ///
+ /// Retail arg2 — nonzero = the run key
+ /// is currently held down.
+ /// Retail arg3 — passed through as
+ /// apply_current_movement's cancelMoveTo arg.
+ public void set_hold_run(bool holdingRun, bool interrupt)
+ {
+ bool runKeyUp = !holdingRun;
+ bool notCurrentlyRun = RawState.CurrentHoldKey != HoldKey.Run;
+
+ if (runKeyUp != notCurrentlyRun)
+ {
+ RawState.CurrentHoldKey = holdingRun ? HoldKey.Run : HoldKey.None;
+ apply_current_movement(cancelMoveTo: interrupt, allowJump: false);
+ }
+ }
+
+ ///
+ /// CMotionInterp::SetHoldKey (0x00528bb0, decomp §5d @306072,
+ /// FULL BODY, closes J13's SetHoldKey leg). No-op if
+ /// already equals .
+ /// Setting only takes effect (and
+ /// re-applies movement) if the CURRENT hold key is
+ /// — requesting None while already something
+ /// else (e.g. Invalid) is silently ignored. Setting
+ /// takes effect whenever the current key
+ /// isn't already Run.
+ ///
+ ///
+ /// Verbatim (raw 306072-306095):
+ ///
+ /// current = raw_state.current_holdkey;
+ /// if (arg2 != current) {
+ /// if (arg2 == HoldKey_None) {
+ /// if (current == HoldKey_Run) {
+ /// raw_state.current_holdkey = HoldKey_None;
+ /// apply_current_movement(this, arg3, 0);
+ /// }
+ /// } else if (arg2 == HoldKey_Run && current != HoldKey_Run) {
+ /// raw_state.current_holdkey = HoldKey_Run;
+ /// apply_current_movement(this, arg3, 0);
+ /// }
+ /// }
+ ///
+ ///
+ ///
+ ///
+ /// This is the function DoMotion (W5) calls when bit
+ /// 0x800 (SetHoldKey) of the incoming MovementParameters
+ /// requests a hold-key change, and what
+ /// reads back via (mirrored
+ /// onto — see the field doc) to decide
+ /// whether fires.
+ ///
+ ///
+ /// Retail arg2.
+ /// Retail arg3 — passed through as
+ /// apply_current_movement's cancelMoveTo arg.
+ public void SetHoldKey(HoldKey key, bool cancelMoveTo)
+ {
+ HoldKey current = RawState.CurrentHoldKey;
+ if (key == current)
+ return;
+
+ if (key == HoldKey.None)
+ {
+ if (current == HoldKey.Run)
+ {
+ RawState.CurrentHoldKey = HoldKey.None;
+ apply_current_movement(cancelMoveTo, allowJump: false);
+ }
+ }
+ else if (key == HoldKey.Run && current != HoldKey.Run)
+ {
+ RawState.CurrentHoldKey = HoldKey.Run;
+ apply_current_movement(cancelMoveTo, allowJump: false);
+ }
}
// ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
@@ -1990,6 +2525,25 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// every axis overwritten, defaults included), re-apply the whole
/// movement through the sink, then replay fresh actions under the
/// 15-bit stamp gate.
+ ///
+ ///
+ /// R3-W4 (Adjacent finding, W0-pins.md): raw 305946-305949
+ /// computes eax_2 = motion_allows_jump(this->interpreted_state.forward_command)
+ /// — the OLD forward command, BEFORE copy_movement_from
+ /// overwrites two lines later — then
+ /// calls apply_current_movement(this, 1, allowJump) where
+ /// allowJump = (eax_2 == 0) (the raw's own
+ /// -((esi_1 - esi_1)) is decompiler noise for that same
+ /// boolean). Ported here as on the
+ /// PRE-overwrite .ForwardCommand,
+ /// snapshotted before the flat-copy below. Passed through to
+ /// per its TODO(W5) — this
+ /// funnel calls the interpreted body directly (not the
+ /// dual-dispatch gate), matching
+ /// the existing S2 architecture: inbound funnel state is always
+ /// server-authoritative, so it always wants the interpreted path
+ /// regardless of IsThePlayer/autonomous.
+ ///
///
public int MoveToInterpretedState(in InboundInterpretedState ims, IInterpretedMotionSink? sink = null)
{
@@ -1997,6 +2551,10 @@ public sealed class MotionInterpreter : IMotionDoneSink
RawState.CurrentStyle = ims.CurrentStyle;
+ // motion_allows_jump on the OLD forward_command, BEFORE the
+ // flat-copy below overwrites it (Adjacent finding).
+ bool allowJump = MotionAllowsJump(InterpretedState.ForwardCommand) == WeenieError.None;
+
// copy_movement_from — flat overwrite, no per-field presence checks.
InterpretedState.ForwardCommand = ims.ForwardCommand;
InterpretedState.ForwardSpeed = ims.ForwardSpeed;
@@ -2005,7 +2563,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
InterpretedState.TurnCommand = ims.TurnCommand;
InterpretedState.TurnSpeed = ims.TurnSpeed;
- ApplyInterpretedMovement(ims.CurrentStyle, sink);
+ ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);
if (ims.Actions is { } actions)
{
@@ -2053,11 +2611,39 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// node's jump_error_code — since the stop succeeded, that value
/// is 0.
///
+ ///
+ ///
+ /// R3-W4 (closes J11's param-plumbing leg): /
+ /// are retail's arg2/arg3 —
+ /// threaded through NOW per the plan (every real caller already has an
+ /// opinion: 's dual-dispatch tail
+ /// passes its own args through unchanged;
+ /// passes (true, motion_allows_jump(OLD forward_command) == 0) per
+ /// the Adjacent finding). Their ONLY retail consumer in this function is
+ /// the garbled tail expression at raw 305778
+ /// ((((arg3&1)<<2)|(arg2&1))<<0xf feeding an
+ /// uninitialized-local byte test before conditionally calling
+ /// InterpretedMotionState::RemoveMotion(0x6500000d) a SECOND time)
+ /// — a BN x87/uninit-local artifact class, not a readable retail
+ /// algorithm. TODO(W5): resolve that tail against a clean decompile (or
+ /// cdb capture) once MovementParameters flows through this
+ /// funnel end to end; until then the params are accepted and preserved
+ /// for signature parity but do not change this function's control flow
+ /// (matches the funnel's existing "no MovementParameters yet" posture,
+ /// e.g. 's own TODO(W5)).
+ ///
///
- public void ApplyInterpretedMovement(uint currentStyle, IInterpretedMotionSink? sink)
+ public void ApplyInterpretedMovement(
+ uint currentStyle, IInterpretedMotionSink? sink,
+ bool cancelMoveTo = false, bool allowJump = false)
{
if (PhysicsObj is null) return;
+ // cancelMoveTo/allowJump: accepted for signature parity (see the
+ // TODO(W5) above) — not yet consumed by this function's body.
+ _ = cancelMoveTo;
+ _ = allowJump;
+
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index 1275bda4..0608482f 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -245,6 +245,24 @@ public sealed class PhysicsBody
///
public bool IsFullyConstrained { get; set; }
+ ///
+ /// R3-W4 — retail CPhysicsObj::last_move_was_autonomous, read by
+ /// CPhysicsObj::movement_is_autonomous (0x0050eb30, decomp §7a
+ /// @276443: return this->last_move_was_autonomous;). Gates the
+ /// A3 dual-dispatch predicate in MotionInterpreter.apply_current_movement/
+ /// ReportExhaustion/SetWeenieObject/SetPhysicsObject:
+ /// true means the last motion on this body was locally-simulated
+ /// (player input / local prediction), false means it was a
+ /// server-driven dead-reckoning update. Set true at the local-player
+ /// input chokepoint (App layer — PlayerMovementController);
+ /// left false (the safe default — routes to
+ /// apply_interpreted_movement) for DR-applied remote updates.
+ /// also sets this true
+ /// itself: retail's set_local_velocity(&var_c, 1) call passes
+ /// the autonomous flag literal 1 (raw @305763-305765).
+ ///
+ public bool LastMoveWasAutonomous { get; set; }
+
// ── convenience helpers ────────────────────────────────────────────────
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity);
@@ -324,10 +342,23 @@ public sealed class PhysicsBody
/// worldY = col0.y*localX + col1.y*localY + col2.y*localZ
/// worldZ = col0.z*localX + col1.z*localY + col2.z*localZ
/// We replicate this as a Quaternion rotation, which is equivalent.
+ ///
+ ///
+ /// R3-W4: retail's set_local_velocity takes a second
+ /// autonomous arg (CPhysicsObj::set_local_velocity,
+ /// stores it to — read by
+ /// CPhysicsObj::movement_is_autonomous, the A3 dual-dispatch
+ /// predicate). Defaults to false to preserve every pre-W4 call
+ /// site's behavior (server/interpreted-driven callers never asserted
+ /// autonomy); is the one
+ /// caller that passes true (raw @305763-305765,
+ /// set_local_velocity(&var_c, 1)).
+ ///
///
- public void set_local_velocity(Vector3 localVelocity)
+ public void set_local_velocity(Vector3 localVelocity, bool autonomous = false)
{
var worldVelocity = Vector3.Transform(localVelocity, Orientation);
+ LastMoveWasAutonomous = autonomous;
set_velocity(worldVelocity);
}
diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
index 88dc0ba3..1af00c11 100644
--- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
@@ -335,12 +335,20 @@ public sealed class AnimationSequencerCutoverTraceTests
}
[Fact]
- public void S8_KFix18_FallingSkipsLink()
+ public void S8_LeaveGroundLinkStrip_FallingEngagesInstantly()
{
+ // EXPECTED-DIFF(R3-W4): the K-fix18 skipTransitionLink flag is
+ // DELETED. The instant-Falling engage is retail's own mechanism:
+ // MotionInterpreter.LeaveGround (0x00528b00) fires the
+ // RemoveLinkAnimations seam — bound to this sequencer's
+ // RemoveAllLinkAnimations (CPhysicsObj::RemoveLinkAnimations
+ // 0x0050fe20 → CSequence::remove_all_link_animations) — after the
+ // Falling dispatch. Same final state the flag produced.
var seq = NewSeq(out var loader);
seq.SetCycle(NC, Ready);
seq.SetCycle(NC, Walk, 1.0f);
- seq.SetCycle(NC, Falling, 1.0f, skipTransitionLink: true);
+ seq.SetCycle(NC, Falling, 1.0f);
+ seq.RemoveAllLinkAnimations(); // = LeaveGround's seam firing
Assert.Equal(
"10C@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=40000015 mod=1.00",
Describe(seq, loader));
diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterGroundLifecycleTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterGroundLifecycleTests.cs
new file mode 100644
index 00000000..3836fed0
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterGroundLifecycleTests.cs
@@ -0,0 +1,784 @@
+using System.Linq;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MotionInterpreterGroundLifecycleTests — R3-W4 (closes J8, J10, J11-shape,
+// J12, J13; J18 one-liner rides along).
+//
+// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md
+// §4 (HitGround 0x00528ac0 @305996, LeaveGround 0x00528b00 @306022,
+// ReportExhaustion 0x005288d0 @305861, enter_default_state 0x00528c80
+// @306124) + §5c/5d (set_hold_run 0x00528b70 @306053, SetHoldKey 0x00528bb0
+// @306072) + raw acclient_2013_pseudo_c.txt:305838-305932 (apply_current_movement
+// 0x00528870, SetWeenieObject 0x00528920, SetPhysicsObject 0x00528970) +
+// W0-pins.md A3 (dual-dispatch gate) / A8 (enter_default_state no-clear).
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Fake WeenieObject — IsThePlayer/IsCreature independently
+/// configurable, for the A3 dispatch truth table.
+file sealed class FakeWeenie : IWeenieObject
+{
+ public bool IsThePlayerResult;
+ public bool IsCreatureResult = true;
+
+ public bool InqJumpVelocity(float extent, out float vz) { vz = 10f; return true; }
+ public bool InqRunRate(out float rate) { rate = 1f; return true; }
+ public bool CanJump(float extent) => true;
+ public bool IsThePlayer() => IsThePlayerResult;
+ public bool IsCreature() => IsCreatureResult;
+}
+
+public sealed class MotionInterpreterGroundLifecycleTests
+{
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ private static PhysicsBody MakeGrounded()
+ {
+ var body = new PhysicsBody
+ {
+ State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
+ };
+ body.TransientState = TransientStateFlags.Contact
+ | TransientStateFlags.OnWalkable
+ | TransientStateFlags.Active;
+ return body;
+ }
+
+ private static PhysicsBody MakeAirborne()
+ {
+ var body = new PhysicsBody
+ {
+ State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
+ };
+ body.TransientState = TransientStateFlags.Active;
+ return body;
+ }
+
+ private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null)
+ {
+ body ??= MakeGrounded();
+ return new MotionInterpreter(body, weenie);
+ }
+
+ // =========================================================================
+ // HitGround — 0x00528ac0 @305996
+ // =========================================================================
+
+ [Fact]
+ public void HitGround_NoPhysicsObj_NoOp()
+ {
+ var interp = new MotionInterpreter();
+ bool called = false;
+ interp.RemoveLinkAnimations = () => called = true;
+
+ interp.HitGround();
+
+ Assert.False(called);
+ }
+
+ [Fact]
+ public void HitGround_NonCreatureWeenie_NoOp()
+ {
+ // raw 305720-305724: (weenie_obj == 0 || eax_2 != 0) -- a WEENIE
+ // present whose IsCreature() returns false skips the whole body.
+ var weenie = new FakeWeenie { IsCreatureResult = false };
+ var body = MakeGrounded();
+ var interp = MakeInterp(body, weenie);
+ bool called = false;
+ interp.RemoveLinkAnimations = () => called = true;
+
+ interp.HitGround();
+
+ Assert.False(called, "a non-creature weenie must skip HitGround's body entirely");
+ }
+
+ [Fact]
+ public void HitGround_NoWeenie_Proceeds()
+ {
+ // weenie_obj == 0 -> the OR short-circuits true, body proceeds.
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ bool called = false;
+ interp.RemoveLinkAnimations = () => called = true;
+
+ interp.HitGround();
+
+ Assert.True(called, "no weenie_obj must still proceed (weenie==0 half of the OR)");
+ }
+
+ [Fact]
+ public void HitGround_GravityFlagClear_NoOp()
+ {
+ // raw 305726-305730: state bit 0x400 (Gravity) gates the body.
+ var body = MakeGrounded();
+ body.State &= ~PhysicsStateFlags.Gravity;
+ var interp = MakeInterp(body);
+ bool called = false;
+ interp.RemoveLinkAnimations = () => called = true;
+
+ interp.HitGround();
+
+ Assert.False(called, "HitGround must no-op when the Gravity state flag is clear");
+ }
+
+ [Fact]
+ public void HitGround_CreatureWithGravity_CallsRemoveLinkAnimationsThenReapplies()
+ {
+ var weenie = new FakeWeenie { IsCreatureResult = true };
+ var body = MakeGrounded();
+ var interp = MakeInterp(body, weenie);
+ interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+ bool called = false;
+ interp.RemoveLinkAnimations = () => called = true;
+
+ interp.HitGround();
+
+ Assert.True(called, "HitGround must call the RemoveLinkAnimations seam");
+ // apply_current_movement(false, true) re-syncs velocity from the
+ // current interpreted state (grounded write, AP-75-adjacent).
+ Assert.True(body.Velocity.Length() > 0f, "HitGround must re-apply current movement");
+ }
+
+ // =========================================================================
+ // LeaveGround — 0x00528b00 @306022
+ // =========================================================================
+
+ [Fact]
+ public void LeaveGround_NoPhysicsObj_NoOp()
+ {
+ var interp = new MotionInterpreter();
+ bool called = false;
+ interp.RemoveLinkAnimations = () => called = true;
+
+ interp.LeaveGround();
+
+ Assert.False(called);
+ }
+
+ [Fact]
+ public void LeaveGround_NonCreatureWeenie_NoOp()
+ {
+ var weenie = new FakeWeenie { IsCreatureResult = false };
+ var body = MakeGrounded();
+ var interp = MakeInterp(body, weenie);
+ interp.StandingLongJump = true;
+ interp.JumpExtent = 0.7f;
+ bool called = false;
+ interp.RemoveLinkAnimations = () => called = true;
+
+ interp.LeaveGround();
+
+ Assert.False(called);
+ // Nothing in the body ran -- StandingLongJump/JumpExtent untouched.
+ Assert.True(interp.StandingLongJump);
+ Assert.Equal(0.7f, interp.JumpExtent);
+ }
+
+ [Fact]
+ public void LeaveGround_GravityFlagClear_NoOp()
+ {
+ var body = MakeGrounded();
+ body.State &= ~PhysicsStateFlags.Gravity;
+ var interp = MakeInterp(body);
+ interp.StandingLongJump = true;
+ bool called = false;
+ interp.RemoveLinkAnimations = () => called = true;
+
+ interp.LeaveGround();
+
+ Assert.False(called);
+ Assert.True(interp.StandingLongJump);
+ }
+
+ [Fact]
+ public void LeaveGround_CreatureWithGravity_SetsVelocityAndResetsJumpState()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.StandingLongJump = true;
+ interp.JumpExtent = 0.5f;
+
+ interp.LeaveGround();
+
+ Assert.False(interp.StandingLongJump, "standing_longjump = 0 on leave-ground");
+ Assert.Equal(0f, interp.JumpExtent, precision: 5);
+ }
+
+ [Fact]
+ public void LeaveGround_CallsRemoveLinkAnimationsThenReapplies()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+ bool called = false;
+ interp.RemoveLinkAnimations = () => called = true;
+
+ interp.LeaveGround();
+
+ Assert.True(called, "LeaveGround must call the RemoveLinkAnimations seam");
+ }
+
+ [Fact]
+ public void LeaveGround_VelocityCarriesGetLeaveGroundVelocity_WithNonzeroJumpExtent()
+ {
+ // A6/A5: get_leave_ground_velocity composes get_state_velocity() +
+ // z=GetJumpVZ(). With JumpExtent set and a weenie present, the body
+ // local velocity written must match GetLeaveGroundVelocity()'s Z.
+ //
+ // Real call-site precondition: by the time LeaveGround fires, the
+ // body has ALREADY left the ground (jump() calls set_on_walkable(false)
+ // before LeaveGround runs; a ledge walk-off similarly clears OnWalkable
+ // via the physics resolver BEFORE this callback). With OnWalkable
+ // false, LeaveGround's own tail apply_current_movement(0,0) call
+ // takes the "airborne -- preserve integrated velocity" branch in the
+ // AP-77 adaptation (ApplyCurrentMovementInterpreted), so the Z this
+ // call just set is not immediately clobbered by a resync.
+ var weenie = new AcDream.Core.Physics.PlayerWeenie();
+ var body = MakeGrounded();
+ body.set_on_walkable(false);
+ var interp = MakeInterp(body, weenie);
+ interp.JumpExtent = 0.5f;
+
+ var expected = interp.GetLeaveGroundVelocity();
+ interp.LeaveGround();
+
+ // set_local_velocity transforms local->world via Orientation
+ // (Identity in these fixtures), so world == local here.
+ Assert.Equal(expected.Z, body.Velocity.Z, precision: 3);
+ }
+
+ [Fact]
+ public void LeaveGround_SetLocalVelocity_UsesAutonomousFlag()
+ {
+ // raw 305763-305765: CPhysicsObj::set_local_velocity(&var_c, 1) --
+ // the autonomous=1 arg. PhysicsBody.LastMoveWasAutonomous should
+ // reflect this after LeaveGround runs (movement_is_autonomous, A3's
+ // apply_current_movement dispatch reads this same flag).
+ var body = MakeGrounded();
+ body.LastMoveWasAutonomous = false;
+ var interp = MakeInterp(body);
+
+ interp.LeaveGround();
+
+ Assert.True(body.LastMoveWasAutonomous, "LeaveGround's set_local_velocity call carries autonomous=1");
+ }
+
+ // =========================================================================
+ // enter_default_state — 0x00528c80 @306124 (A8: append sentinel, NO drain)
+ // =========================================================================
+
+ [Fact]
+ public void EnterDefaultState_AppendsReadySentinel_WithoutDrainingExistingNodes()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.AddToQueue(1, MotionCommand.RunForward, 0);
+ interp.AddToQueue(2, MotionCommand.WalkForward, 0x48);
+
+ interp.EnterDefaultState();
+
+ var nodes = interp.PendingMotions.ToArray();
+ Assert.Equal(3, nodes.Length);
+ Assert.Equal(new MotionNode(1, MotionCommand.RunForward, 0), nodes[0]);
+ Assert.Equal(new MotionNode(2, MotionCommand.WalkForward, 0x48), nodes[1]);
+ Assert.Equal(new MotionNode(0, MotionCommand.Ready, 0), nodes[2]);
+ }
+
+ [Fact]
+ public void EnterDefaultState_SetsInitted()
+ {
+ var interp = new MotionInterpreter { PhysicsObj = MakeGrounded() };
+ // Force Initted false to prove EnterDefaultState is what flips it
+ // (the ctor already defaults it true -- see the Initted-gating
+ // section below for why).
+ interp.Initted = false;
+
+ interp.EnterDefaultState();
+
+ Assert.True(interp.Initted);
+ }
+
+ [Fact]
+ public void EnterDefaultState_ResetsRawAndInterpretedStateToCtorDefaults()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.ForwardCommand = MotionCommand.RunForward;
+ interp.RawState.ForwardSpeed = 2.94f;
+ interp.RawState.CurrentHoldKey = HoldKey.Run;
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.TurnCommand = MotionCommand.TurnRight;
+
+ interp.EnterDefaultState();
+
+ Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
+ Assert.Equal(1.0f, interp.RawState.ForwardSpeed);
+ Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
+ Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
+ Assert.Equal(0u, interp.InterpretedState.TurnCommand);
+ }
+
+ [Fact]
+ public void EnterDefaultState_CallsInitializeMotionTablesSeam()
+ {
+ var interp = MakeInterp();
+ bool called = false;
+ interp.InitializeMotionTables = () => called = true;
+
+ interp.EnterDefaultState();
+
+ Assert.True(called);
+ }
+
+ [Fact]
+ public void EnterDefaultState_TailCallsLeaveGround()
+ {
+ // enter_default_state's LAST step is an unconditional LeaveGround()
+ // call (raw @306153). With a grounded creature+gravity body and a
+ // nonzero JumpExtent pre-seeded, LeaveGround's reset (standing_longjump=0,
+ // jump_extent=0) must be observable after EnterDefaultState.
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.StandingLongJump = true;
+ interp.JumpExtent = 0.9f;
+
+ interp.EnterDefaultState();
+
+ Assert.False(interp.StandingLongJump, "EnterDefaultState's LeaveGround tail must fire");
+ Assert.Equal(0f, interp.JumpExtent, precision: 5);
+ }
+
+ // =========================================================================
+ // Initted gating — apply_current_movement / ReportExhaustion (A3 entry gate)
+ // =========================================================================
+
+ [Fact]
+ public void ApplyCurrentMovement_NotInitted_NoOp()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.Initted = false;
+ interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
+
+ interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
+
+ Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
+ }
+
+ [Fact]
+ public void ApplyCurrentMovement_Initted_Proceeds()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ Assert.True(interp.Initted, "the two-arg constructor defaults Initted=true (see final report)");
+ interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
+
+ interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
+
+ Assert.True(body.Velocity.Length() > 0f);
+ }
+
+ [Fact]
+ public void ReportExhaustion_NotInitted_NoOp()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.Initted = false;
+ interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
+
+ interp.ReportExhaustion();
+
+ Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
+ }
+
+ [Fact]
+ public void ReportExhaustion_NoPhysicsObj_NoOp()
+ {
+ var interp = new MotionInterpreter();
+ // No exception is the assertion -- retail's entry gate is
+ // physics_obj != 0 && initted != 0, both ANDed.
+ interp.ReportExhaustion();
+ }
+
+ // =========================================================================
+ // Dual-dispatch truth table — A3: IsThePlayer && movement_is_autonomous
+ // gates apply_raw_movement vs apply_interpreted_movement, for
+ // apply_current_movement AND ReportExhaustion.
+ //
+ // Reading raw_movement's effect: apply_raw_movement copies RawState ->
+ // InterpretedState (see apply_raw_movement's existing doc comment) then
+ // re-normalizes via adjust_motion. apply_interpreted_movement instead
+ // reads InterpretedState directly and pushes velocity via
+ // get_state_velocity/set_local_velocity when grounded. We distinguish
+ // the two dispatch targets by seeding RawState and InterpretedState with
+ // DIFFERENT forward commands/speeds and observing which one drove the
+ // resulting body velocity.
+ // =========================================================================
+
+ [Theory]
+ [InlineData(true, true, /* expectRaw */ true)] // IsThePlayer && autonomous -> raw
+ [InlineData(true, false, /* expectRaw */ false)] // IsThePlayer but not autonomous -> interpreted
+ [InlineData(false, true, /* expectRaw */ false)] // autonomous but NOT the player -> interpreted (remote player IS a creature but not "the player")
+ [InlineData(false, false, /* expectRaw */ false)] // neither -> interpreted
+ public void ApplyCurrentMovement_DualDispatch_MatchesA3TruthTable(
+ bool isThePlayer, bool autonomous, bool expectRaw)
+ {
+ var weenie = new FakeWeenie { IsThePlayerResult = isThePlayer, IsCreatureResult = true };
+ var body = MakeGrounded();
+ body.LastMoveWasAutonomous = autonomous;
+ var interp = MakeInterp(body, weenie);
+
+ // RawState drives WalkForward (speed 1 => WalkAnimSpeed); InterpretedState
+ // drives RunForward (speed 1 => RunAnimSpeed). WalkAnimSpeed != RunAnimSpeed
+ // so the resulting body.Velocity.Y distinguishes which path ran.
+ interp.RawState.ForwardCommand = MotionCommand.WalkForward;
+ interp.RawState.ForwardSpeed = 1.0f;
+ interp.RawState.ForwardHoldKey = HoldKey.None;
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+
+ interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
+
+ float expectedY = expectRaw
+ ? MotionInterpreter.WalkAnimSpeed // apply_raw_movement re-derives from RawState
+ : MotionInterpreter.RunAnimSpeed; // apply_interpreted_movement reads InterpretedState as-is
+ Assert.Equal(expectedY, body.Velocity.Y, precision: 2);
+ }
+
+ [Fact]
+ public void ApplyCurrentMovement_NoWeenie_Autonomous_DispatchesRaw()
+ {
+ // weenie_obj == 0 short-circuits the OR to true (raw 305849:
+ // weenie_obj == 0 || eax_2 != 0) -- no weenie means "treat as the
+ // player" for dispatch purposes.
+ var body = MakeGrounded();
+ body.LastMoveWasAutonomous = true;
+ var interp = MakeInterp(body); // no weenie
+
+ interp.RawState.ForwardCommand = MotionCommand.WalkForward;
+ interp.RawState.ForwardSpeed = 1.0f;
+ interp.RawState.ForwardHoldKey = HoldKey.None;
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+
+ interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
+
+ Assert.Equal(MotionInterpreter.WalkAnimSpeed, body.Velocity.Y, precision: 2);
+ }
+
+ [Fact]
+ public void ReportExhaustion_DualDispatch_PassesZeroZeroArgs()
+ {
+ // A3: ReportExhaustion's dispatch args are hardcoded (0, 0) --
+ // distinct from apply_current_movement which passes its own
+ // (cancelMoveTo, allowJump) through. We can't observe the args
+ // directly (both paths are void), so this proves ReportExhaustion
+ // dispatches at all when initted+player+autonomous (raw path) by
+ // observing the same velocity-divergence trick.
+ var weenie = new FakeWeenie { IsThePlayerResult = true };
+ var body = MakeGrounded();
+ body.LastMoveWasAutonomous = true;
+ var interp = MakeInterp(body, weenie);
+ interp.RawState.ForwardCommand = MotionCommand.WalkForward;
+ interp.RawState.ForwardSpeed = 1.0f;
+ interp.RawState.ForwardHoldKey = HoldKey.None;
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+
+ interp.ReportExhaustion();
+
+ Assert.Equal(MotionInterpreter.WalkAnimSpeed, body.Velocity.Y, precision: 2);
+ }
+
+ [Fact]
+ public void ReportExhaustion_RemotePlayer_DispatchesInterpreted()
+ {
+ // The ACE-divergence pin: a remote player weenie (IsThePlayer=false,
+ // IsCreature=true) must route INTERPRETED, not raw -- even though
+ // it IS a creature. ACE's IsCreature-gated read would wrongly send
+ // this down apply_raw_movement.
+ var weenie = new FakeWeenie { IsThePlayerResult = false, IsCreatureResult = true };
+ var body = MakeGrounded();
+ body.LastMoveWasAutonomous = true; // even with autonomous=true...
+ var interp = MakeInterp(body, weenie);
+ interp.RawState.ForwardCommand = MotionCommand.WalkForward;
+ interp.RawState.ForwardSpeed = 1.0f;
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+
+ interp.ReportExhaustion();
+
+ // ...IsThePlayer==false forces the interpreted path.
+ Assert.Equal(MotionInterpreter.RunAnimSpeed, body.Velocity.Y, precision: 2);
+ }
+
+ // =========================================================================
+ // SetWeenieObject / SetPhysicsObject — 0x00528920 / 0x00528970 re-apply
+ // =========================================================================
+
+ [Fact]
+ public void SetWeenieObject_WhilePhysicsBoundAndInitted_ReappliesMovement()
+ {
+ var body = MakeGrounded();
+ var interp = new MotionInterpreter { PhysicsObj = body, Initted = true };
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+
+ interp.SetWeenieObject(new FakeWeenie { IsThePlayerResult = false });
+
+ Assert.True(body.Velocity.Length() > 0f, "SetWeenieObject must re-apply movement when bound+initted");
+ }
+
+ [Fact]
+ public void SetWeenieObject_NoPhysicsObj_DoesNotReapply()
+ {
+ var interp = new MotionInterpreter();
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+
+ // Must not throw despite no PhysicsObj.
+ interp.SetWeenieObject(new FakeWeenie());
+
+ Assert.Null(interp.PhysicsObj);
+ }
+
+ [Fact]
+ public void SetWeenieObject_NotInitted_DoesNotReapply()
+ {
+ var body = MakeGrounded();
+ var interp = new MotionInterpreter { PhysicsObj = body, Initted = false };
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+
+ interp.SetWeenieObject(new FakeWeenie());
+
+ Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
+ }
+
+ [Fact]
+ public void SetPhysicsObject_BindsAndReappliesWhenInitted()
+ {
+ var interp = new MotionInterpreter { Initted = true };
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+ var body = MakeGrounded();
+
+ interp.SetPhysicsObject(body);
+
+ Assert.Same(body, interp.PhysicsObj);
+ Assert.True(body.Velocity.Length() > 0f);
+ }
+
+ [Fact]
+ public void SetPhysicsObject_NullArg_DoesNotReapply()
+ {
+ var interp = new MotionInterpreter { Initted = true };
+
+ // arg2 != 0 gates the reapply -- passing null must not throw and
+ // must not attempt InterpretedState velocity work (no body to write to).
+ interp.SetPhysicsObject(null);
+
+ Assert.Null(interp.PhysicsObj);
+ }
+
+ // =========================================================================
+ // set_hold_run — 0x00528b70 @306053 (XOR toggle guard)
+ // =========================================================================
+
+ [Fact]
+ public void SetHoldRun_TogglesFromNoneToRun_WhenHoldingRunKey()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.None;
+
+ interp.set_hold_run(holdingRun: true, interrupt: false);
+
+ Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
+ }
+
+ [Fact]
+ public void SetHoldRun_TogglesFromRunToNone_WhenReleasingRunKey()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.Run;
+
+ interp.set_hold_run(holdingRun: false, interrupt: false);
+
+ Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
+ }
+
+ [Fact]
+ public void SetHoldRun_NoChange_WhenAlreadyInRequestedState_IsANoOp()
+ {
+ // XOR guard: eax(=arg2==0) != edx(=current!=Run) is FALSE when
+ // arg2 requests exactly the state we're already in -- skip.
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.Run;
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+ body.Velocity = System.Numerics.Vector3.Zero;
+
+ interp.set_hold_run(holdingRun: true, interrupt: false);
+
+ Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
+ // No re-apply fired (still zero) -- the guard skipped the whole body.
+ Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
+ }
+
+ [Fact]
+ public void SetHoldRun_OnChange_CallsApplyCurrentMovementWithInterruptArg()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.None;
+ interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+
+ interp.set_hold_run(holdingRun: true, interrupt: true);
+
+ // apply_current_movement(arg3, 0) fired -- observable via the
+ // grounded velocity re-application (AP-75-adjacent write).
+ Assert.True(body.Velocity.Length() > 0f);
+ }
+
+ // =========================================================================
+ // SetHoldKey — 0x00528bb0 @306072 (None-only-meaningful-from-Run)
+ // =========================================================================
+
+ [Fact]
+ public void SetHoldKey_None_FromRun_TakesEffect()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.Run;
+
+ interp.SetHoldKey(HoldKey.None, cancelMoveTo: false);
+
+ Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
+ }
+
+ [Fact]
+ public void SetHoldKey_None_FromInvalid_IsIgnored()
+ {
+ // raw @306072: setting None only takes effect from Run. Any other
+ // starting state (Invalid, or already None) is silently ignored.
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.Invalid;
+
+ interp.SetHoldKey(HoldKey.None, cancelMoveTo: false);
+
+ Assert.Equal(HoldKey.Invalid, interp.RawState.CurrentHoldKey);
+ }
+
+ [Fact]
+ public void SetHoldKey_AlreadyNone_IsNoOp()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.None;
+
+ interp.SetHoldKey(HoldKey.None, cancelMoveTo: false);
+
+ Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
+ }
+
+ [Fact]
+ public void SetHoldKey_Run_FromNone_TakesEffect()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.None;
+
+ interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false);
+
+ Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
+ }
+
+ [Fact]
+ public void SetHoldKey_Run_AlreadyRun_IsNoOp()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.Run;
+ interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+ body.Velocity = System.Numerics.Vector3.Zero;
+
+ interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false);
+
+ Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
+ Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
+ }
+
+ [Fact]
+ public void SetHoldKey_EffectiveChange_ReappliesMovement()
+ {
+ var body = MakeGrounded();
+ var interp = MakeInterp(body);
+ interp.RawState.CurrentHoldKey = HoldKey.None;
+ interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
+ interp.InterpretedState.ForwardSpeed = 1.0f;
+
+ interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false);
+
+ Assert.True(body.Velocity.Length() > 0f, "an effective SetHoldKey change must reapply movement");
+ }
+
+ // =========================================================================
+ // adjust_motion creature guard — J18 one-liner (retires register TS-34)
+ // =========================================================================
+
+ [Fact]
+ public void AdjustMotion_NonCreatureWeenie_SkipsNormalization()
+ {
+ var weenie = new FakeWeenie { IsCreatureResult = false };
+ var interp = MakeInterp(weenie: weenie);
+ uint motion = MotionCommand.WalkBackward;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
+
+ // Retail returns immediately for a non-creature weenie -- WalkBackward
+ // must NOT be remapped to WalkForward/negated-speed.
+ Assert.Equal(MotionCommand.WalkBackward, motion);
+ Assert.Equal(1.0f, speed);
+ }
+
+ [Fact]
+ public void AdjustMotion_CreatureWeenie_NormalizesAsBefore()
+ {
+ var weenie = new FakeWeenie { IsCreatureResult = true };
+ var interp = MakeInterp(weenie: weenie);
+ uint motion = MotionCommand.WalkBackward;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
+
+ Assert.Equal(MotionCommand.WalkForward, motion);
+ Assert.Equal(-MotionInterpreter.BackwardsFactor, speed, precision: 5);
+ }
+
+ [Fact]
+ public void AdjustMotion_NoWeenie_StillNormalizes()
+ {
+ // weenie == null must behave like a creature (the "always creature
+ // unless proven otherwise" retail idiom -- weenie_obj != 0 gates
+ // the query at all).
+ var interp = MakeInterp();
+ uint motion = MotionCommand.WalkBackward;
+ float speed = 1.0f;
+
+ interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
+
+ Assert.Equal(MotionCommand.WalkForward, motion);
+ }
+}