diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 719e1af6..acf206a3 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -209,7 +209,7 @@ accepted-divergence entries (#96, #49, #50).
| TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 |
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands; "we don't parse yet" comment is STALE (K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:341` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 |
-| TS-24 | RawMotionState action list always empty at runtime — the packer now emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), but the outbound caller builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91` | Discrete client-initiated motions (D2) not wired yet; packer-ready, runtime emission is Phase 2+ | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
+| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets. (The 0xF74C UpdateMotion side of this gap CLOSED 2026-07-02 — L.2g S1 `MotionSequenceGate` ports the retail instance/movement/server-control gate; the UP side + teleport/force-position stamps remain open) | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write); `CPhysicsObj::newer_event` 0x00451b10 |
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
diff --git a/src/AcDream.Core/Physics/Motion/MotionNode.cs b/src/AcDream.Core/Physics/Motion/MotionNode.cs
new file mode 100644
index 00000000..007a6c11
--- /dev/null
+++ b/src/AcDream.Core/Physics/Motion/MotionNode.cs
@@ -0,0 +1,30 @@
+namespace AcDream.Core.Physics.Motion;
+
+///
+/// R3-W1 — verbatim port of retail's CMotionInterp::MotionNode
+/// (acclient.h:53293, struct #5857):
+///
+/// struct __cppobj CMotionInterp::MotionNode : LListData
+/// {
+/// unsigned int context_id; // +4 (offset from LListData's own +0 next-ptr)
+/// unsigned int motion; // +8 — the "action-class" field; bit 0x10000000 = action-class flag
+/// unsigned int jump_error_code; // +0xc
+/// };
+///
+/// This is the node type queued onto CMotionInterp::pending_motions
+/// by add_to_queue (0x00527b80, docs/research/2026-07-02-r3-motioninterp/
+/// r3-motioninterp-decomp.md §1a) and consumed head-first, unconditionally,
+/// by MotionDone (0x00527ec0, §1c) / HandleExitWorld (0x00527f30,
+/// §1d). W1 ports only the shape — the queue itself (PendingMotions,
+/// add_to_queue, MotionDone) is R3-W2 scope (r3-port-plan.md §3).
+///
+/// Retail context_id (+4) — the
+/// MovementParameters.ContextId that produced this node.
+/// Retail motion (+8) — the applied motion id.
+/// Bit 0x10000000 marks an "action-class" motion; MotionDone
+/// only pops the state action-FIFO head when this bit is set on the queue
+/// head it is consuming.
+/// Retail jump_error_code (+0xc) — the
+/// motion_allows_jump result computed at enqueue time. Peeked by
+/// jump_is_allowed's pending-head short-circuit (W0-pins A2).
+public readonly record struct MotionNode(uint ContextId, uint Motion, uint JumpErrorCode);
diff --git a/src/AcDream.Core/Physics/Motion/MovementParameters.cs b/src/AcDream.Core/Physics/Motion/MovementParameters.cs
new file mode 100644
index 00000000..798157dd
--- /dev/null
+++ b/src/AcDream.Core/Physics/Motion/MovementParameters.cs
@@ -0,0 +1,187 @@
+namespace AcDream.Core.Physics.Motion;
+
+///
+/// R3-W1 — verbatim port of retail's MovementParameters
+/// (acclient.h:31453, struct #3460; bitfield struct
+/// acclient.h:31423-31443):
+///
+/// struct __cppobj MovementParameters : PackObj
+/// {
+/// union { unsigned int bitfield; ... } ___u1;
+/// float distance_to_object;
+/// float min_distance;
+/// float desired_heading;
+/// float speed;
+/// float fail_distance;
+/// float walk_run_threshhold;
+/// unsigned int context_id;
+/// HoldKey hold_key_to_apply;
+/// unsigned int action_stamp;
+/// };
+///
+///
+///
+/// The bitfield's absolute mask table is pinned in
+/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A4 (bit-for-bit
+/// identical to ACE's MovementParamFlags):
+///
+///
+/// - 0x1CanWalk
+/// - 0x2CanRun
+/// - 0x4CanSidestep
+/// - 0x8CanWalkBackwards
+/// - 0x10CanCharge
+/// - 0x20FailWalk
+/// - 0x40UseFinalHeading
+/// - 0x80Sticky
+/// - 0x100MoveAway
+/// - 0x200MoveTowards
+/// - 0x400UseSpheres
+/// - 0x800SetHoldKey
+/// - 0x1000Autonomous
+/// - 0x2000ModifyRawState
+/// - 0x4000ModifyInterpretedState
+/// - 0x8000CancelMoveTo
+/// - 0x10000StopCompletely
+/// - 0x20000DisableJumpDuringLink
+///
+///
+///
+/// Ctor default (raw 300510-300534, 0x00524380):
+/// (bitfield & 0xfffdee0f) | 0x1ee0f → 0x1EE0F sets
+/// {CanWalk, CanRun, CanSidestep, CanWalkBackwards, MoveTowards, UseSpheres,
+/// SetHoldKey, ModifyRawState, ModifyInterpretedState, CancelMoveTo,
+/// StopCompletely}; clears {CanCharge, FailWalk, UseFinalHeading, Sticky,
+/// MoveAway, Autonomous, DisableJumpDuringLink}.
+/// Scalars: min_distance=0, distance_to_object=0.6,
+/// fail_distance=FLT_MAX, desired_heading=0, speed=1,
+/// walk_run_threshhold=15 (NOT ACE's 1.0 — W0-pins A4 divergence trap),
+/// context_id=0, hold_key_to_apply=HoldKey.Invalid,
+/// action_stamp=0.
+///
+///
+///
+/// ACE-divergence traps (W0-pins A4, do not copy): ACE's
+/// MovementParameters ctor sets CanCharge = true
+/// (MovementParameters.cs:58) — retail's default has bit 0x10 CLEAR;
+/// this port defaults CanCharge = false. ACE also changed
+/// Default_WalkRunThreshold to 1.0 (L50) vs retail's literal 15.0
+/// (@300519) — this port defaults WalkRunThreshhold = 15f.
+///
+///
+///
+/// Named bool properties per plan (no ToBitfield()/FromBitfield()
+/// pair — the wire never carries this struct raw; RawMotionState::Pack
+/// serializes the STATE, not this transient parameter block. If a future
+/// slice needs the packed form, add the pair then with a cited call site).
+///
+///
+public sealed class MovementParameters
+{
+ // ── bitfield flags (retail 0x1EE0F default; W0-pins A4) ───────────────
+
+ /// Mask 0x1 — default true.
+ public bool CanWalk { get; set; } = true;
+
+ /// Mask 0x2 — default true.
+ public bool CanRun { get; set; } = true;
+
+ /// Mask 0x4 — default true.
+ public bool CanSidestep { get; set; } = true;
+
+ /// Mask 0x8 — default true.
+ public bool CanWalkBackwards { get; set; } = true;
+
+ ///
+ /// Mask 0x10 — default FALSE. ACE-divergence trap (W0-pins A4): ACE's
+ /// ctor sets this true (MovementParameters.cs:58); retail's 0x1EE0F
+ /// default has bit 0x10 CLEAR. Do not "fix" this to true.
+ ///
+ public bool CanCharge { get; set; }
+
+ /// Mask 0x20 — default false.
+ public bool FailWalk { get; set; }
+
+ /// Mask 0x40 — default false.
+ public bool UseFinalHeading { get; set; }
+
+ /// Mask 0x80 — default false.
+ public bool Sticky { get; set; }
+
+ /// Mask 0x100 — default false.
+ public bool MoveAway { get; set; }
+
+ /// Mask 0x200 — default true.
+ public bool MoveTowards { get; set; } = true;
+
+ /// Mask 0x400 — default true.
+ public bool UseSpheres { get; set; } = true;
+
+ /// Mask 0x800 — default true. DoMotion @306188: byte1&8
+ /// requests a SetHoldKey call before adjust_motion.
+ public bool SetHoldKey { get; set; } = true;
+
+ ///
+ /// Mask 0x1000 — default FALSE. Not the same virtual as
+ /// IWeenieObject.IsThePlayer (W0-pins A3) — this is the
+ /// per-call "was this an autonomous (locally-predicted) action?" flag.
+ ///
+ public bool Autonomous { get; set; }
+
+ /// Mask 0x2000 — default true. DoMotion @306213: byte1&0x20
+ /// mirrors the applied motion into RawMotionState via
+ /// ApplyMotion/RemoveMotion.
+ public bool ModifyRawState { get; set; } = true;
+
+ /// Mask 0x4000 — default true. Mirrors into
+ /// InterpretedMotionState.
+ public bool ModifyInterpretedState { get; set; } = true;
+
+ /// Mask 0x8000 — default true. Bitfield high-byte sign bit;
+ /// DoMotion/StopMotion @306183/@305684: triggers
+ /// interrupt_current_movement before the rest of the call.
+ public bool CancelMoveTo { get; set; } = true;
+
+ /// Mask 0x10000 — default true.
+ public bool StopCompletelyFlag { get; set; } = true;
+
+ ///
+ /// Mask 0x20000 — default FALSE. DoInterpretedMotion @305597: when set,
+ /// forces the computed jump_error_code to 0x48 (A1: jump
+ /// BLOCKED by motion/position) regardless of what
+ /// motion_allows_jump would have said.
+ ///
+ public bool DisableJumpDuringLink { get; set; }
+
+ // ── scalar fields (retail ctor 0x00524380 defaults) ───────────────────
+
+ /// Retail default 0.6.
+ public float DistanceToObject { get; set; } = 0.6f;
+
+ /// Retail default 0.
+ public float MinDistance { get; set; }
+
+ /// Retail default 0.
+ public float DesiredHeading { get; set; }
+
+ /// Retail default 1.
+ public float Speed { get; set; } = 1f;
+
+ /// Retail default FLT_MAX.
+ public float FailDistance { get; set; } = float.MaxValue;
+
+ ///
+ /// Retail default 15.0 (@300519). ACE-divergence trap (W0-pins A4): ACE
+ /// changed Default_WalkRunThreshold to 1.0 — do not copy.
+ ///
+ public float WalkRunThreshhold { get; set; } = 15f;
+
+ /// Retail default 0.
+ public uint ContextId { get; set; }
+
+ /// Retail default .
+ public HoldKey HoldKeyToApply { get; set; } = HoldKey.Invalid;
+
+ /// Retail default 0.
+ public uint ActionStamp { get; set; }
+}
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index eddfa868..c2be087f 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.Numerics;
+using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics;
@@ -107,81 +109,119 @@ public enum MovementType
}
///
-/// WeenieError codes returned by CMotionInterp methods.
-/// Values are the hex constants used directly in the decompiled C code.
+/// WeenieError-shaped codes returned by CMotionInterp methods. Values are
+/// the hex constants used directly in the decompiled C code.
+///
+///
+/// R3-W1 renumber (closes J16-codes/A10). Prior to this slice, the
+/// names were shuffled relative to retail's actual numeric semantics
+/// (GeneralMovementFailure was assigned 0x24 and used at the
+/// airborne-jump gate, but retail's 0x24 means "not grounded / no contact"
+/// and retail's 0x47 is the general-movement-failure code; 0x48 meant
+/// "cannot jump while in the air" here, but retail's 0x48 means "jump
+/// blocked by the CURRENT MOTION OR POSITION" — a blocklist check
+/// (motion_allows_jump), not an airborne check). Renumbered per the
+/// definitive, exhaustively-swept table in
+/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A10 (19
+/// return sites + 1 store site over raw 304908-306277 + 300150-300540).
+/// These codes are LOCAL-ONLY — never serialized to the wire — so the
+/// renumber is safe (verified: no consumer outside
+/// MotionInterpreter.cs pattern-matches on the numeric value).
+///
///
public enum WeenieError : uint
{
/// 0x00 — success.
None = 0x00,
- /// 0x08 — PhysicsObj is null.
+ ///
+ /// 0x08 — no physics_obj. Sites (A10): StopCompletely @305214;
+ /// DoInterpretedMotion @305579; StopInterpretedMotion @305639;
+ /// StopMotion @305680; jump @305798; DoMotion @306165.
+ ///
NoPhysicsObject = 0x08,
- /// 0x24 — general movement failure.
- GeneralMovementFailure = 0x24,
- /// 0x47 — cannot jump from this position (motion state blocks it).
- YouCantJumpFromThisPosition = 0x47,
- /// 0x48 — cannot jump while in the air.
- YouCantJumpWhileInTheAir = 0x48,
- /// 0x49 — loaded down / weenie blocked the jump.
+ ///
+ /// 0x24 — not grounded / no contact. Sites (A10):
+ /// jump_is_allowed @305570 (gravity-active creature without
+ /// Contact+OnWalkable; also the physics_obj == null case, which
+ /// falls out to this same code per the A10 note — NOT 8);
+ /// DoInterpretedMotion @305622-305623 (action-class motion
+ /// blocked by contact_allows_move).
+ ///
+ NotGrounded = 0x24,
+ ///
+ /// 0x3f — Crouch (0x41000012) rejected while in combat stance. Site:
+ /// DoMotion @306196.
+ ///
+ CrouchInCombatStance = 0x3f,
+ ///
+ /// 0x40 — Sitting (0x41000013) rejected while in combat stance. Site:
+ /// DoMotion @306199.
+ ///
+ SitInCombatStance = 0x40,
+ ///
+ /// 0x41 — Sleeping (0x41000014) rejected while in combat stance. Site:
+ /// DoMotion @306202.
+ ///
+ SleepInCombatStance = 0x41,
+ ///
+ /// 0x42 — motion & 0x2000000 (the chat-emote bit) rejected
+ /// outside NonCombat (0x8000003d). Site: DoMotion @306205.
+ ///
+ ChatEmoteOutsideNonCombat = 0x42,
+ ///
+ /// 0x45 — action-queue depth cap: an action-class motion (bit
+ /// 0x10000000) with GetNumActions() >= 6 pending. Site:
+ /// DoMotion @306209.
+ ///
+ ActionDepthExceeded = 0x45,
+ ///
+ /// 0x47 — general movement failure. Sites (A10):
+ /// jump_is_allowed @305525 (IsFullyConstrained);
+ /// @305549+305556 (JumpStaminaCost refusal);
+ /// CMotionInterp::PerformMovement @306227 (dispatch type-1 > 4);
+ /// MovementManager::PerformMovement @300201 (dispatch type-1 > 8).
+ ///
+ GeneralMovementFailure = 0x47,
+ ///
+ /// 0x48 — jump BLOCKED by the current motion or position (A1: the
+ /// motion_allows_jump literal-range blocklist — NOT an airborne
+ /// check; airborne is 0x24). Sites (A10):
+ /// motion_allows_jump @304930; jump_charge_is_allowed
+ /// @304948 (Fallen or Crouch..Sleeping); charge_jump @305459
+ /// (same predicate); STORED (not returned) as the queue node's
+ /// jump_error_code in DoInterpretedMotion @305605 when
+ /// disable_jump_during_link is set.
+ ///
+ YouCantJumpFromThisPosition = 0x48,
+ ///
+ /// 0x49 — the weenie's CanJump(jump_extent) virtual refused
+ /// (e.g. stamina/burden gate). Sites: jump_charge_is_allowed
+ /// @304941; charge_jump @305454.
+ ///
CantJumpLoadedDown = 0x49,
}
// ── Motion state structs ───────────────────────────────────────────────────────
///
-/// Raw (network-derived) motion state for the local player, as consumed by
-/// the (still-approximate) input-state path.
-/// Struct layout in chunk_00520000 starts at offset +0x14 (struct field +0x20 =
-/// ForwardCommand, +0x28 = ForwardSpeed, etc.).
+/// Interpreted motion state, derived from the raw state (retail
+/// InterpretedMotionState, ctor 0x0051e8d0, decomp 293418-293431).
+/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50).
///
///
-/// Renamed from RawMotionState during the L.2b wire-parity slice
-/// (2026-06-30) to free that name for the retail-faithful, full-field
-/// AcDream.Core.Physics.RawMotionState (mirrors
-/// acclient.h RawMotionState::PackBitfield) used by the outbound
-/// wire packer. This partial type is D6/Phase-2 territory (the
-/// adjust_motion/apply_raw_movement input-state port) —
-/// out of scope for this slice. It is expected to be folded into or
-/// replaced by the retail-faithful type when D6 lands.
+/// R3-W1 (closes J2): gains retail's action FIFO
+/// (///
+/// /) — previously this was
+/// a flat 6-field struct with no action tracking, so
+/// DoMotion's GetNumActions() >= 6 depth cap and
+/// MotionDone's action-class RemoveAction pop had nothing to
+/// operate on. The action list is backed by a private
+/// lazily created on first mutation (defensive
+/// against a bare default(InterpretedMotionState), which C# structs
+/// permit even though every constructor in this file routes through
+/// ).
///
///
-public struct LegacyRawMotionState
-{
- /// Current style / stance (retail raw_state.current_style,
- /// offset +0x18). Adopted from the inbound InterpretedMotionState by
- /// move_to_interpreted_state (0x005289c0 line 305944). Default
- /// NonCombat 0x8000003D.
- public uint CurrentStyle;
- /// Forward/backward motion command (offset +0x20).
- public uint ForwardCommand;
- /// Speed scalar for forward motion (offset +0x28).
- public float ForwardSpeed;
- /// Sidestep command (offset +0x2C).
- public uint SideStepCommand;
- /// Speed scalar for sidestep (offset +0x34, inferred from ACE).
- public float SideStepSpeed;
- /// Turn command (offset +0x38).
- public uint TurnCommand;
- /// Speed scalar for turn (offset +0x40, inferred).
- public float TurnSpeed;
-
- /// Initialize to the idle/ready state (1.0 speed, Ready command).
- public static LegacyRawMotionState Default() => new()
- {
- CurrentStyle = 0x8000003Du,
- ForwardCommand = MotionCommand.Ready,
- ForwardSpeed = 1.0f,
- SideStepCommand = 0,
- SideStepSpeed = 1.0f,
- TurnCommand = 0,
- TurnSpeed = 1.0f,
- };
-}
-
-///
-/// Interpreted motion state, derived from the raw state.
-/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50).
-///
public struct InterpretedMotionState
{
/// Forward/backward interpreted command (offset +0x4C).
@@ -196,6 +236,20 @@ public struct InterpretedMotionState
public uint TurnCommand;
/// Speed scalar for turn (offset +0x60).
public float TurnSpeed;
+ /// Current style / stance (retail current_style). Adopted from
+ /// the raw state's style channel; NOT part of retail's
+ /// InterpretedMotionState::ApplyMotion dispatch itself (that
+ /// writes this->current_style only via the negative-motion
+ /// branch — see ). Default NonCombat
+ /// 0x8000003D.
+ public uint CurrentStyle;
+
+ private List? _actions;
+
+ /// Action FIFO in retail order (oldest first). Empty (never
+ /// null) when read — the private field is lazily created.
+ public readonly IReadOnlyList Actions
+ => (IReadOnlyList?)_actions ?? Array.Empty();
/// Initialize to the idle/ready state.
public static InterpretedMotionState Default() => new()
@@ -206,7 +260,141 @@ public struct InterpretedMotionState
SideStepSpeed = 1.0f,
TurnCommand = 0,
TurnSpeed = 1.0f,
+ CurrentStyle = 0x8000003Du,
};
+
+ ///
+ /// InterpretedMotionState::AddAction (0x0051e9e0, decomp
+ /// 293500-293527): unconditional tail-append of
+ /// {motion, speed, action_stamp, autonomous}. Identical shape to
+ /// — retail duplicates the
+ /// LListData append logic per state type rather than sharing it.
+ ///
+ public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous)
+ {
+ _actions ??= new List();
+ _actions.Add(new RawMotionAction(
+ Command: (ushort)motion,
+ Stamp: (ushort)actionStamp,
+ Autonomous: autonomous,
+ Speed: speed));
+ }
+
+ ///
+ /// InterpretedMotionState::RemoveAction (0x0051ead0, decomp
+ /// 293568-293586): pop the FIFO head unconditionally, returning its
+ /// motion field (0 when empty).
+ ///
+ public uint RemoveAction()
+ {
+ if (_actions is null || _actions.Count == 0)
+ return 0;
+ var head = _actions[0];
+ _actions.RemoveAt(0);
+ return head.Command;
+ }
+
+ ///
+ /// InterpretedMotionState::GetNumActions (0x0051eb00, decomp
+ /// 293590-293603): count the FIFO by walking it (retail has no O(1)
+ /// count field on the LList).
+ ///
+ public readonly uint GetNumActions() => (uint)(_actions?.Count ?? 0);
+
+ ///
+ /// InterpretedMotionState::ApplyMotion (0x0051ea40, decomp
+ /// 293531-293564). Verbatim dispatch, quoted from the raw named decomp:
+ ///
+ /// if (arg2 == 0x6500000d) { turn_command = arg2; turn_speed = params.speed; return; }
+ /// if (arg2 == 0x6500000f) { sidestep_command = arg2; sidestep_speed = params.speed; return; }
+ /// if ((arg2 & 0x40000000) != 0) { forward_command = arg2; forward_speed = params.speed; return; }
+ /// if (arg2 < 0) { forward_command = 0x41000003; current_style = arg2; return; }
+ /// if ((arg2 & 0x10000000) != 0)
+ /// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield>>0xc)&1);
+ ///
+ /// Note only TurnRight (0x6500000d) and SideStepRight (0x6500000f) are
+ /// tested here — unlike , the
+ /// LEFT variants (TurnLeft/SideStepLeft) are NOT separately cased;
+ /// retail's adjust_motion normalizes Left→Right upstream before
+ /// this ever runs, so this asymmetry is verbatim, not a gap.
+ ///
+ /// Retail arg2 — the motion id AFTER
+ /// adjust_motion normalization (the ADJUSTED id, unlike
+ /// which takes the ORIGINAL).
+ /// Retail arg3 (MovementParameters const*).
+ public void ApplyMotion(uint motion, MovementParameters p)
+ {
+ if (motion == 0x6500000du) // TurnRight
+ {
+ TurnCommand = motion;
+ TurnSpeed = p.Speed;
+ return;
+ }
+ if (motion == 0x6500000fu) // SideStepRight
+ {
+ SideStepCommand = motion;
+ SideStepSpeed = p.Speed;
+ return;
+ }
+ if ((motion & 0x40000000u) != 0)
+ {
+ ForwardCommand = motion;
+ ForwardSpeed = p.Speed;
+ return;
+ }
+ if (motion >= 0x80000000u) // arg2 < 0 as signed int32
+ {
+ ForwardCommand = 0x41000003u;
+ CurrentStyle = motion;
+ return;
+ }
+ if ((motion & 0x10000000u) != 0)
+ AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous);
+ }
+
+ ///
+ /// InterpretedMotionState::RemoveMotion (0x0051e790, decomp
+ /// 293315-293340):
+ ///
+ /// if (arg2 == 0x6500000d) { turn_command = 0; return; }
+ /// if (arg2 == 0x6500000f) { sidestep_command = 0; return; }
+ /// if ((arg2 & 0x40000000) == 0) {
+ /// if (arg2 < 0 && arg2 == current_style) current_style = 0x8000003d;
+ /// } else if (arg2 == forward_command) {
+ /// forward_command = 0x41000003; forward_speed = 1f;
+ /// }
+ ///
+ /// Note the asymmetric range test vs
+ /// (which uses (arg2 - 0x6500000d) > 3 covering all four
+ /// turn/sidestep ids) — this one only special-cases the RIGHT variants
+ /// by exact equality; TurnLeft/SideStepLeft fall through to the
+ /// style/forward-command branch below. Verbatim, not a gap (mirrors
+ /// retail's own asymmetry between the two RemoveMotion bodies).
+ ///
+ /// Retail arg2.
+ public void RemoveMotion(uint motion)
+ {
+ if (motion == 0x6500000du)
+ {
+ TurnCommand = 0;
+ return;
+ }
+ if (motion == 0x6500000fu)
+ {
+ SideStepCommand = 0;
+ return;
+ }
+ if ((motion & 0x40000000u) == 0)
+ {
+ if (motion >= 0x80000000u && motion == CurrentStyle)
+ CurrentStyle = 0x8000003du;
+ }
+ else if (motion == ForwardCommand)
+ {
+ ForwardCommand = 0x41000003u;
+ ForwardSpeed = 1f;
+ }
+ }
}
///
@@ -317,8 +505,18 @@ public sealed class MotionInterpreter
/// Optional WeenieObject for stamina / run-rate queries (struct offset +0x04).
public IWeenieObject? WeenieObj { get; set; }
- /// Raw (network-derived) motion state (struct offsets +0x14..+0x44).
- public LegacyRawMotionState RawState;
+ ///
+ /// Raw (network-derived) motion state (struct offsets +0x14..+0x44).
+ ///
+ ///
+ /// R3-W1 fold (closes J2): this now holds the SAME retail-faithful,
+ /// full-field type the outbound
+ /// wire packer reads from — the former flat LegacyRawMotionState
+ /// struct (no action FIFO, no ApplyMotion/RemoveMotion)
+ /// is deleted. One raw-state type end to end.
+ ///
+ ///
+ public RawMotionState RawState;
/// Interpreted motion state derived from raw (struct offsets +0x44..+0x7C).
public InterpretedMotionState InterpretedState;
@@ -385,7 +583,7 @@ public sealed class MotionInterpreter
public MotionInterpreter()
{
- RawState = LegacyRawMotionState.Default();
+ RawState = new RawMotionState();
InterpretedState = InterpretedMotionState.Default();
}
@@ -393,7 +591,7 @@ public sealed class MotionInterpreter
{
PhysicsObj = physicsObj;
WeenieObj = weenieObj;
- RawState = LegacyRawMotionState.Default();
+ RawState = new RawMotionState();
InterpretedState = InterpretedMotionState.Default();
}
@@ -477,9 +675,12 @@ public sealed class MotionInterpreter
if (!contact_allows_move(motion))
{
- // Action commands (bit 0x10000000) are blocked mid-air.
+ // Action commands (bit 0x10000000) are blocked mid-air. A10:
+ // DoInterpretedMotion @305622-305623 returns 0x24 (NotGrounded)
+ // here, not 0x48 (that code is reserved for the
+ // motion_allows_jump literal-range blocklist).
if ((motion & 0x10000000u) != 0)
- return WeenieError.YouCantJumpWhileInTheAir;
+ return WeenieError.NotGrounded;
// Non-action motions are queued silently; state still updates.
}
@@ -505,10 +706,10 @@ public sealed class MotionInterpreter
RawState.ForwardCommand = MotionCommand.Ready;
RawState.ForwardSpeed = 1.0f;
}
- if (RawState.SideStepCommand == motion)
+ if (RawState.SidestepCommand == motion)
{
- RawState.SideStepCommand = 0;
- RawState.SideStepSpeed = 1.0f;
+ RawState.SidestepCommand = 0;
+ RawState.SidestepSpeed = 1.0f;
}
if (RawState.TurnCommand == motion)
{
@@ -563,7 +764,7 @@ public sealed class MotionInterpreter
/// uVar1 = FUN_005285e0(InterpretedState.ForwardCommand) — motion_allows_jump
/// *(+0x20) = 0x41000003 (RawState.ForwardCommand = Ready)
/// *(+0x28) = 0x3f800000 (RawState.ForwardSpeed = 1.0f)
- /// *(+0x2c) = 0 (RawState.SideStepCommand = 0)
+ /// *(+0x2c) = 0 (RawState.SidestepCommand = 0)
/// *(+0x38) = 0 (RawState.TurnCommand = 0)
/// *(+0x4c) = 0x41000003 (InterpretedState.ForwardCommand = Ready)
/// *(+0x50) = 0x3f800000 (InterpretedState.ForwardSpeed = 1.0f)
@@ -582,8 +783,8 @@ public sealed class MotionInterpreter
// Reset raw state
RawState.ForwardCommand = MotionCommand.Ready;
RawState.ForwardSpeed = 1.0f;
- RawState.SideStepCommand = 0;
- RawState.SideStepSpeed = 1.0f;
+ RawState.SidestepCommand = 0;
+ RawState.SidestepSpeed = 1.0f;
RawState.TurnCommand = 0;
RawState.TurnSpeed = 1.0f;
@@ -1039,7 +1240,7 @@ public sealed class MotionInterpreter
/// elif WeenieObj.IsCreature() returns false: proceed
/// iVar2 = PhysicsObj
/// if Gravity flag NOT set OR (Contact AND OnWalkable): ← grounded or no gravity
- /// return 0x24 (GeneralMovementFailure)
+ /// return 0x24 (NotGrounded)
/// if FUN_0050f730() (IsFullyConstrained) != 0: return 0x47
/// if pending queue action has non-zero jump error: return that error
/// iVar2 = FUN_00528660() (jump_charge_is_allowed)
@@ -1052,8 +1253,13 @@ public sealed class MotionInterpreter
///
public WeenieError jump_is_allowed(float extent, int staminaCost)
{
+ // A10: jump_is_allowed returns 0x24 (NotGrounded), NOT 8, when
+ // physics_obj == null — the "8 = no physics obj" convention holds
+ // everywhere else in CMotionInterp but not here (raw structure
+ // 305512 + 305570: the `if (physics_obj != 0) {...}` wrapper falls
+ // out to `return 0x24;`).
if (PhysicsObj is null)
- return WeenieError.GeneralMovementFailure;
+ return WeenieError.NotGrounded;
// Must have gravity and be grounded (Contact + OnWalkable) to start a jump.
bool hasGravity = PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity);
@@ -1061,7 +1267,7 @@ public sealed class MotionInterpreter
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (!hasGravity || !isGrounded)
- return WeenieError.YouCantJumpWhileInTheAir;
+ return WeenieError.NotGrounded;
// Delegate jump eligibility to WeenieObj if present.
if (WeenieObj is not null && !WeenieObj.CanJump(extent))
@@ -1421,6 +1627,6 @@ public sealed class MotionInterpreter
if ((motion & 0x10000000u) == 0)
return WeenieError.None; // apply-only: state kept, no cycle change
- return WeenieError.GeneralMovementFailure; // retail 0x24
+ return WeenieError.NotGrounded; // retail 0x24 (A10)
}
}
diff --git a/src/AcDream.Core/Physics/RawMotionState.cs b/src/AcDream.Core/Physics/RawMotionState.cs
index 33a1536a..315ee1ff 100644
--- a/src/AcDream.Core/Physics/RawMotionState.cs
+++ b/src/AcDream.Core/Physics/RawMotionState.cs
@@ -1,3 +1,5 @@
+using AcDream.Core.Physics.Motion;
+
namespace AcDream.Core.Physics;
///
@@ -15,57 +17,306 @@ public enum HoldKey : uint
/// event (jump, attack, etc.) layered on top of the continuous
/// forward/sidestep/turn axes.
///
-/// Retail packs each action as u16 command then
-/// u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0))
-/// (RawMotionState::Pack, 0x0051ed10, decomp lines ~293998-294010).
-///
-public readonly record struct RawMotionAction(ushort Command, ushort Stamp, bool Autonomous);
-
-///
-/// Pure-data mirror of retail's RawMotionState struct
-/// (acclient.h RawMotionState::PackBitfield, line 46474;
-/// packer at RawMotionState::Pack, 0x0051ed10).
+///
+/// Retail's in-memory action node (RawMotionState::AddAction,
+/// 0x0051e840, and InterpretedMotionState::AddAction, 0x0051e9e0 —
+/// both a 0x14-byte LListData-derived node) carries FOUR fields:
+/// motion (+4), speed (+8), action_stamp (+0xc),
+/// autonomous (+0x10). is the in-memory-only
+/// field the R3-W1 action FIFO needs for ApplyMotion's velocity
+/// bookkeeping; it is NOT part of the wire encoding.
+///
///
///
-/// This type carries the COMPLETE motion-state snapshot the way retail's
-/// CPhysicsObj::InqRawMotionState() would return it. The packer
-/// () compares every field against
-/// and only emits the fields that differ — see
-/// RawMotionState::Pack for the exact default-difference logic.
+/// Retail packs each action ON THE WIRE as u16 command then
+/// u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0))
+/// (RawMotionState::Pack, 0x0051ed10, decomp lines ~293998-294010)
+/// — // are
+/// exactly the packed triple; reads only
+/// those three.
+///
+///
+public readonly record struct RawMotionAction(
+ ushort Command,
+ ushort Stamp,
+ bool Autonomous,
+ float Speed = 1f);
+
+///
+/// R3-W1 — verbatim, full-field port of retail's RawMotionState
+/// (acclient.h RawMotionState::PackBitfield, line 46474; ctor
+/// RawMotionState::RawMotionState, 0x0051e7f0, decomp lines
+/// 293344-293361; packer at RawMotionState::Pack, 0x0051ed10).
+///
+///
+/// R3-W1 fold (closes J2): this type now ALSO serves as
+/// MotionInterpreter.RawState — the former LegacyRawMotionState
+/// (a flat 7-field struct with no action FIFO) is deleted. One raw-state
+/// type end to end: the physics-side motion interpreter mutates it via
+/// //
+/// /, and the SAME
+/// instance's is read by
+/// when building the outbound wire packet. Previously the packer's action
+/// list was always empty (register TS-24) because nothing populated it —
+/// starting R3-W2 (the add_to_queue/MotionDone port),
+/// CMotionInterp populates this list locally exactly like retail
+/// does, so the packed bytes will start reflecting real queued actions.
+/// W1 itself does not change what gets packed (no caller invokes
+/// yet) — only adds the capability + tests.
///
///
///
/// PURE DATA: no PacketWriter / GL / Net dependency. Lives in
-/// AcDream.Core.Physics so the (future) motion interpreter, which is
+/// AcDream.Core.Physics so the motion interpreter, which is
/// also Core.Physics, can populate it without taking a Core.Net dependency
/// (Code Structure Rule #2).
///
///
public sealed class RawMotionState
{
- public HoldKey CurrentHoldKey { get; init; } = HoldKey.None;
- public uint CurrentStyle { get; init; } = 0x8000003Du;
- public uint ForwardCommand { get; init; } = 0x41000003u;
- public HoldKey ForwardHoldKey { get; init; } = HoldKey.Invalid;
- public float ForwardSpeed { get; init; } = 1.0f;
- public uint SidestepCommand { get; init; } = 0u;
- public HoldKey SidestepHoldKey { get; init; } = HoldKey.Invalid;
- public float SidestepSpeed { get; init; } = 1.0f;
- public uint TurnCommand { get; init; } = 0u;
- public HoldKey TurnHoldKey { get; init; } = HoldKey.Invalid;
- public float TurnSpeed { get; init; } = 1.0f;
+ /// Retail current_holdkey (ctor default HoldKey_None).
+ public HoldKey CurrentHoldKey { get; set; } = HoldKey.None;
+ /// Retail current_style (ctor default 0x8000003D, NonCombat).
+ public uint CurrentStyle { get; set; } = 0x8000003Du;
+ /// Retail forward_command (ctor default 0x41000003, Ready).
+ public uint ForwardCommand { get; set; } = 0x41000003u;
+ /// Retail forward_holdkey (ctor default HoldKey_Invalid).
+ public HoldKey ForwardHoldKey { get; set; } = HoldKey.Invalid;
+ /// Retail forward_speed (ctor default 1.0).
+ public float ForwardSpeed { get; set; } = 1.0f;
+ /// Retail sidestep_command (ctor default 0).
+ public uint SidestepCommand { get; set; }
+ /// Retail sidestep_holdkey (ctor default HoldKey_Invalid).
+ public HoldKey SidestepHoldKey { get; set; } = HoldKey.Invalid;
+ /// Retail sidestep_speed (ctor default 1.0).
+ public float SidestepSpeed { get; set; } = 1.0f;
+ /// Retail turn_command (ctor default 0).
+ public uint TurnCommand { get; set; }
+ /// Retail turn_holdkey (ctor default HoldKey_Invalid).
+ public HoldKey TurnHoldKey { get; set; } = HoldKey.Invalid;
+ /// Retail turn_speed (ctor default 1.0).
+ public float TurnSpeed { get; set; } = 1.0f;
+
+ private readonly List _actions = new();
///
- /// Discrete action list. Retail packs num_actions (count, not the
+ /// Discrete action FIFO (retail actions, an LListData
+ /// tail-append queue). Retail packs num_actions (count, not the
/// values) into bits 11-15 of the flags dword, then emits each action's
- /// command+stamp pair unconditionally after the continuous-axis fields.
+ /// command+stamp pair unconditionally after the continuous-axis fields
+ /// ().
+ ///
+ ///
+ /// Settable via object-initializer for test fixtures / the wire-packer
+ /// call sites that build a one-shot snapshot; assigning replaces the
+ /// whole FIFO. / are
+ /// the retail-faithful mutators for live FIFO discipline.
+ ///
///
- public IReadOnlyList Actions { get; init; } = Array.Empty();
+ public IReadOnlyList Actions
+ {
+ get => _actions;
+ set
+ {
+ _actions.Clear();
+ _actions.AddRange(value);
+ }
+ }
///
/// Retail defaults — a field bit is set in the packed flags dword ONLY
/// when the live value differs from these (RawMotionState::Pack,
- /// 0x0051ed10).
+ /// 0x0051ed10). NEVER mutate this shared instance.
///
public static readonly RawMotionState Default = new();
+
+ ///
+ /// RawMotionState::AddAction (0x0051e840, decomp 293365-293392):
+ /// unconditional tail-append of
+ /// {motion, speed, action_stamp, autonomous}.
+ ///
+ /// Retail arg2 — the action motion id.
+ /// Retail arg3.
+ /// Retail arg4.
+ /// Retail arg5 (nonzero = autonomous).
+ public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous)
+ {
+ _actions.Add(new RawMotionAction(
+ Command: (ushort)motion,
+ Stamp: (ushort)actionStamp,
+ Autonomous: autonomous,
+ Speed: speed));
+ }
+
+ ///
+ /// RawMotionState::RemoveAction (0x0051e8a0, decomp 293396-293414):
+ /// pop the FIFO head unconditionally, returning its motion field
+ /// (0 when empty — retail returns *(head+4), i.e. the stored
+ /// arg2/Command, widened; the C# port returns the widened
+ /// as a uint).
+ ///
+ public uint RemoveAction()
+ {
+ if (_actions.Count == 0)
+ return 0;
+ var head = _actions[0];
+ _actions.RemoveAt(0);
+ return head.Command;
+ }
+
+ ///
+ /// RawMotionState::ApplyMotion (0x0051eb60, decomp 293630-293703).
+ /// Verbatim dispatch, quoted from the raw named decomp:
+ ///
+ /// if ((arg2 - 0x6500000d) > 3) { // outside [0x6500000d, 0x65000010]
+ /// if ((arg2 & 0x40000000) == 0) { // NOT forward-class
+ /// if (arg2 >= 0) { // NOT style-class (high bit clear)
+ /// if ((arg2 & 0x10000000) != 0)
+ /// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield>>0xc)&1);
+ /// } else if (current_style != arg2) {
+ /// forward_command = 0x41000003; current_style = arg2;
+ /// }
+ /// } else if (arg2 != 0x44000007) { // forward-class, NOT RunForward
+ /// forward_command = arg2;
+ /// if (params.bitfield byte1 & 8 != 0) { forward_holdkey = Invalid; forward_speed = params.speed; }
+ /// else { forward_holdkey = params.hold_key_to_apply; forward_speed = params.speed; }
+ /// }
+ /// // arg2 == 0x44000007 (RunForward) with the forward-class bit set: falls through, no write.
+ /// return;
+ /// }
+ /// switch (arg2) {
+ /// case 0x6500000d: case 0x6500000e: // TurnRight/TurnLeft
+ /// turn_command = arg2;
+ /// if (SetHoldKey bit) { turn_holdkey = Invalid; turn_speed = params.speed; }
+ /// else { turn_holdkey = params.hold_key_to_apply; turn_speed = params.speed; }
+ /// return;
+ /// case 0x6500000f: case 0x65000010: // SideStepRight/SideStepLeft
+ /// sidestep_command = arg2;
+ /// if (SetHoldKey bit) { sidestep_holdkey = Invalid; sidestep_speed = params.speed; }
+ /// else { sidestep_holdkey = params.hold_key_to_apply; sidestep_speed = params.speed; }
+ /// return;
+ /// }
+ ///
+ /// The arg2 == 0x44000007 (RunForward) no-write fallthrough on the
+ /// forward-class branch is a genuine retail quirk (RunForward bit
+ /// 0x40000000 IS set, but the `else if (arg2 != 0x44000007)` guard
+ /// excludes it) — ported verbatim, not "fixed".
+ ///
+ /// Retail arg2 — the ORIGINAL (pre-adjustment)
+ /// motion id per the DoMotion/StopMotion mirror-discipline callers.
+ /// Retail arg3 (MovementParameters const*).
+ public void ApplyMotion(uint motion, MovementParameters p)
+ {
+ if (motion - 0x6500000du > 3u)
+ {
+ if ((motion & 0x40000000u) == 0)
+ {
+ if (motion < 0x80000000u) // arg2 >= 0 as signed int32
+ {
+ if ((motion & 0x10000000u) != 0)
+ AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous);
+ }
+ else if (CurrentStyle != motion)
+ {
+ ForwardCommand = 0x41000003u;
+ CurrentStyle = motion;
+ }
+ }
+ else if (motion != 0x44000007u)
+ {
+ ForwardCommand = motion;
+ if (p.SetHoldKey)
+ {
+ ForwardHoldKey = HoldKey.Invalid;
+ ForwardSpeed = p.Speed;
+ }
+ else
+ {
+ ForwardHoldKey = p.HoldKeyToApply;
+ ForwardSpeed = p.Speed;
+ }
+ }
+ return;
+ }
+
+ switch (motion)
+ {
+ case 0x6500000du: // TurnRight
+ case 0x6500000eu: // TurnLeft
+ TurnCommand = motion;
+ if (p.SetHoldKey)
+ {
+ TurnHoldKey = HoldKey.Invalid;
+ TurnSpeed = p.Speed;
+ }
+ else
+ {
+ TurnHoldKey = p.HoldKeyToApply;
+ TurnSpeed = p.Speed;
+ }
+ return;
+ case 0x6500000fu: // SideStepRight
+ case 0x65000010u: // SideStepLeft
+ SidestepCommand = motion;
+ if (p.SetHoldKey)
+ {
+ SidestepHoldKey = HoldKey.Invalid;
+ SidestepSpeed = p.Speed;
+ }
+ else
+ {
+ SidestepHoldKey = p.HoldKeyToApply;
+ SidestepSpeed = p.Speed;
+ }
+ return;
+ }
+ }
+
+ ///
+ /// RawMotionState::RemoveMotion (0x0051e6e0, decomp 293252-293288):
+ ///
+ /// if ((arg2 - 0x6500000d) > 3) {
+ /// if ((arg2 & 0x40000000) == 0) {
+ /// if (arg2 < 0 && arg2 == current_style) current_style = 0x8000003d;
+ /// } else if (arg2 == forward_command) {
+ /// forward_command = 0x41000003; forward_speed = 1f;
+ /// }
+ /// return;
+ /// }
+ /// switch (arg2) {
+ /// case 0x6500000d: case 0x6500000e: turn_command = 0; return;
+ /// case 0x6500000f: case 0x65000010: sidestep_command = 0; return;
+ /// }
+ ///
+ ///
+ /// Retail arg2 — the ORIGINAL motion id.
+ public void RemoveMotion(uint motion)
+ {
+ if (motion - 0x6500000du > 3u)
+ {
+ if ((motion & 0x40000000u) == 0)
+ {
+ if (motion >= 0x80000000u && motion == CurrentStyle)
+ CurrentStyle = 0x8000003du;
+ }
+ else if (motion == ForwardCommand)
+ {
+ ForwardCommand = 0x41000003u;
+ ForwardSpeed = 1f;
+ }
+ return;
+ }
+
+ switch (motion)
+ {
+ case 0x6500000du:
+ case 0x6500000eu:
+ TurnCommand = 0;
+ return;
+ case 0x6500000fu:
+ case 0x65000010u:
+ SidestepCommand = 0;
+ return;
+ }
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/InterpretedMotionStateActionFifoTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/InterpretedMotionStateActionFifoTests.cs
new file mode 100644
index 00000000..68420929
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/InterpretedMotionStateActionFifoTests.cs
@@ -0,0 +1,217 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R3-W1 — action FIFO discipline + ApplyMotion/RemoveMotion
+/// field effects on (closes J2). Oracle:
+/// docs/research/named-retail/acclient_2013_pseudo_c.txt — verbatim bodies
+/// quoted in MotionInterpreter.cs doc comments:
+/// InterpretedMotionState::AddAction (0x0051e9e0), RemoveAction
+/// (0x0051ead0), GetNumActions (0x0051eb00), ApplyMotion
+/// (0x0051ea40), RemoveMotion (0x0051e790).
+///
+public sealed class InterpretedMotionStateActionFifoTests
+{
+ [Fact]
+ public void Default_HasEmptyActionsAndZeroCount()
+ {
+ var ims = InterpretedMotionState.Default();
+ Assert.Empty(ims.Actions);
+ Assert.Equal(0u, ims.GetNumActions());
+ }
+
+ // ── AddAction / RemoveAction / GetNumActions FIFO discipline ──────────
+
+ [Fact]
+ public void AddAction_AppendsInOrder_GetNumActionsCounts()
+ {
+ var ims = InterpretedMotionState.Default();
+ ims.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false);
+ ims.AddAction(0x10000050u, 1.5f, 2, autonomous: true);
+
+ Assert.Equal(2u, ims.GetNumActions());
+ Assert.Equal((ushort)0x004Bu, ims.Actions[0].Command);
+ Assert.Equal((ushort)0x0050u, ims.Actions[1].Command);
+ }
+
+ [Fact]
+ public void RemoveAction_PopsHeadFirst_FifoOrder()
+ {
+ var ims = InterpretedMotionState.Default();
+ ims.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false);
+ ims.AddAction(0x10000050u, 1.5f, 2, autonomous: true);
+
+ uint first = ims.RemoveAction();
+ uint second = ims.RemoveAction();
+
+ Assert.Equal(0x004Bu, first);
+ Assert.Equal(0x0050u, second);
+ Assert.Equal(0u, ims.GetNumActions());
+ }
+
+ [Fact]
+ public void RemoveAction_Empty_ReturnsZero()
+ {
+ var ims = InterpretedMotionState.Default();
+ Assert.Equal(0u, ims.RemoveAction());
+ }
+
+ [Fact]
+ public void GetNumActions_DefaultStruct_NoNullRef()
+ {
+ // Defensive: a bare default(InterpretedMotionState) (bypassing
+ // .Default()) must not NRE on GetNumActions/Actions/RemoveAction —
+ // the lazy-list field starts null.
+ InterpretedMotionState bare = default;
+ Assert.Equal(0u, bare.GetNumActions());
+ Assert.Empty(bare.Actions);
+ Assert.Equal(0u, bare.RemoveAction());
+ }
+
+ // ── DoMotion's depth-cap gate consumes this directly (documentation) ──
+
+ [Fact]
+ public void GetNumActions_SixQueued_MeetsDoMotionDepthCapThreshold()
+ {
+ // DoMotion (0x00528d20 @306159) rejects with 0x45 when an
+ // action-class motion arrives AND GetNumActions() >= 6. W1 only
+ // proves the counter is correct; the gate itself lands in W5.
+ var ims = InterpretedMotionState.Default();
+ for (uint i = 0; i < 6; i++)
+ ims.AddAction(0x10000000u + i, 1.0f, i, false);
+
+ Assert.Equal(6u, ims.GetNumActions());
+ Assert.True(ims.GetNumActions() >= 6);
+ }
+
+ // ── ApplyMotion field effects (0x0051ea40) ─────────────────────────────
+
+ [Fact]
+ public void ApplyMotion_TurnRight_SetsTurnCommandAndSpeed()
+ {
+ var ims = InterpretedMotionState.Default();
+ var p = new MovementParameters { Speed = 1.5f };
+
+ ims.ApplyMotion(0x6500000du, p);
+
+ Assert.Equal(0x6500000du, ims.TurnCommand);
+ Assert.Equal(1.5f, ims.TurnSpeed);
+ }
+
+ [Fact]
+ public void ApplyMotion_SideStepRight_SetsSideStepCommandAndSpeed()
+ {
+ var ims = InterpretedMotionState.Default();
+ var p = new MovementParameters { Speed = 1.248f };
+
+ ims.ApplyMotion(0x6500000fu, p);
+
+ Assert.Equal(0x6500000fu, ims.SideStepCommand);
+ Assert.Equal(1.248f, ims.SideStepSpeed);
+ }
+
+ [Fact]
+ public void ApplyMotion_ForwardClassMotion_SetsForwardCommandAndSpeed()
+ {
+ // Unlike RawMotionState::ApplyMotion, InterpretedMotionState's
+ // forward-class branch has NO RunForward exclusion — every
+ // forward-class id (bit 0x40000000) writes forward_command.
+ var ims = InterpretedMotionState.Default();
+ var p = new MovementParameters { Speed = 2.94f };
+
+ ims.ApplyMotion(0x44000007u, p); // RunForward
+
+ Assert.Equal(0x44000007u, ims.ForwardCommand);
+ Assert.Equal(2.94f, ims.ForwardSpeed);
+ }
+
+ [Fact]
+ public void ApplyMotion_StyleClassMotion_ResetsForwardToReady_SetsCurrentStyle()
+ {
+ var ims = InterpretedMotionState.Default();
+ ims.ForwardCommand = 0x45000005u;
+ var p = new MovementParameters();
+
+ ims.ApplyMotion(0x80000042u, p);
+
+ Assert.Equal(0x41000003u, ims.ForwardCommand);
+ Assert.Equal(0x80000042u, ims.CurrentStyle);
+ }
+
+ [Fact]
+ public void ApplyMotion_ActionClassMotion_AddsAction()
+ {
+ var ims = InterpretedMotionState.Default();
+ var p = new MovementParameters { Speed = 1.0f, ActionStamp = 7u, Autonomous = true };
+
+ ims.ApplyMotion(0x1000004Bu, p);
+
+ Assert.Equal(1u, ims.GetNumActions());
+ var a = ims.Actions[0];
+ Assert.Equal((ushort)0x004Bu, a.Command);
+ Assert.Equal(1.0f, a.Speed);
+ Assert.Equal((ushort)7u, a.Stamp);
+ Assert.True(a.Autonomous);
+ }
+
+ // ── RemoveMotion field effects (0x0051e790) ────────────────────────────
+
+ [Fact]
+ public void RemoveMotion_TurnRightExact_ClearsTurnCommand()
+ {
+ var ims = InterpretedMotionState.Default();
+ ims.TurnCommand = 0x6500000du;
+ ims.RemoveMotion(0x6500000du);
+ Assert.Equal(0u, ims.TurnCommand);
+ }
+
+ [Fact]
+ public void RemoveMotion_TurnLeftExact_DoesNotMatchTurnBranch_FallsThroughToStyleCheck()
+ {
+ // Asymmetric vs RawMotionState::RemoveMotion: InterpretedMotionState
+ // only exact-matches 0x6500000d (TurnRight) for the turn branch, NOT
+ // 0x6500000e (TurnLeft) — verbatim per the raw decomp.
+ var ims = InterpretedMotionState.Default();
+ ims.TurnCommand = 0x6500000eu;
+ ims.RemoveMotion(0x6500000eu);
+ // Falls through: bit 0x40000000 clear, arg2 (0x6500000e) is not
+ // negative and != current_style (0x8000003D default) -> no-op.
+ Assert.Equal(0x6500000eu, ims.TurnCommand); // untouched
+ }
+
+ [Fact]
+ public void RemoveMotion_SideStepRightExact_ClearsSideStepCommand()
+ {
+ var ims = InterpretedMotionState.Default();
+ ims.SideStepCommand = 0x6500000fu;
+ ims.RemoveMotion(0x6500000fu);
+ Assert.Equal(0u, ims.SideStepCommand);
+ }
+
+ [Fact]
+ public void RemoveMotion_ForwardClassMotion_MatchingCommand_ResetsToReady()
+ {
+ var ims = InterpretedMotionState.Default();
+ ims.ForwardCommand = 0x45000005u;
+ ims.ForwardSpeed = 3.0f;
+
+ ims.RemoveMotion(0x45000005u);
+
+ Assert.Equal(0x41000003u, ims.ForwardCommand);
+ Assert.Equal(1f, ims.ForwardSpeed);
+ }
+
+ [Fact]
+ public void RemoveMotion_StyleClassMotion_MatchingCurrentStyle_ResetsToNonCombat()
+ {
+ var ims = InterpretedMotionState.Default();
+ ims.CurrentStyle = 0x80000042u;
+
+ ims.RemoveMotion(0x80000042u);
+
+ Assert.Equal(0x8000003du, ims.CurrentStyle);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionNodeTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionNodeTests.cs
new file mode 100644
index 00000000..78fa2bcf
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MotionNodeTests.cs
@@ -0,0 +1,33 @@
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R3-W1 — CMotionInterp::MotionNode shape pin. Oracle:
+/// acclient.h:53293 (struct #5857). W2 consumes this as the
+/// pending_motions element type; W1 only ports the shape.
+///
+public sealed class MotionNodeTests
+{
+ [Fact]
+ public void Ctor_StoresAllThreeFields()
+ {
+ var node = new MotionNode(ContextId: 7u, Motion: 0x41000003u, JumpErrorCode: 0x48u);
+
+ Assert.Equal(7u, node.ContextId);
+ Assert.Equal(0x41000003u, node.Motion);
+ Assert.Equal(0x48u, node.JumpErrorCode);
+ }
+
+ [Fact]
+ public void Equality_IsValueBased()
+ {
+ var a = new MotionNode(1u, 2u, 3u);
+ var b = new MotionNode(1u, 2u, 3u);
+ var c = new MotionNode(1u, 2u, 4u);
+
+ Assert.Equal(a, b);
+ Assert.NotEqual(a, c);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTests.cs
new file mode 100644
index 00000000..6171f2d2
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTests.cs
@@ -0,0 +1,112 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R3-W1 — MovementParameters ctor-default pins. Oracle:
+/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A4 (bitfield 0x1EE0F
+/// expansion) + r3-motioninterp-decomp.md §0 (scalar ctor, raw 300510-300534,
+/// 0x00524380). Every flag asserted individually against the retail
+/// 0x1EE0F default so a future bit-numbering slip fails loudly, plus the two
+/// ACE-divergence traps (CanCharge, WalkRunThreshhold) get dedicated tests.
+///
+public sealed class MovementParametersTests
+{
+ [Fact]
+ public void Ctor_SetFlags_MatchRetail0x1EE0FExpansion()
+ {
+ var p = new MovementParameters();
+
+ // Bits SET in 0x1EE0F: 0x1,0x2,0x4,0x8,0x200,0x400,0x800,0x2000,0x4000,0x8000,0x10000
+ Assert.True(p.CanWalk); // 0x1
+ Assert.True(p.CanRun); // 0x2
+ Assert.True(p.CanSidestep); // 0x4
+ Assert.True(p.CanWalkBackwards); // 0x8
+ Assert.True(p.MoveTowards); // 0x200
+ Assert.True(p.UseSpheres); // 0x400
+ Assert.True(p.SetHoldKey); // 0x800
+ Assert.True(p.ModifyRawState); // 0x2000
+ Assert.True(p.ModifyInterpretedState); // 0x4000
+ Assert.True(p.CancelMoveTo); // 0x8000
+ Assert.True(p.StopCompletelyFlag); // 0x10000
+ }
+
+ [Fact]
+ public void Ctor_ClearFlags_MatchRetail0x1EE0FExpansion()
+ {
+ var p = new MovementParameters();
+
+ // Bits CLEAR in 0x1EE0F: 0x10,0x20,0x40,0x80,0x100,0x1000,0x20000
+ Assert.False(p.CanCharge); // 0x10 — ACE-divergence trap
+ Assert.False(p.FailWalk); // 0x20
+ Assert.False(p.UseFinalHeading); // 0x40
+ Assert.False(p.Sticky); // 0x80
+ Assert.False(p.MoveAway); // 0x100
+ Assert.False(p.Autonomous); // 0x1000
+ Assert.False(p.DisableJumpDuringLink); // 0x20000
+ }
+
+ [Fact]
+ public void Ctor_Bitfield_ReconstitutesExactly0x1EE0F()
+ {
+ var p = new MovementParameters();
+
+ uint bitfield = 0;
+ if (p.CanWalk) bitfield |= 0x1;
+ if (p.CanRun) bitfield |= 0x2;
+ if (p.CanSidestep) bitfield |= 0x4;
+ if (p.CanWalkBackwards) bitfield |= 0x8;
+ if (p.CanCharge) bitfield |= 0x10;
+ if (p.FailWalk) bitfield |= 0x20;
+ if (p.UseFinalHeading) bitfield |= 0x40;
+ if (p.Sticky) bitfield |= 0x80;
+ if (p.MoveAway) bitfield |= 0x100;
+ if (p.MoveTowards) bitfield |= 0x200;
+ if (p.UseSpheres) bitfield |= 0x400;
+ if (p.SetHoldKey) bitfield |= 0x800;
+ if (p.Autonomous) bitfield |= 0x1000;
+ if (p.ModifyRawState) bitfield |= 0x2000;
+ if (p.ModifyInterpretedState) bitfield |= 0x4000;
+ if (p.CancelMoveTo) bitfield |= 0x8000;
+ if (p.StopCompletelyFlag) bitfield |= 0x10000;
+ if (p.DisableJumpDuringLink) bitfield |= 0x20000;
+
+ Assert.Equal(0x1EE0Fu, bitfield);
+ }
+
+ [Fact]
+ public void Ctor_ScalarDefaults_MatchRetail()
+ {
+ var p = new MovementParameters();
+
+ Assert.Equal(0f, p.MinDistance);
+ Assert.Equal(0.6f, p.DistanceToObject);
+ Assert.Equal(float.MaxValue, p.FailDistance);
+ Assert.Equal(0f, p.DesiredHeading);
+ Assert.Equal(1f, p.Speed);
+ // ACE-divergence trap: retail is 15.0, NOT ACE's 1.0.
+ Assert.Equal(15f, p.WalkRunThreshhold);
+ Assert.Equal(0u, p.ContextId);
+ Assert.Equal(HoldKey.Invalid, p.HoldKeyToApply);
+ Assert.Equal(0u, p.ActionStamp);
+ }
+
+ [Fact]
+ public void Ctor_CanCharge_DefaultsFalse_NotAceTrue()
+ {
+ // Dedicated regression test for the single highest-risk ACE trap:
+ // ACE's MovementParameters.cs:58 sets CanCharge = true at construction.
+ // Retail's 0x1EE0F has bit 0x10 CLEAR.
+ var p = new MovementParameters();
+ Assert.False(p.CanCharge);
+ }
+
+ [Fact]
+ public void Ctor_WalkRunThreshhold_Is15_NotAce1()
+ {
+ var p = new MovementParameters();
+ Assert.Equal(15f, p.WalkRunThreshhold);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RawMotionStateActionFifoTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RawMotionStateActionFifoTests.cs
new file mode 100644
index 00000000..cd27ee7c
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/RawMotionStateActionFifoTests.cs
@@ -0,0 +1,244 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R3-W1 — action FIFO discipline + ApplyMotion/RemoveMotion
+/// field effects on (closes J2). Oracle:
+/// docs/research/named-retail/acclient_2013_pseudo_c.txt — verbatim bodies
+/// quoted in RawMotionState.cs doc comments:
+/// RawMotionState::AddAction (0x0051e840), RemoveAction
+/// (0x0051e8a0), ApplyMotion (0x0051eb60), RemoveMotion
+/// (0x0051e6e0).
+///
+public sealed class RawMotionStateActionFifoTests
+{
+ // ── AddAction / RemoveAction / GetNumActions FIFO discipline ──────────
+
+ [Fact]
+ public void AddAction_AppendsInOrder()
+ {
+ var raw = new RawMotionState();
+ raw.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false);
+ raw.AddAction(0x10000050u, 1.5f, 2, autonomous: true);
+
+ Assert.Equal(2, raw.Actions.Count);
+ Assert.Equal((ushort)0x004Bu, raw.Actions[0].Command); // widened to ushort on wire, verified below
+ Assert.Equal((ushort)0x0050u, raw.Actions[1].Command);
+ }
+
+ [Fact]
+ public void RemoveAction_PopsHeadFirst_FifoOrder()
+ {
+ var raw = new RawMotionState();
+ raw.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false);
+ raw.AddAction(0x10000050u, 1.5f, 2, autonomous: true);
+
+ uint first = raw.RemoveAction();
+ uint second = raw.RemoveAction();
+
+ Assert.Equal(0x004Bu, first); // head popped first (FIFO)
+ Assert.Equal(0x0050u, second);
+ Assert.Empty(raw.Actions);
+ }
+
+ [Fact]
+ public void RemoveAction_Empty_ReturnsZero()
+ {
+ var raw = new RawMotionState();
+ Assert.Equal(0u, raw.RemoveAction());
+ }
+
+ [Fact]
+ public void AddAction_StoresSpeedStampAutonomous()
+ {
+ var raw = new RawMotionState();
+ raw.AddAction(0x1000004Bu, speed: 2.5f, actionStamp: 0x7FFFu, autonomous: true);
+
+ var a = raw.Actions[0];
+ Assert.Equal(2.5f, a.Speed);
+ Assert.Equal((ushort)0x7FFFu, a.Stamp);
+ Assert.True(a.Autonomous);
+ }
+
+ // ── ApplyMotion field effects (0x0051eb60) ─────────────────────────────
+
+ [Fact]
+ public void ApplyMotion_TurnRight_SetsTurnCommandAndSpeed_HonorsSetHoldKeyBit()
+ {
+ var raw = new RawMotionState();
+ var p = new MovementParameters { Speed = 1.5f, SetHoldKey = true };
+
+ raw.ApplyMotion(0x6500000du, p); // TurnRight
+
+ Assert.Equal(0x6500000du, raw.TurnCommand);
+ Assert.Equal(1.5f, raw.TurnSpeed);
+ Assert.Equal(HoldKey.Invalid, raw.TurnHoldKey); // SetHoldKey bit set -> Invalid
+ }
+
+ [Fact]
+ public void ApplyMotion_TurnRight_SetHoldKeyClear_UsesHoldKeyToApply()
+ {
+ var raw = new RawMotionState();
+ var p = new MovementParameters { Speed = 1.5f, SetHoldKey = false, HoldKeyToApply = HoldKey.Run };
+
+ raw.ApplyMotion(0x6500000du, p);
+
+ Assert.Equal(HoldKey.Run, raw.TurnHoldKey);
+ }
+
+ [Fact]
+ public void ApplyMotion_SideStepRight_SetsSidestepCommandAndSpeed()
+ {
+ var raw = new RawMotionState();
+ var p = new MovementParameters { Speed = 1.248f, SetHoldKey = true };
+
+ raw.ApplyMotion(0x6500000fu, p); // SideStepRight
+
+ Assert.Equal(0x6500000fu, raw.SidestepCommand);
+ Assert.Equal(1.248f, raw.SidestepSpeed);
+ Assert.Equal(HoldKey.Invalid, raw.SidestepHoldKey);
+ }
+
+ [Fact]
+ public void ApplyMotion_ForwardClassMotion_SetsForwardCommandAndSpeed()
+ {
+ // WalkForward = 0x45000005 has bit 0x40000000 set (forward-class)
+ // and is NOT 0x44000007 (RunForward) -> the write branch fires.
+ var raw = new RawMotionState();
+ var p = new MovementParameters { Speed = 1.0f, SetHoldKey = true };
+
+ raw.ApplyMotion(0x45000005u, p);
+
+ Assert.Equal(0x45000005u, raw.ForwardCommand);
+ Assert.Equal(1.0f, raw.ForwardSpeed);
+ Assert.Equal(HoldKey.Invalid, raw.ForwardHoldKey);
+ }
+
+ [Fact]
+ public void ApplyMotion_RunForwardExactId_ForwardClassButExcluded_NoWrite()
+ {
+ // Verbatim retail quirk (0x0051eb60): arg2 == 0x44000007 (RunForward)
+ // with the forward-class bit (0x40000000) set falls through BOTH
+ // inner branches — no field write occurs. Port verbatim, not fixed.
+ var raw = new RawMotionState();
+ var before = raw.ForwardCommand;
+ var p = new MovementParameters { Speed = 3.0f };
+
+ raw.ApplyMotion(0x44000007u, p);
+
+ Assert.Equal(before, raw.ForwardCommand); // untouched
+ Assert.Equal(1.0f, raw.ForwardSpeed); // untouched (still ctor default)
+ }
+
+ [Fact]
+ public void ApplyMotion_StyleClassMotion_SetsCurrentStyleAndResetsForwardToReady()
+ {
+ // High bit set (>= 0x80000000) and current_style differs -> style branch.
+ var raw = new RawMotionState { ForwardCommand = 0x45000005u };
+ var p = new MovementParameters();
+
+ raw.ApplyMotion(0x80000042u, p);
+
+ Assert.Equal(0x41000003u, raw.ForwardCommand); // reset to Ready
+ Assert.Equal(0x80000042u, raw.CurrentStyle);
+ }
+
+ [Fact]
+ public void ApplyMotion_StyleClassMotion_SameAsCurrentStyle_NoOp()
+ {
+ var raw = new RawMotionState { CurrentStyle = 0x80000042u, ForwardCommand = 0x45000005u };
+ var p = new MovementParameters();
+
+ raw.ApplyMotion(0x80000042u, p);
+
+ // current_style already equals arg2 -> the style branch's condition
+ // (current_style != arg2) is false, so forward_command is untouched.
+ Assert.Equal(0x45000005u, raw.ForwardCommand);
+ Assert.Equal(0x80000042u, raw.CurrentStyle);
+ }
+
+ [Fact]
+ public void ApplyMotion_ActionClassMotion_AddsAction()
+ {
+ // Outside turn/sidestep range, bit 0x40000000 clear, arg2 >= 0
+ // (not style-class), bit 0x10000000 set -> AddAction.
+ var raw = new RawMotionState();
+ var p = new MovementParameters { Speed = 1.0f, ActionStamp = 42u, Autonomous = true };
+
+ raw.ApplyMotion(0x1000004Bu, p); // Jumpup action id
+
+ Assert.Single(raw.Actions);
+ var a = raw.Actions[0];
+ Assert.Equal((ushort)0x004Bu, a.Command);
+ Assert.Equal(1.0f, a.Speed);
+ Assert.Equal((ushort)42u, a.Stamp);
+ Assert.True(a.Autonomous);
+ }
+
+ // ── RemoveMotion field effects (0x0051e6e0) ────────────────────────────
+
+ [Fact]
+ public void RemoveMotion_TurnRange_ClearsTurnCommand()
+ {
+ var raw = new RawMotionState { TurnCommand = 0x6500000du };
+ raw.RemoveMotion(0x6500000du);
+ Assert.Equal(0u, raw.TurnCommand);
+ }
+
+ [Fact]
+ public void RemoveMotion_TurnLeftRange_ClearsTurnCommand()
+ {
+ var raw = new RawMotionState { TurnCommand = 0x6500000eu };
+ raw.RemoveMotion(0x6500000eu);
+ Assert.Equal(0u, raw.TurnCommand);
+ }
+
+ [Fact]
+ public void RemoveMotion_SidestepRange_ClearsSidestepCommand()
+ {
+ var raw = new RawMotionState { SidestepCommand = 0x6500000fu };
+ raw.RemoveMotion(0x6500000fu);
+ Assert.Equal(0u, raw.SidestepCommand);
+ }
+
+ [Fact]
+ public void RemoveMotion_ForwardClassMotion_MatchingCommand_ResetsToReady()
+ {
+ var raw = new RawMotionState { ForwardCommand = 0x45000005u, ForwardSpeed = 3.0f };
+ raw.RemoveMotion(0x45000005u);
+
+ Assert.Equal(0x41000003u, raw.ForwardCommand);
+ Assert.Equal(1f, raw.ForwardSpeed);
+ }
+
+ [Fact]
+ public void RemoveMotion_ForwardClassMotion_NonMatchingCommand_NoOp()
+ {
+ var raw = new RawMotionState { ForwardCommand = 0x45000005u, ForwardSpeed = 3.0f };
+ raw.RemoveMotion(0x44000007u); // different forward-class id
+
+ Assert.Equal(0x45000005u, raw.ForwardCommand); // untouched
+ Assert.Equal(3.0f, raw.ForwardSpeed);
+ }
+
+ [Fact]
+ public void RemoveMotion_StyleClassMotion_MatchingCurrentStyle_ResetsToNonCombat()
+ {
+ var raw = new RawMotionState { CurrentStyle = 0x80000042u };
+ raw.RemoveMotion(0x80000042u);
+
+ Assert.Equal(0x8000003du, raw.CurrentStyle); // reset to NonCombat
+ }
+
+ [Fact]
+ public void RemoveMotion_StyleClassMotion_NonMatchingCurrentStyle_NoOp()
+ {
+ var raw = new RawMotionState { CurrentStyle = 0x80000042u };
+ raw.RemoveMotion(0x80000099u); // different style id
+
+ Assert.Equal(0x80000042u, raw.CurrentStyle); // untouched
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
index 7c3cb769..2ed8c38b 100644
--- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs
@@ -176,7 +176,7 @@ public sealed class MotionInterpreterTests
{
var interp = MakeInterp();
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
- interp.RawState.SideStepCommand = MotionCommand.SideStepRight;
+ interp.RawState.SidestepCommand = MotionCommand.SideStepRight;
interp.RawState.TurnCommand = MotionCommand.TurnRight;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.TurnCommand = MotionCommand.TurnLeft;
@@ -186,7 +186,7 @@ public sealed class MotionInterpreterTests
Assert.Equal(WeenieError.None, result);
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
Assert.Equal(1.0f, interp.RawState.ForwardSpeed, precision: 5);
- Assert.Equal(0u, interp.RawState.SideStepCommand);
+ Assert.Equal(0u, interp.RawState.SidestepCommand);
Assert.Equal(0u, interp.RawState.TurnCommand);
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
Assert.Equal(1.0f, interp.InterpretedState.ForwardSpeed, precision: 5);
@@ -440,14 +440,16 @@ public sealed class MotionInterpreterTests
}
[Fact]
- public void Jump_Airborne_ReturnsYouCantJumpWhileInTheAir()
+ public void Jump_Airborne_ReturnsNotGrounded()
{
+ // R3-W1 renumber (A10): airborne is 0x24 (NotGrounded), not 0x48
+ // (0x48 is now reserved for the motion_allows_jump blocklist).
var body = MakeAirborne();
var interp = MakeInterp(body);
var result = interp.jump(0.5f);
- Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result);
+ Assert.Equal(WeenieError.NotGrounded, result);
}
[Fact]
@@ -586,13 +588,13 @@ public sealed class MotionInterpreterTests
}
[Fact]
- public void JumpIsAllowed_Airborne_ReturnsYouCantJumpWhileInTheAir()
+ public void JumpIsAllowed_Airborne_ReturnsNotGrounded()
{
var interp = MakeInterp(MakeAirborne());
var result = interp.jump_is_allowed(0.5f, 0);
- Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result);
+ Assert.Equal(WeenieError.NotGrounded, result);
}
[Fact]
@@ -607,7 +609,7 @@ public sealed class MotionInterpreterTests
}
[Fact]
- public void JumpIsAllowed_NoGravityFlag_ReturnsYouCantJumpWhileInTheAir()
+ public void JumpIsAllowed_NoGravityFlag_ReturnsNotGrounded()
{
var body = new PhysicsBody
{
@@ -619,17 +621,20 @@ public sealed class MotionInterpreterTests
// No gravity → must be airborne-style
var result = interp.jump_is_allowed(0.5f, 0);
- Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result);
+ Assert.Equal(WeenieError.NotGrounded, result);
}
[Fact]
- public void JumpIsAllowed_NullPhysicsObj_ReturnsGeneralFailure()
+ public void JumpIsAllowed_NullPhysicsObj_ReturnsNotGrounded()
{
+ // A10 note: jump_is_allowed returns 0x24 (NotGrounded), NOT 8, when
+ // physics_obj == null — the "8 = no physics obj" convention that
+ // holds everywhere else in CMotionInterp does not hold here.
var interp = new MotionInterpreter();
var result = interp.jump_is_allowed(0.5f, 0);
- Assert.Equal(WeenieError.GeneralMovementFailure, result);
+ Assert.Equal(WeenieError.NotGrounded, result);
}
// =========================================================================
diff --git a/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs b/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs
new file mode 100644
index 00000000..383e8b34
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs
@@ -0,0 +1,81 @@
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// R3-W1 — pins 's numeric values against the
+/// definitive retail table in
+/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A10 (an exhaustive
+/// sweep of every return <code> site across raw 304908-306277 +
+/// 300150-300540: 19 return sites + 1 store site, all attributed). These
+/// codes are local-only (never on the wire), so the renumber from the
+/// pre-R3 shuffled names is a safe rename — this test is the single
+/// source of truth that a future edit can't silently un-pin.
+///
+public sealed class WeenieErrorCodeTableTests
+{
+ [Fact]
+ public void None_Is0x00()
+ => Assert.Equal(0x00u, (uint)WeenieError.None);
+
+ [Fact]
+ public void NoPhysicsObject_Is0x08()
+ => Assert.Equal(0x08u, (uint)WeenieError.NoPhysicsObject);
+
+ [Fact]
+ public void NotGrounded_Is0x24()
+ => Assert.Equal(0x24u, (uint)WeenieError.NotGrounded);
+
+ [Fact]
+ public void CrouchInCombatStance_Is0x3f()
+ => Assert.Equal(0x3fu, (uint)WeenieError.CrouchInCombatStance);
+
+ [Fact]
+ public void SitInCombatStance_Is0x40()
+ => Assert.Equal(0x40u, (uint)WeenieError.SitInCombatStance);
+
+ [Fact]
+ public void SleepInCombatStance_Is0x41()
+ => Assert.Equal(0x41u, (uint)WeenieError.SleepInCombatStance);
+
+ [Fact]
+ public void ChatEmoteOutsideNonCombat_Is0x42()
+ => Assert.Equal(0x42u, (uint)WeenieError.ChatEmoteOutsideNonCombat);
+
+ [Fact]
+ public void ActionDepthExceeded_Is0x45()
+ => Assert.Equal(0x45u, (uint)WeenieError.ActionDepthExceeded);
+
+ [Fact]
+ public void GeneralMovementFailure_Is0x47()
+ => Assert.Equal(0x47u, (uint)WeenieError.GeneralMovementFailure);
+
+ [Fact]
+ public void YouCantJumpFromThisPosition_Is0x48()
+ => Assert.Equal(0x48u, (uint)WeenieError.YouCantJumpFromThisPosition);
+
+ [Fact]
+ public void CantJumpLoadedDown_Is0x49()
+ => Assert.Equal(0x49u, (uint)WeenieError.CantJumpLoadedDown);
+
+ ///
+ /// Every code in the A10 table in one pass — guards against a
+ /// future partial edit desyncing an individual test above from the
+ /// enum declaration.
+ ///
+ [Theory]
+ [InlineData(WeenieError.None, 0x00u)]
+ [InlineData(WeenieError.NoPhysicsObject, 0x08u)]
+ [InlineData(WeenieError.NotGrounded, 0x24u)]
+ [InlineData(WeenieError.CrouchInCombatStance, 0x3fu)]
+ [InlineData(WeenieError.SitInCombatStance, 0x40u)]
+ [InlineData(WeenieError.SleepInCombatStance, 0x41u)]
+ [InlineData(WeenieError.ChatEmoteOutsideNonCombat, 0x42u)]
+ [InlineData(WeenieError.ActionDepthExceeded, 0x45u)]
+ [InlineData(WeenieError.GeneralMovementFailure, 0x47u)]
+ [InlineData(WeenieError.YouCantJumpFromThisPosition, 0x48u)]
+ [InlineData(WeenieError.CantJumpLoadedDown, 0x49u)]
+ public void A10Table_EveryCode_MatchesRetailNumericValue(WeenieError code, uint expected)
+ => Assert.Equal(expected, (uint)code);
+}