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); + } +}