diff --git a/src/AcDream.Core/Physics/Motion/MoveToMath.cs b/src/AcDream.Core/Physics/Motion/MoveToMath.cs
new file mode 100644
index 00000000..ab30efaa
--- /dev/null
+++ b/src/AcDream.Core/Physics/Motion/MoveToMath.cs
@@ -0,0 +1,201 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.Physics.Motion;
+
+///
+/// R4-V1 — pure-math free functions consumed by the (future) MoveToManager
+/// port: heading_diff, heading_greater, Position::heading
+/// / Frame::get_heading / Frame::set_heading, and
+/// Position::cylinder_distance. No GL/App dependency — Core-only,
+/// per the Code Structure Rules. NAME WATCH: this file (not
+/// MoveToManager.cs, per r4-port-plan.md §3 "New code target") is the
+/// R4-V1 deliverable; the manager itself is R4-V2.
+///
+public static class MoveToMath
+{
+ ///
+ /// Universal heading/distance epsilon (same literal as R3's A5/A6 —
+ /// r4-moveto-decomp.md §12 constants inventory).
+ ///
+ public const float Epsilon = 0.000199999995f;
+
+ ///
+ /// Retail heading_diff (0x00528fb0, free function, raw
+ /// 306327-306347), PINNED by direct disassembly of the PDB-matched
+ /// retail binary (ghidra-confirmations.md §P3 — the strongest evidence
+ /// tier in the R4 pin set):
+ ///
+ /// d = h1 - h2;
+ /// if (fabs(h1 - h2) < F_EPSILON) d = 0;
+ /// if (d < -F_EPSILON) d += 360;
+ /// if (F_EPSILON < d && turnCmd != TurnRight) d = 360 - d; // the mirror
+ /// return d;
+ ///
+ /// The mirror gates on the turn command NOT being TurnRight
+ /// (0x6500000d) — TurnLeft (and any other command) measures the
+ /// COMPLEMENTARY angle. This CONTRADICTS r4-moveto-decomp.md §5g's
+ /// "arg3 UNUSED" claim, which the Ghidra disassembly pin overrides
+ /// (V0-pins.md §P3 adjudication). Call sites: BeginTurnToHeading
+ /// passes the CONSTANT TurnRight (mirror explicitly disabled — the
+ /// direction pick stays the ≤180 test elsewhere); HandleTurnToHeading
+ /// passes the LIVE current_command (can be TurnLeft).
+ ///
+ /// First heading, degrees.
+ /// Second heading, degrees.
+ /// The active turn command id — gates the mirror.
+ /// Normalized heading difference, degrees.
+ public static float HeadingDiff(float h1, float h2, uint turnCmd)
+ {
+ float d = h1 - h2;
+ if (MathF.Abs(h1 - h2) < Epsilon)
+ {
+ d = 0f;
+ }
+ if (d < -Epsilon)
+ {
+ d += 360f;
+ }
+ if (Epsilon < d && turnCmd != MotionCommand.TurnRight)
+ {
+ d = 360f - d;
+ }
+ return d;
+ }
+
+ ///
+ /// Retail heading_greater (00528f60, free function, raw
+ /// 306281-306323), verbatim per r4-moveto-decomp.md §5f:
+ ///
+ /// if (fabs(a - b) > 180) greater = (b > a); // wrapped case: compare flipped
+ /// else greater = (a > b);
+ /// if (turnCmd == TurnRight) return greater;
+ /// return !greater; // TurnLeft (and any other cmd): inverted
+ ///
+ /// "Has the turn passed the target heading" — direction-aware,
+ /// 360°-wrap-aware. The visible TurnRight-arg idiom: the gate is
+ /// == TurnRight (not != TurnRight as in
+ /// 's mirror) — every OTHER command inverts,
+ /// not just TurnLeft specifically.
+ ///
+ public static bool HeadingGreater(float a, float b, uint turnCmd)
+ {
+ bool greater = MathF.Abs(a - b) > 180f
+ ? b > a
+ : a > b;
+
+ return turnCmd == MotionCommand.TurnRight ? greater : !greater;
+ }
+
+ ///
+ /// Retail Position::heading(from, to) (0x005a9520, raw
+ /// 438288-438290), PINNED per V0-pins.md §P5: compass degrees, 0 =
+ /// North (+Y), 90 = East (+X), CLOCKWISE, range [0,360).
+ ///
+ /// heading(from, to) = (450 - atan2Deg(dy, dx)) % 360
+ ///
+ /// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270.
+ /// Horizontal (X/Y) only — Z (height) does not participate, matching
+ /// retail's compass-heading semantics. An in-tree twin of this formula
+ /// already exists at SceneryHelpers.cs:75 (render-side,
+ /// independently verified — not reused directly to keep this file
+ /// GL-free per the Code Structure Rules, but the formula is identical).
+ ///
+ public static float PositionHeading(Vector3 from, Vector3 to)
+ {
+ float dx = to.X - from.X;
+ float dy = to.Y - from.Y;
+ float headingDeg = 450f - MathF.Atan2(dy, dx) * (180f / MathF.PI);
+ headingDeg %= 360f;
+ if (headingDeg < 0f) headingDeg += 360f;
+ return headingDeg;
+ }
+
+ ///
+ /// Retail Frame::get_heading (0x00535760, raw 319781) —
+ /// extracts the compass heading (P5 convention) from a body orientation
+ /// quaternion. The packer-reuse trap (V0-pins §P5 correction):
+ /// acdream's outbound packer (GameWindow.YawToAcQuaternion) is
+ /// wire-correct at the QUATERNION level but its internal scalar
+ /// intermediate (headingDeg = 180 - yawDeg) is holtburger's
+ /// SHIFTED convention, not retail's. This method uses the CORRECT
+ /// scalar bridge derived from acdream's own body convention
+ /// (PlayerMovementController.cs:1022-1025: Orientation =
+ /// AxisAngle(Z, Yaw - PI/2), local-forward = +Y, Yaw=0 faces +X):
+ /// world-forward = (cos Yaw, sin Yaw), so
+ /// YawDeg = atan2Deg(forward.Y, forward.X) and
+ /// heading = (90 - YawDeg) mod 360 — the exact inverse of
+ /// . Identity quaternion (Yaw=PI/2, i.e. facing
+ /// +Y/North) → heading 0, matching P5's "identity quaternion faces
+ /// heading 0" pin.
+ ///
+ public static float GetHeading(Quaternion orientation)
+ {
+ var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), orientation);
+ float yawDeg = MathF.Atan2(forward.Y, forward.X) * (180f / MathF.PI);
+ float headingDeg = 90f - yawDeg;
+ headingDeg %= 360f;
+ if (headingDeg < 0f) headingDeg += 360f;
+ return headingDeg;
+ }
+
+ ///
+ /// Retail Frame::set_heading (0x00535e40, raw
+ /// 320055-320066) — builds a body orientation quaternion facing
+ /// (P5 compass convention), preserving
+ /// acdream's body-orientation convention (rotation about world Z only;
+ /// 's pitch/roll, if any, is
+ /// discarded — matching retail's set_heading, which is a pure
+ /// yaw-about-Z setter). Exact inverse of :
+ /// YawDeg = 90 - headingDeg, then Orientation =
+ /// AxisAngle(Z, Yaw - PI/2) per
+ /// PlayerMovementController.cs:1025's convention.
+ ///
+ /// Unused beyond signature parity with
+ /// the render-side SceneryHelpers.SetHeading twin — retail's
+ /// set_heading is a pure yaw-about-Z setter with no dependency
+ /// on the prior orientation's roll/pitch component in the body-frame
+ /// convention this port uses.
+ /// Desired compass heading, degrees.
+ public static Quaternion SetHeading(Quaternion baseOrientation, float headingDeg)
+ {
+ _ = baseOrientation;
+ float yawDeg = 90f - headingDeg;
+ float yaw = yawDeg * (MathF.PI / 180f);
+ return Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw - MathF.PI / 2f);
+ }
+
+ ///
+ /// Retail Position::cylinder_distance, the pure-math shape
+ /// consumed by MoveToManager::GetCurrentDistance
+ /// (005291b0, r4-moveto-decomp.md §5a) when use_spheres
+ /// (wire bit 0x400) is set — object moves use edge-to-edge cylinder
+ /// distance; position moves use plain center (Euclidean) distance
+ /// instead (not ported here — Vector3.Distance already covers
+ /// it). BN garbles the x87 plumbing in the raw, so the exact
+ /// radius-combination arithmetic is not directly visible; ported per
+ /// the PDB argument ORDER (own radius/height/position, target
+ /// radius/height/position) with the standard cylinder-distance shape:
+ /// planar (X/Y) center distance minus the sum of both radii, clamped
+ /// at zero (overlapping cylinders report 0, never negative). Height is
+ /// accepted for signature parity with the retail call (own/target
+ /// height feed the caller's contact-plane logic elsewhere) but does
+ /// NOT participate in this edge-to-edge planar computation — matching
+ /// retail's use of cylinder_distance purely for the horizontal arrival
+ /// gate.
+ ///
+ public static float CylinderDistance(
+ float ownRadius, float ownHeight, Vector3 ownPos,
+ float targetRadius, float targetHeight, Vector3 targetPos)
+ {
+ _ = ownHeight;
+ _ = targetHeight;
+
+ float dx = targetPos.X - ownPos.X;
+ float dy = targetPos.Y - ownPos.Y;
+ float centerDist = MathF.Sqrt(dx * dx + dy * dy);
+
+ float edgeDist = centerDist - ownRadius - targetRadius;
+ return edgeDist > 0f ? edgeDist : 0f;
+ }
+}
diff --git a/src/AcDream.Core/Physics/Motion/MovementParameters.cs b/src/AcDream.Core/Physics/Motion/MovementParameters.cs
index 798157dd..8e648f55 100644
--- a/src/AcDream.Core/Physics/Motion/MovementParameters.cs
+++ b/src/AcDream.Core/Physics/Motion/MovementParameters.cs
@@ -1,3 +1,5 @@
+using AcDream.Core.Physics;
+
namespace AcDream.Core.Physics.Motion;
///
@@ -184,4 +186,287 @@ public sealed class MovementParameters
/// Retail default 0.
public uint ActionStamp { get; set; }
+
+ // ── R4-V1: command-selection family (closes M2-mechanics) ─────────────
+
+ ///
+ /// Retail MovementParameters::get_command (0x0052aa00,
+ /// raw 307946-308012), VERBATIM per
+ /// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §5c. Picks the
+ /// motion command + moving-away flag from the towards/away bitfield
+ /// combination, THEN the walk-vs-run cascade.
+ ///
+ ///
+ /// Command pick (mirrors towards_and_away's bands but is
+ /// NOT identical — see the asymmetry note on ):
+ ///
+ /// - MoveTowards && MoveAway → delegate
+ /// to (the three-band form).
+ /// - MoveTowards only (or neither flag set —
+ /// retail's else if falls through to the SAME branch): plain
+ /// towards — dist > DistanceToObject → WalkForward,
+ /// !movingAway; else idle (cmd 0).
+ /// - MoveAway only: pure away —
+ /// dist < MinDistance → WalkForward, movingAway=true (the
+ /// heading flips +180 via — turn-around,
+ /// NOT WalkBackwards, unlike 's min-band);
+ /// else idle.
+ ///
+ ///
+ ///
+ ///
+ /// THE walk-vs-run rule (confirms
+ /// feedback_autowalk_cancharge_bit — port RETAIL's version of
+ /// BOTH the fast-path ACE dropped and the threshold-close-walk pair):
+ /// HoldKey.Run ⇐ CanCharge set (the fast-path — wins
+ /// regardless of CanRun/CanWalk/distance), OR (CanRun set AND
+ /// (CanWalk clear OR dist - DistanceToObject >
+ /// WalkRunThreshhold)). HoldKey.None (walk) ⇐ no CanRun, or
+ /// walk-capable within the threshold (INCLUSIVE ≤ — the raw's
+ /// test ah,0x41 after the fcom is the not-greater-than reading,
+ /// §5c @308003).
+ ///
+ ///
+ /// Current distance-to-target (retail's
+ /// GetCurrentDistance result — center or cylinder distance per
+ /// ).
+ /// Heading-to-target minus current heading,
+ /// normalized [0,360) — UNUSED by get_command itself (the raw
+ /// signature carries it for parity with the caller's local; retail's
+ /// body never reads arg3 in this build). Kept as a parameter for
+ /// call-site symmetry with BeginMoveForward (§4c), which computes
+ /// it immediately before calling get_command.
+ /// Chosen motion command id, or 0 if no movement
+ /// is needed (already in range).
+ /// Chosen hold key (walk vs run).
+ /// True if the chosen motion moves the mover
+ /// AWAY from the target (feeds and the
+ /// arrival predicate's polarity).
+ public void GetCommand(float dist, float headingDiff, out uint motion, out HoldKey holdKey, out bool movingAway)
+ {
+ _ = headingDiff; // retail's arg3 — unread in this build's body (§5c)
+
+ // ── command + moving_away pick ──────────────────────────────────
+ if (MoveTowards && MoveAway)
+ {
+ TowardsAndAway(dist, out motion, out movingAway);
+ }
+ else if (MoveAway && !MoveTowards)
+ {
+ // pure AWAY: dist < min_distance → WalkForward, moving away
+ // (turn-around; heading flips +180 via GetDesiredHeading).
+ if (dist < MinDistance)
+ {
+ motion = MotionCommand.WalkForward;
+ movingAway = true;
+ }
+ else
+ {
+ motion = 0u;
+ movingAway = false;
+ }
+ }
+ else
+ {
+ // plain TOWARDS (MoveTowards set, or neither flag set — retail's
+ // `else if ((flags & 0x100) == 0)` falls to the same label).
+ if (dist > DistanceToObject)
+ {
+ motion = MotionCommand.WalkForward;
+ movingAway = false;
+ }
+ else
+ {
+ motion = 0u;
+ movingAway = false;
+ }
+ }
+
+ // ── walk-vs-run HoldKey cascade ─────────────────────────────────
+ if (CanCharge)
+ {
+ // THE fast-path ACE dropped: can_charge short-circuits straight
+ // to Run regardless of CanRun/CanWalk/distance.
+ holdKey = HoldKey.Run;
+ return;
+ }
+ if (!CanRun)
+ {
+ holdKey = HoldKey.None;
+ return;
+ }
+ if (CanWalk && (dist - DistanceToObject) <= WalkRunThreshhold)
+ {
+ holdKey = HoldKey.None;
+ return;
+ }
+ holdKey = HoldKey.Run;
+ }
+
+ ///
+ /// Retail MovementParameters::towards_and_away
+ /// (0x0052a9a0, raw 307917-307942), VERBATIM per
+ /// r4-moveto-decomp.md §5d. Three bands:
+ ///
+ /// - dist > DistanceToObject → WalkForward,
+ /// towards (not moving away).
+ /// - dist - MinDistance < F_EPSILON (inside
+ /// the min-distance band) → WalkBackward, moving away. NOTE the
+ /// asymmetry vs 's pure-away branch: this backs
+ /// up with WalkBackwards (no turn-around), not WalkForward+heading-flip
+ /// (r4-moveto-decomp.md :656).
+ /// - otherwise (strictly inside [MinDistance,
+ /// DistanceToObject]) → idle (cmd 0).
+ ///
+ ///
+ /// Current distance-to-target.
+ /// Chosen motion command, or 0 if already in the dead
+ /// band.
+ /// True only for the WalkBackward (min-band)
+ /// case.
+ public void TowardsAndAway(float dist, out uint cmd, out bool movingAway)
+ {
+ const float epsilon = 0.000199999995f;
+
+ if (dist > DistanceToObject)
+ {
+ cmd = MotionCommand.WalkForward;
+ movingAway = false;
+ return;
+ }
+ if (dist - MinDistance < epsilon)
+ {
+ cmd = MotionCommand.WalkBackward;
+ movingAway = true;
+ return;
+ }
+ cmd = 0u;
+ movingAway = false;
+ }
+
+ ///
+ /// Retail MovementParameters::get_desired_heading
+ /// (0x0052aad0), PINNED by direct Ghidra decompile of
+ /// patchmem.gpr (fetched live during V0 — see
+ /// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P2,
+ /// ACE-shaped constants CONFIRMED exact, superseding the earlier
+ /// BN-garble-based "high confidence" pin):
+ ///
+ /// __thiscall get_desired_heading(command, movingAway)
+ /// {
+ /// if (command == RunForward || command == WalkForward) {
+ /// if (!movingAway) return 0.0f;
+ /// } else {
+ /// if (command != WalkBackward) return 0.0f;
+ /// if (movingAway) return 0.0f;
+ /// }
+ /// return 180.0f;
+ /// }
+ ///
+ /// Truth table: forward/run + towards → 0°; forward/run + away → 180°;
+ /// backward + towards → 180°; backward + away → 0°; any other command
+ /// → 0°. This is the heading OFFSET added to the raw heading-to-target
+ /// so an "away" walk faces away and an "away" backstep faces the
+ /// target.
+ ///
+ public float GetDesiredHeading(uint command, bool movingAway)
+ {
+ if (command == MotionCommand.RunForward || command == MotionCommand.WalkForward)
+ {
+ if (!movingAway) return 0f;
+ }
+ else
+ {
+ if (command != MotionCommand.WalkBackward) return 0f;
+ if (movingAway) return 0f;
+ }
+ return 180f;
+ }
+
+ // ── R4-V1: wire factory (closes M15/wire-exposure groundwork) ─────────
+
+ ///
+ /// Factory for the retail MovementParameters::UnPackNet 7-dword
+ /// MoveTo wire form (0x0052ac50, 0x1c bytes, raw 308118-308205 —
+ /// r4-moveto-decomp.md §2g): bitfield, distance_to_object,
+ /// min_distance, fail_distance, speed, walk_run_threshhold,
+ /// desired_heading. Used by MoveToObject (type 6) and
+ /// MoveToPosition (type 7) wire payloads — the SAME field order
+ /// UpdateMotion.TryParseMoveToPayload already reads
+ /// (UpdateMotion.cs:328-341). The bitfield fully OVERWRITES every named
+ /// flag (UnPackNet does not merge with ctor defaults — every bit not
+ /// present in resolves to false); the wire
+ /// bitfield carries can_charge (0x10), the walk-vs-run answer
+ /// consumed by (cross-ref
+ /// feedback_autowalk_cancharge_bit).
+ ///
+ public static MovementParameters FromWire(
+ uint bitfield,
+ float distanceToObject,
+ float minDistance,
+ float failDistance,
+ float speed,
+ float walkRunThreshhold,
+ float desiredHeading)
+ {
+ var p = new MovementParameters();
+ ApplyBitfield(p, bitfield);
+ p.DistanceToObject = distanceToObject;
+ p.MinDistance = minDistance;
+ p.FailDistance = failDistance;
+ p.Speed = speed;
+ p.WalkRunThreshhold = walkRunThreshhold;
+ p.DesiredHeading = desiredHeading;
+ return p;
+ }
+
+ ///
+ /// Factory for the retail MovementParameters::UnPackNet 3-dword
+ /// TurnTo wire form (0xc bytes, r4-moveto-decomp.md §2g): bitfield,
+ /// speed, desired_heading. Used by TurnToObject (type 8) and
+ /// TurnToHeading (type 9) wire payloads. Distance-related scalars
+ /// (DistanceToObject/MinDistance/FailDistance/
+ /// WalkRunThreshhold) are NOT on this wire form and keep the
+ /// ctor defaults — retail's UnPackNet
+ /// for this form only ever writes the three fields named here.
+ ///
+ public static MovementParameters FromWireTurnTo(
+ uint bitfield,
+ float speed,
+ float desiredHeading)
+ {
+ var p = new MovementParameters();
+ ApplyBitfield(p, bitfield);
+ p.Speed = speed;
+ p.DesiredHeading = desiredHeading;
+ return p;
+ }
+
+ ///
+ /// Decode the A4-pinned bitfield masks onto the named bool properties.
+ /// Shared by / — the
+ /// wire bitfield always fully overwrites (retail's UnPackNet assigns
+ /// the raw dword straight into this->bitfield, no merge).
+ ///
+ private static void ApplyBitfield(MovementParameters p, uint bitfield)
+ {
+ p.CanWalk = (bitfield & 0x1u) != 0;
+ p.CanRun = (bitfield & 0x2u) != 0;
+ p.CanSidestep = (bitfield & 0x4u) != 0;
+ p.CanWalkBackwards = (bitfield & 0x8u) != 0;
+ p.CanCharge = (bitfield & 0x10u) != 0;
+ p.FailWalk = (bitfield & 0x20u) != 0;
+ p.UseFinalHeading = (bitfield & 0x40u) != 0;
+ p.Sticky = (bitfield & 0x80u) != 0;
+ p.MoveAway = (bitfield & 0x100u) != 0;
+ p.MoveTowards = (bitfield & 0x200u) != 0;
+ p.UseSpheres = (bitfield & 0x400u) != 0;
+ p.SetHoldKey = (bitfield & 0x800u) != 0;
+ p.Autonomous = (bitfield & 0x1000u) != 0;
+ p.ModifyRawState = (bitfield & 0x2000u) != 0;
+ p.ModifyInterpretedState = (bitfield & 0x4000u) != 0;
+ p.CancelMoveTo = (bitfield & 0x8000u) != 0;
+ p.StopCompletelyFlag = (bitfield & 0x10000u) != 0;
+ p.DisableJumpDuringLink = (bitfield & 0x20000u) != 0;
+ }
}
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index a31cc37b..a0048089 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -95,11 +95,31 @@ public static class MotionCommand
}
///
-/// Movement type passed in PerformMovement's switch statement.
-/// Matches the 5-case switch at FUN_00529a90.
+/// Movement type passed in PerformMovement's switch statement. Matches
+/// retail's MovementTypes::Type (acclient.h:2856, enum #229) in full.
+///
+///
+/// R4-V1 widening (closes M11). Values 1-5 dispatch through
+/// CMotionInterp (the 5-case switch at FUN_00529a90, unchanged since
+/// R1-R3); values 6-9 dispatch through MoveToManager
+/// (MovementManager::PerformMovement, r4-moveto-decomp.md §2b:
+/// (type - 1) > 8 → 0x47, case 0-4 → CMotionInterp, case 5-8 →
+/// MoveToManager). Invalid=0 and values > 9 both fail with
+/// (0x47) at the
+/// MovementManager level — no consumer wiring changes in this slice
+/// (mechanical, additive-only; MoveToManager itself is R4-V2+).
+///
///
public enum MovementType
{
+ ///
+ /// 0 — no movement in progress / uninitialized. R4-V1 addition (M11).
+ /// MoveToManager::InitializeLocalVariables resets
+ /// movement_type to this value; MovementManager::PerformMovement
+ /// rejects it with 0x47 (§2b: (type - 1) > 8 underflows to a
+ /// huge unsigned value for type 0, which is always > 8).
+ ///
+ Invalid = 0,
/// case 1 — raw motion command (DoMotion).
RawCommand = 1,
/// case 2 — interpreted motion command (DoInterpretedMotion).
@@ -110,6 +130,31 @@ public enum MovementType
StopInterpretedCommand = 4,
/// case 5 — stop completely (StopCompletely).
StopCompletely = 5,
+ ///
+ /// 6 — MoveToObject. R4-V1 addition (M11). Dispatches to
+ /// MoveToManager::MoveToObject (r4-moveto-decomp.md §3b); uses
+ /// //
+ /// /.
+ ///
+ MoveToObject = 6,
+ ///
+ /// 7 — MoveToPosition. R4-V1 addition (M11). Dispatches to
+ /// MoveToManager::MoveToPosition (§3c); uses
+ /// .
+ ///
+ MoveToPosition = 7,
+ ///
+ /// 8 — TurnToObject. R4-V1 addition (M11). Dispatches to
+ /// MoveToManager::TurnToObject (§3d); uses
+ /// /.
+ ///
+ TurnToObject = 8,
+ ///
+ /// 9 — TurnToHeading. R4-V1 addition (M11). Dispatches to
+ /// MoveToManager::TurnToHeading (§3e); uses
+ /// 's DesiredHeading.
+ ///
+ TurnToHeading = 9,
}
///
@@ -144,6 +189,15 @@ public enum WeenieError : uint
///
NoPhysicsObject = 0x08,
///
+ /// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per
+ /// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants
+ /// inventory (8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47). ACE name;
+ /// the retail sites that store this code were not individually
+ /// extracted in the R4 pass (no MoveToManager consumer in this slice —
+ /// V1 pins the numeric value only).
+ ///
+ NoMotionInterpreter = 0x0B,
+ ///
/// 0x24 — not grounded / no contact. Sites (A10):
/// jump_is_allowed @305570 (gravity-active creature without
/// Contact+OnWalkable; also the physics_obj == null case, which
@@ -173,6 +227,30 @@ public enum WeenieError : uint
///
ChatEmoteOutsideNonCombat = 0x42,
///
+ /// 0x36 — ActionCancelled. R4-V1 addition (M12). Site:
+ /// MoveToManager::PerformMovement (r4-moveto-decomp.md §3a
+ /// @0052a901) — every new moveto cancels the previous one with this
+ /// code before dispatching; also CPhysicsObj::interrupt_current_movement
+ /// → MovementManager::CancelMoveTo(0x36) (§9e — the TS-36 cancel
+ /// entry). Per §7c, MoveToManager::CancelMoveTo's WeenieError arg
+ /// is NEVER READ in this build's body — kept for parity/logging only.
+ ///
+ ActionCancelled = 0x36,
+ ///
+ /// 0x37 — ObjectGone. R4-V1 addition (M12). Site:
+ /// MoveToManager::HandleUpdateTarget (§6d @307866-307867) — a
+ /// RETARGET delivery arrives with a non-OK target status (the target
+ /// object was already being tracked, then went away).
+ ///
+ ObjectGone = 0x37,
+ ///
+ /// 0x38 — NoObject. R4-V1 addition (M12). Site:
+ /// MoveToManager::HandleUpdateTarget (§6d @307857-307858) — the
+ /// FIRST target callback arrives with a non-OK status (the target never
+ /// resolved in the first place).
+ ///
+ NoObject = 0x38,
+ ///
/// 0x45 — action-queue depth cap: an action-class motion (bit
/// 0x10000000) with GetNumActions() >= 6 pending. Site:
/// DoMotion @306209.
@@ -203,6 +281,17 @@ public enum WeenieError : uint
/// @304941; charge_jump @305454.
///
CantJumpLoadedDown = 0x49,
+ ///
+ /// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site:
+ /// MoveToManager::HandleMoveToPosition Phase 2 arrival check
+ /// (r4-moveto-decomp.md §6b) — the fail_distance progress gate
+ /// exceeded (CheckProgressMade §5b failing for >1s AND the
+ /// mover overshot fail_distance). NOTE: numerically out of A10's
+ /// increasing order (0x3D < 0x3F/0x40/0x41/0x42/0x45) because it was
+ /// not part of the CMotionInterp jump-family sweep this code sits
+ /// beside — it belongs to the MoveToManager family instead (§7c, §12).
+ ///
+ YouChargedTooFar = 0x3D,
}
// ── Motion state structs ───────────────────────────────────────────────────────
@@ -404,12 +493,40 @@ public struct InterpretedMotionState
///
/// Lightweight struct passed into PerformMovement.
/// Fields correspond to what the retail dispatcher read from param_1 (the movement packet struct).
+///
+///
+/// R4-V1 widening (closes M11). Retail's full MovementStruct
+/// (acclient.h:38069, struct #4067):
+///
+/// struct __cppobj MovementStruct
+/// {
+/// MovementTypes::Type type;
+/// unsigned int motion; // types 1-4 only
+/// unsigned int object_id; // types 6, 8
+/// unsigned int top_level_id; // types 6, 8
+/// Position pos; // type 7
+/// float radius; // type 6
+/// float height; // type 6
+/// MovementParameters *params; // types 1-4, 6-9
+/// };
+///
+/// ///
+/// // are the
+/// R4-V1 additions — additive only, no consumer wiring in this slice
+/// (MoveToManager itself is R4-V2). The pre-R4 fields (/
+/// ///
+/// /) are
+/// untouched. uses acdream's
+/// (ObjCellId + CellFrame) rather than retail's block-local Position —
+/// V0-pins.md §P5: distances are equivalent after rebase in acdream's
+/// streaming-world space.
+///
///
public struct MovementStruct
{
- /// Which of the 5 motion types to dispatch.
+ /// Which movement type to dispatch (retail MovementTypes::Type, full 0-9 range).
public MovementType Type;
- /// Motion command ID (e.g. WalkForward).
+ /// Motion command ID (e.g. WalkForward). Types 1-4 only.
public uint Motion;
/// Speed scalar for this motion.
public float Speed;
@@ -419,6 +536,35 @@ public struct MovementStruct
public bool ModifyInterpretedState;
/// Whether to modify the raw state.
public bool ModifyRawState;
+
+ ///
+ /// R4-V1 — retail object_id. Types 6 (MoveToObject), 8
+ /// (TurnToObject) only.
+ ///
+ public uint ObjectId;
+ ///
+ /// R4-V1 — retail top_level_id. Types 6 (MoveToObject), 8
+ /// (TurnToObject) only.
+ ///
+ public uint TopLevelId;
+ ///
+ /// R4-V1 — retail pos (world position + cell). Type 7
+ /// (MoveToPosition) only.
+ ///
+ public Position Pos;
+ ///
+ /// R4-V1 — retail radius. Type 6 (MoveToObject) only.
+ ///
+ public float Radius;
+ ///
+ /// R4-V1 — retail height. Type 6 (MoveToObject) only.
+ ///
+ public float Height;
+ ///
+ /// R4-V1 — retail params (a pointer in retail; a reference
+ /// here). Types 1-4 and 6-9.
+ ///
+ public Motion.MovementParameters? Params;
}
// ── Optional WeenieObject interface ──────────────────────────────────────────
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs
new file mode 100644
index 00000000..eb21d692
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs
@@ -0,0 +1,91 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — Position::cylinder_distance, the pure-math shape per
+/// r4-moveto-decomp.md §5a (MoveToManager::GetCurrentDistance,
+/// 005291b0): edge-to-edge distance between two vertical cylinders
+/// (own radius/height, target radius/height, both positions). Object moves
+/// (use_spheres set on the wire) use this; position moves use plain
+/// Euclidean Position::distance (§5a: "position moves use center
+/// distance" — is the object-move
+/// variant only; center distance is Vector3.Distance, already
+/// available, not re-ported here).
+///
+///
+/// The retail signature's exact combination math for radius/height beyond
+/// "edge-to-edge, own+target cylinders" is not spelled out in the raw (BN
+/// garbles the x87 plumbing) — ported per the PDB argument ORDER
+/// (own radius/height, own position, target radius/height, target
+/// position) with the standard cylinder-distance shape: horizontal
+/// (planar) distance minus the sum of the two radii (clamped at 0), since
+/// that is the only shape consistent with "edge-to-edge" and with
+/// distance_to_object's ctor default of 0.6 (melee range from
+/// surface to surface, not center to center).
+///
+///
+public sealed class MoveToMathCylinderDistanceTests
+{
+ [Fact]
+ public void TwoCylinders_HorizontallySeparated_SubtractsBothRadii()
+ {
+ // centers 10 units apart on X, radii 1 and 2 → edge distance = 10-1-2=7
+ float d = MoveToMath.CylinderDistance(
+ ownRadius: 1f, ownHeight: 2f, ownPos: Vector3.Zero,
+ targetRadius: 2f, targetHeight: 2f, targetPos: new Vector3(10f, 0f, 0f));
+
+ Assert.Equal(7f, d, 3);
+ }
+
+ [Fact]
+ public void TwoCylinders_Overlapping_ClampsAtZero_NoNegativeDistance()
+ {
+ // centers 1 unit apart, radii 5 and 5 → would be -9, clamps to 0
+ float d = MoveToMath.CylinderDistance(
+ ownRadius: 5f, ownHeight: 2f, ownPos: Vector3.Zero,
+ targetRadius: 5f, targetHeight: 2f, targetPos: new Vector3(1f, 0f, 0f));
+
+ Assert.Equal(0f, d, 3);
+ }
+
+ [Fact]
+ public void TwoCylinders_ZeroRadii_ReducesToCenterDistance()
+ {
+ float d = MoveToMath.CylinderDistance(
+ ownRadius: 0f, ownHeight: 2f, ownPos: Vector3.Zero,
+ targetRadius: 0f, targetHeight: 2f, targetPos: new Vector3(3f, 4f, 0f));
+
+ Assert.Equal(5f, d, 3); // 3-4-5 triangle
+ }
+
+ [Fact]
+ public void TwoCylinders_IgnoresVerticalSeparation_PlanarOnly()
+ {
+ // Same X/Y, large Z separation — cylinder_distance in retail's own
+ // callers (GetCurrentDistance) is used for horizontal arrival gates;
+ // the Z axis is height, not part of the radial edge-to-edge gap.
+ float d1 = MoveToMath.CylinderDistance(
+ ownRadius: 1f, ownHeight: 2f, ownPos: new Vector3(0, 0, 0),
+ targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(5f, 0f, 0f));
+ float d2 = MoveToMath.CylinderDistance(
+ ownRadius: 1f, ownHeight: 2f, ownPos: new Vector3(0, 0, 50f),
+ targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(5f, 0f, -50f));
+
+ Assert.Equal(d1, d2, 3);
+ Assert.Equal(3f, d1, 3); // 5 - 1 - 1
+ }
+
+ [Fact]
+ public void SamePosition_ZeroDistance_ClampsNotNegative()
+ {
+ float d = MoveToMath.CylinderDistance(
+ ownRadius: 0.5f, ownHeight: 2f, ownPos: Vector3.Zero,
+ targetRadius: 0.5f, targetHeight: 2f, targetPos: Vector3.Zero);
+
+ Assert.Equal(0f, d, 3);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingDiffTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingDiffTests.cs
new file mode 100644
index 00000000..1159642b
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingDiffTests.cs
@@ -0,0 +1,164 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — heading_diff (0x00528fb0), PINNED by direct
+/// disassembly of the PDB-matched retail binary (see
+/// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P3 — this is
+/// the strongest evidence tier in the whole R4 pin set, one level above a
+/// Ghidra decompile). Verbatim body:
+///
+/// d = h1 - h2;
+/// if (fabs(h1 - h2) < F_EPSILON) d = 0;
+/// if (d < -F_EPSILON) d += 360;
+/// if (F_EPSILON < d && turnCmd != TurnRight) d = 360 - d; // the mirror
+/// return d;
+///
+/// F_EPSILON = 0.000199999995f. The mirror gates on the turn command being
+/// NOT TurnRight (0x6500000d) — TurnLeft (and any other command) measures
+/// the COMPLEMENTARY angle. This contradicts r4-moveto-decomp.md §5g's
+/// "arg3 UNUSED" claim, which the Ghidra pin overrides (adjudicated in
+/// V0-pins.md).
+///
+public sealed class MoveToMathHeadingDiffTests
+{
+ private const float Eps = 0.000199999995f;
+ private const uint TurnRight = MotionCommand.TurnRight;
+ private const uint TurnLeft = MotionCommand.TurnLeft;
+
+ // ── basic subtraction, TurnRight (no mirror) ───────────────────────────
+
+ [Fact]
+ public void TurnRight_SimplePositiveDiff_NoWrap()
+ {
+ float d = MoveToMath.HeadingDiff(90f, 30f, TurnRight);
+ Assert.Equal(60f, d, 3);
+ }
+
+ [Fact]
+ public void TurnRight_NegativeDiff_WrapsBy360()
+ {
+ // h1 - h2 = 30 - 90 = -60 → wraps to 300
+ float d = MoveToMath.HeadingDiff(30f, 90f, TurnRight);
+ Assert.Equal(300f, d, 3);
+ }
+
+ [Fact]
+ public void TurnRight_ZeroDiff_IsZero()
+ {
+ float d = MoveToMath.HeadingDiff(45f, 45f, TurnRight);
+ Assert.Equal(0f, d, 3);
+ }
+
+ // ── epsilon boundary (both sides) ──────────────────────────────────────
+
+ [Fact]
+ public void EpsilonBoundary_ExactlyAtEpsilon_NotSnappedToZero()
+ {
+ // fabs(d) < EPSILON is a STRICT less-than — exactly at epsilon does
+ // NOT snap to zero.
+ float d = MoveToMath.HeadingDiff(Eps, 0f, TurnRight);
+ Assert.NotEqual(0f, d);
+ Assert.Equal(Eps, d, 6);
+ }
+
+ [Fact]
+ public void EpsilonBoundary_JustBelowEpsilon_SnapsToZero()
+ {
+ float d = MoveToMath.HeadingDiff(Eps * 0.5f, 0f, TurnRight);
+ Assert.Equal(0f, d);
+ }
+
+ [Fact]
+ public void EpsilonBoundary_NegativeJustBelowNegEpsilon_WrapsBy360()
+ {
+ // d = -Eps * 1.5 < -Eps → wraps
+ float raw = -Eps * 1.5f;
+ float d = MoveToMath.HeadingDiff(raw, 0f, TurnRight);
+ Assert.Equal(raw + 360f, d, 3);
+ }
+
+ [Fact]
+ public void EpsilonBoundary_NegativeExactlyAtNegEpsilon_DoesNotWrap()
+ {
+ // d < -EPSILON is STRICT — exactly -EPSILON does not wrap.
+ float d = MoveToMath.HeadingDiff(-Eps, 0f, TurnRight);
+ Assert.Equal(-Eps, d, 6);
+ }
+
+ // ── the mirror (TurnLeft / not-TurnRight) ──────────────────────────────
+
+ [Fact]
+ public void TurnLeft_PositiveDiffAboveEpsilon_MirroredTo360MinusD()
+ {
+ float d = MoveToMath.HeadingDiff(90f, 30f, TurnLeft);
+ // raw = 60; mirror: 360 - 60 = 300
+ Assert.Equal(300f, d, 3);
+ }
+
+ [Fact]
+ public void TurnLeft_NegativeDiff_WrapsThenMirrors()
+ {
+ // h1-h2 = 30-90 = -60 → wraps to 300 → mirror gate (300 > EPS, not
+ // TurnRight) → 360 - 300 = 60
+ float d = MoveToMath.HeadingDiff(30f, 90f, TurnLeft);
+ Assert.Equal(60f, d, 3);
+ }
+
+ [Fact]
+ public void TurnLeft_ZeroDiff_MirrorGateDoesNotFire_StaysZero()
+ {
+ // d == 0 does not satisfy `d > EPSILON`, so the mirror never fires
+ // regardless of turn command.
+ float d = MoveToMath.HeadingDiff(45f, 45f, TurnLeft);
+ Assert.Equal(0f, d);
+ }
+
+ [Fact]
+ public void TurnLeft_AtEpsilonBoundary_MirrorGateIsStrictGreaterThan()
+ {
+ // d > EPSILON is STRICT: exactly at EPSILON does NOT mirror.
+ float d = MoveToMath.HeadingDiff(Eps, 0f, TurnLeft);
+ Assert.Equal(Eps, d, 6);
+ }
+
+ [Fact]
+ public void TurnLeft_JustAboveEpsilon_Mirrors()
+ {
+ float raw = Eps * 2f;
+ float d = MoveToMath.HeadingDiff(raw, 0f, TurnLeft);
+ Assert.Equal(360f - raw, d, 3);
+ }
+
+ [Fact]
+ public void AnyNonTurnRightCommand_AlsoMirrors()
+ {
+ // The gate is "!= TurnRight", not "== TurnLeft" — any other command
+ // (e.g. 0, WalkForward) also triggers the mirror.
+ float d = MoveToMath.HeadingDiff(90f, 30f, 0u);
+ Assert.Equal(300f, d, 3);
+
+ float d2 = MoveToMath.HeadingDiff(90f, 30f, MotionCommand.WalkForward);
+ Assert.Equal(300f, d2, 3);
+ }
+
+ // ── 360-wrap combined with the mirror ──────────────────────────────────
+
+ [Fact]
+ public void TurnRight_FullCircleInputs_NormalizeCorrectly()
+ {
+ float d = MoveToMath.HeadingDiff(350f, 10f, TurnRight);
+ Assert.Equal(340f, d, 3);
+ }
+
+ [Fact]
+ public void TurnLeft_FullCircleInputs_MirroredAfterNormalize()
+ {
+ // raw = 350-10 = 340 (no wrap needed, positive); mirror: 360-340=20
+ float d = MoveToMath.HeadingDiff(350f, 10f, TurnLeft);
+ Assert.Equal(20f, d, 3);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingGreaterTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingGreaterTests.cs
new file mode 100644
index 00000000..7ab6fdff
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingGreaterTests.cs
@@ -0,0 +1,82 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — heading_greater (00528f60, raw 306281-306323), per
+/// r4-moveto-decomp.md §5f:
+///
+/// if (fabs(a - b) > 180) greater = (b > a); // wrapped case: compare flipped
+/// else greater = (a > b);
+/// if (turnCmd == TurnRight) return greater;
+/// return !greater; // TurnLeft: inverted
+///
+/// "Has the turn passed the target heading" — direction-aware, wrap-aware.
+///
+public sealed class MoveToMathHeadingGreaterTests
+{
+ private const uint TurnRight = MotionCommand.TurnRight;
+ private const uint TurnLeft = MotionCommand.TurnLeft;
+
+ [Fact]
+ public void TurnRight_UnwrappedCase_SimpleGreater()
+ {
+ Assert.True(MoveToMath.HeadingGreater(90f, 30f, TurnRight));
+ Assert.False(MoveToMath.HeadingGreater(30f, 90f, TurnRight));
+ }
+
+ [Fact]
+ public void TurnRight_WrappedCase_ComparisonFlips()
+ {
+ // |350 - 10| = 340 > 180 → wrapped: greater = (b > a) = (10 > 350) = false
+ Assert.False(MoveToMath.HeadingGreater(350f, 10f, TurnRight));
+ // |10 - 350| = 340 > 180 → wrapped: greater = (b > a) = (350 > 10) = true
+ Assert.True(MoveToMath.HeadingGreater(10f, 350f, TurnRight));
+ }
+
+ [Fact]
+ public void TurnRight_ExactlyAt180Delta_UnwrappedBranch()
+ {
+ // fabs(a-b) > 180 is STRICT — exactly 180 uses the unwrapped branch.
+ // a=200,b=20: fabs=180, not >180 → greater = (a>b) = true
+ Assert.True(MoveToMath.HeadingGreater(200f, 20f, TurnRight));
+ }
+
+ [Fact]
+ public void TurnLeft_InvertsTheUnwrappedResult()
+ {
+ Assert.False(MoveToMath.HeadingGreater(90f, 30f, TurnLeft));
+ Assert.True(MoveToMath.HeadingGreater(30f, 90f, TurnLeft));
+ }
+
+ [Fact]
+ public void TurnLeft_InvertsTheWrappedResult()
+ {
+ Assert.True(MoveToMath.HeadingGreater(350f, 10f, TurnLeft));
+ Assert.False(MoveToMath.HeadingGreater(10f, 350f, TurnLeft));
+ }
+
+ [Fact]
+ public void EqualHeadings_NotGreater_TurnRight()
+ {
+ Assert.False(MoveToMath.HeadingGreater(45f, 45f, TurnRight));
+ }
+
+ [Fact]
+ public void EqualHeadings_InvertedToTrue_TurnLeft()
+ {
+ // greater=false for equal headings; TurnLeft inverts → true.
+ Assert.True(MoveToMath.HeadingGreater(45f, 45f, TurnLeft));
+ }
+
+ [Fact]
+ public void AnyNonTurnRightCommand_AlsoInverts()
+ {
+ // The retail gate is `== TurnRight` (not `!= TurnRight` as in
+ // heading_diff) — every OTHER command, not just TurnLeft, inverts.
+ Assert.False(MoveToMath.HeadingGreater(90f, 30f, 0u));
+ Assert.False(MoveToMath.HeadingGreater(90f, 30f, MotionCommand.WalkForward));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs
new file mode 100644
index 00000000..cd9fe4f7
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs
@@ -0,0 +1,127 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — Position::heading / Frame::get_heading /
+/// Frame::set_heading, per V0-pins.md §P5 (PINNED — compass degrees,
+/// 0 = North (+Y), 90 = East (+X), CLOCKWISE, [0,360); identity quaternion
+/// faces heading 0):
+///
+/// heading(from, to) = (450 - atan2Deg(dy, dx)) % 360
+///
+/// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270.
+///
+///
+/// The packer-reuse trap (V0-pins §P5 correction): acdream's
+/// outbound packer (GameWindow.YawToAcQuaternion) is wire-correct at
+/// the QUATERNION level but its internal scalar intermediate
+/// (headingDeg = 180 - yawDeg) is holtburger's shifted convention,
+/// NOT retail's wire convention. must
+/// use the CORRECT scalar bridge from acdream yaw (yaw=0 faces +X, per
+/// PlayerMovementController.cs:1022-1025): heading = (90 -
+/// yawDeg) mod 360 — NOT 180 - yawDeg.
+///
+///
+public sealed class MoveToMathPositionHeadingTests
+{
+ private const float Tol = 0.01f;
+
+ // ── PositionHeading: the four cardinal offsets ─────────────────────────
+
+ [Fact]
+ public void North_PlusY_IsZero()
+ {
+ float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(0f, 1f, 0f));
+ Assert.Equal(0f, h, 2);
+ }
+
+ [Fact]
+ public void East_PlusX_Is90()
+ {
+ float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(1f, 0f, 0f));
+ Assert.Equal(90f, h, 2);
+ }
+
+ [Fact]
+ public void South_MinusY_Is180()
+ {
+ float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(0f, -1f, 0f));
+ Assert.Equal(180f, h, 2);
+ }
+
+ [Fact]
+ public void West_MinusX_Is270()
+ {
+ float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(-1f, 0f, 0f));
+ Assert.Equal(270f, h, 2);
+ }
+
+ [Fact]
+ public void Heading_IsAlways_InZeroToThreeSixtyRange()
+ {
+ // NE diagonal
+ float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(1f, 1f, 0f));
+ Assert.InRange(h, 0f, 360f);
+ Assert.Equal(45f, h, 2);
+ }
+
+ [Fact]
+ public void Heading_IgnoresZ_HorizontalOnly()
+ {
+ float h1 = MoveToMath.PositionHeading(new Vector3(0, 0, 5f), new Vector3(1f, 0f, -10f));
+ float h2 = MoveToMath.PositionHeading(new Vector3(0, 0, -3f), new Vector3(1f, 0f, 100f));
+ Assert.Equal(h1, h2, 2);
+ Assert.Equal(90f, h1, 2);
+ }
+
+ // ── GetHeading: extracts heading from a body orientation quaternion ────
+
+ [Fact]
+ public void GetHeading_IdentityQuaternion_FacesHeadingZero()
+ {
+ // Identity quaternion → acdream yaw = 0 → +X-facing in our
+ // convention, which decodes to AC heading 90 per the corrected
+ // scalar bridge... BUT the identity quaternion in acdream's body
+ // frame corresponds to yaw = -PI/2 relative to +Y-forward (see
+ // PlayerMovementController.cs:1025: Orientation = AxisAngle(Yaw -
+ // PI/2)). GetHeading must invert that exact convention: identity
+ // orientation (no rotation applied) means Yaw=PI/2 was baked in,
+ // which is heading 0 — matching P5's "identity quaternion faces
+ // heading 0" pin.
+ float h = MoveToMath.GetHeading(Quaternion.Identity);
+ Assert.Equal(0f, h, 1);
+ }
+
+ [Fact]
+ public void GetHeading_SetHeading_RoundTrips_Cardinals()
+ {
+ foreach (float heading in new[] { 0f, 90f, 180f, 270f, 45f, 359f })
+ {
+ var q = MoveToMath.SetHeading(Quaternion.Identity, heading);
+ float back = MoveToMath.GetHeading(q);
+ float diff = MathF.Abs(back - heading);
+ if (diff > 180f) diff = 360f - diff;
+ Assert.True(diff < 0.5f, $"heading {heading} round-tripped to {back}");
+ }
+ }
+
+ [Fact]
+ public void SetHeading_North_ProducesForwardVectorFacingPlusY()
+ {
+ var q = MoveToMath.SetHeading(Quaternion.Identity, 0f);
+ var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), q);
+ Assert.True(forward.Y > 0.9f, $"expected +Y forward, got {forward}");
+ }
+
+ [Fact]
+ public void SetHeading_East_ProducesForwardVectorFacingPlusX()
+ {
+ var q = MoveToMath.SetHeading(Quaternion.Identity, 90f);
+ var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), q);
+ Assert.True(forward.X > 0.9f, $"expected +X forward, got {forward}");
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersFromWireTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersFromWireTests.cs
new file mode 100644
index 00000000..6bc46f0d
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersFromWireTests.cs
@@ -0,0 +1,186 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — MovementParameters::UnPackNet (0x0052ac50, raw
+/// 308118-308205) factory semantics, per
+/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §2g: the 7-dword
+/// MoveTo wire form is bitfield, distance_to_object, min_distance,
+/// fail_distance, speed, walk_run_threshhold, desired_heading — the SAME
+/// field order UpdateMotion.TryParseMoveToPayload already reads off
+/// the wire (UpdateMotion.cs:328-341). The A4 bitfield masks
+/// (W0-pins.md §A4) decode into the named bool properties; every bit not
+/// present on the wire bitfield resolves to false (UnPackNet fully
+/// overwrites the bitfield — no ctor-default bits survive).
+///
+public sealed class MovementParametersFromWireTests
+{
+ [Fact]
+ public void FromWire_AllBitsSet_EveryFlagTrue()
+ {
+ var p = MovementParameters.FromWire(
+ bitfield: 0x3FFFFu, // every A4 bit through 0x20000
+ distanceToObject: 1f,
+ minDistance: 2f,
+ failDistance: 3f,
+ speed: 4f,
+ walkRunThreshhold: 5f,
+ desiredHeading: 6f);
+
+ Assert.True(p.CanWalk);
+ Assert.True(p.CanRun);
+ Assert.True(p.CanSidestep);
+ Assert.True(p.CanWalkBackwards);
+ Assert.True(p.CanCharge);
+ Assert.True(p.FailWalk);
+ Assert.True(p.UseFinalHeading);
+ Assert.True(p.Sticky);
+ Assert.True(p.MoveAway);
+ Assert.True(p.MoveTowards);
+ Assert.True(p.UseSpheres);
+ Assert.True(p.SetHoldKey);
+ Assert.True(p.Autonomous);
+ Assert.True(p.ModifyRawState);
+ Assert.True(p.ModifyInterpretedState);
+ Assert.True(p.CancelMoveTo);
+ Assert.True(p.StopCompletelyFlag);
+ Assert.True(p.DisableJumpDuringLink);
+ }
+
+ [Fact]
+ public void FromWire_ZeroBitfield_EveryFlagFalse_NoCtorDefaultsSurvive()
+ {
+ var p = MovementParameters.FromWire(
+ bitfield: 0u,
+ distanceToObject: 1f, minDistance: 2f, failDistance: 3f,
+ speed: 4f, walkRunThreshhold: 5f, desiredHeading: 6f);
+
+ Assert.False(p.CanWalk);
+ Assert.False(p.CanRun);
+ Assert.False(p.CanSidestep);
+ Assert.False(p.CanWalkBackwards);
+ Assert.False(p.CanCharge);
+ Assert.False(p.MoveTowards);
+ Assert.False(p.UseSpheres);
+ Assert.False(p.SetHoldKey);
+ Assert.False(p.ModifyRawState);
+ Assert.False(p.ModifyInterpretedState);
+ Assert.False(p.CancelMoveTo);
+ Assert.False(p.StopCompletelyFlag);
+ }
+
+ [Fact]
+ public void FromWire_CanChargeBit_DecodesIndependently()
+ {
+ // The wire bitfield carries can_charge 0x10 — the walk-vs-run answer
+ // (feedback_autowalk_cancharge_bit). Verify it round-trips on its own.
+ var p = MovementParameters.FromWire(
+ bitfield: 0x10u,
+ distanceToObject: 0f, minDistance: 0f, failDistance: 0f,
+ speed: 0f, walkRunThreshhold: 0f, desiredHeading: 0f);
+
+ Assert.True(p.CanCharge);
+ Assert.False(p.CanWalk);
+ Assert.False(p.CanRun);
+ }
+
+ [Theory]
+ [InlineData(0x1u)]
+ [InlineData(0x2u)]
+ [InlineData(0x4u)]
+ [InlineData(0x8u)]
+ [InlineData(0x10u)]
+ [InlineData(0x20u)]
+ [InlineData(0x40u)]
+ [InlineData(0x80u)]
+ [InlineData(0x100u)]
+ [InlineData(0x200u)]
+ [InlineData(0x400u)]
+ [InlineData(0x800u)]
+ [InlineData(0x1000u)]
+ [InlineData(0x2000u)]
+ [InlineData(0x4000u)]
+ [InlineData(0x8000u)]
+ [InlineData(0x10000u)]
+ [InlineData(0x20000u)]
+ public void FromWire_SingleBitMaskRoundTrips(uint mask)
+ {
+ var p = MovementParameters.FromWire(
+ bitfield: mask,
+ distanceToObject: 0f, minDistance: 0f, failDistance: 0f,
+ speed: 0f, walkRunThreshhold: 0f, desiredHeading: 0f);
+
+ Assert.Equal(mask, ToBitfield(p));
+ }
+
+ [Fact]
+ public void FromWire_ScalarFields_CopiedInWireOrder()
+ {
+ var p = MovementParameters.FromWire(
+ bitfield: 0u,
+ distanceToObject: 1.5f,
+ minDistance: 2.5f,
+ failDistance: 3.5f,
+ speed: 4.5f,
+ walkRunThreshhold: 5.5f,
+ desiredHeading: 6.5f);
+
+ Assert.Equal(1.5f, p.DistanceToObject);
+ Assert.Equal(2.5f, p.MinDistance);
+ Assert.Equal(3.5f, p.FailDistance);
+ Assert.Equal(4.5f, p.Speed);
+ Assert.Equal(5.5f, p.WalkRunThreshhold);
+ Assert.Equal(6.5f, p.DesiredHeading);
+ }
+
+ [Fact]
+ public void FromWireTurnTo_ThreeDwordForm_LeavesDistanceFieldsAtDefault()
+ {
+ // TurnToObject/TurnToHeading wire form (0xc bytes, 3 dwords):
+ // bitfield, speed, desired_heading only. distance_to_object /
+ // min_distance / fail_distance / walk_run_threshhold are NOT on
+ // this wire form — the factory overload must not touch them
+ // (they keep the MovementParameters ctor defaults).
+ var p = MovementParameters.FromWireTurnTo(
+ bitfield: 0x2u, // can_run
+ speed: 2f,
+ desiredHeading: 90f);
+
+ Assert.True(p.CanRun);
+ Assert.Equal(2f, p.Speed);
+ Assert.Equal(90f, p.DesiredHeading);
+
+ // ctor defaults, untouched by the 3-dword form:
+ Assert.Equal(0.6f, p.DistanceToObject);
+ Assert.Equal(0f, p.MinDistance);
+ Assert.Equal(float.MaxValue, p.FailDistance);
+ Assert.Equal(15f, p.WalkRunThreshhold);
+ }
+
+ private static uint ToBitfield(MovementParameters p)
+ {
+ 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;
+ return bitfield;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetCommandTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetCommandTests.cs
new file mode 100644
index 00000000..885baa2f
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetCommandTests.cs
@@ -0,0 +1,340 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — MovementParameters::get_command (0x0052aa00, raw
+/// 307946-308012), verbatim per
+/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §5c. Covers the
+/// command/moving_away pick (plain-towards / plain-away / towards_and_away
+/// delegate) crossed with the walk-vs-run HoldKey cascade, INCLUDING the
+/// CanCharge 0x10 fast-path ACE dropped (feedback_autowalk_cancharge_bit)
+/// and the walk_run_threshhold ≤-vs-< edge (retail: dist - dto ≤
+/// threshold → walk; the raw's `test ah,0x41` is the inclusive ≤ reading,
+/// §5c @308003).
+///
+public sealed class MovementParametersGetCommandTests
+{
+ // ── plain TOWARDS (move_towards set, move_away clear) ─────────────────
+
+ [Fact]
+ public void PlainTowards_DistGreaterThanDto_WalkForward_NotMovingAway()
+ {
+ var p = new MovementParameters { DistanceToObject = 0.6f };
+ // move_towards=true (default), move_away=false (default)
+
+ p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out HoldKey holdKey, out bool movingAway);
+
+ Assert.Equal(MotionCommand.WalkForward, motion);
+ Assert.False(movingAway);
+ }
+
+ [Fact]
+ public void PlainTowards_DistNotGreaterThanDto_Idle()
+ {
+ var p = new MovementParameters { DistanceToObject = 0.6f };
+
+ p.GetCommand(dist: 0.6f, headingDiff: 0f, out uint motion, out _, out bool movingAway);
+
+ Assert.Equal(0u, motion);
+ Assert.False(movingAway);
+ }
+
+ [Fact]
+ public void PlainTowards_DistLessThanDto_Idle()
+ {
+ var p = new MovementParameters { DistanceToObject = 0.6f };
+
+ p.GetCommand(dist: 0.1f, headingDiff: 0f, out uint motion, out _, out _);
+
+ Assert.Equal(0u, motion);
+ }
+
+ // ── pure AWAY (move_away set, move_towards clear) ─────────────────────
+
+ [Fact]
+ public void PureAway_DistLessThanMinDistance_WalkForward_MovingAway()
+ {
+ var p = new MovementParameters
+ {
+ MoveTowards = false,
+ MoveAway = true,
+ MinDistance = 5f,
+ };
+
+ p.GetCommand(dist: 2f, headingDiff: 0f, out uint motion, out _, out bool movingAway);
+
+ Assert.Equal(MotionCommand.WalkForward, motion);
+ Assert.True(movingAway);
+ }
+
+ [Fact]
+ public void PureAway_DistNotLessThanMinDistance_Idle()
+ {
+ var p = new MovementParameters
+ {
+ MoveTowards = false,
+ MoveAway = true,
+ MinDistance = 5f,
+ };
+
+ p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out _, out bool movingAway);
+
+ Assert.Equal(0u, motion);
+ Assert.False(movingAway);
+ }
+
+ // ── towards_and_away delegate (both move_towards AND move_away set) ───
+
+ [Fact]
+ public void TowardsAndAway_DistGreaterThanDto_DelegatesToWalkForwardTowards()
+ {
+ var p = new MovementParameters
+ {
+ MoveTowards = true,
+ MoveAway = true,
+ DistanceToObject = 0.6f,
+ MinDistance = 0.2f,
+ };
+
+ p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out _, out bool movingAway);
+
+ Assert.Equal(MotionCommand.WalkForward, motion);
+ Assert.False(movingAway);
+ }
+
+ [Fact]
+ public void TowardsAndAway_InsideMinBand_WalkBackwards_MovingAway()
+ {
+ var p = new MovementParameters
+ {
+ MoveTowards = true,
+ MoveAway = true,
+ DistanceToObject = 0.6f,
+ MinDistance = 0.2f,
+ };
+
+ // dist - min_distance < epsilon → inside the min band
+ p.GetCommand(dist: 0.2f, headingDiff: 0f, out uint motion, out _, out bool movingAway);
+
+ Assert.Equal(MotionCommand.WalkBackward, motion);
+ Assert.True(movingAway);
+ }
+
+ [Fact]
+ public void TowardsAndAway_InsideDeadband_Idle()
+ {
+ var p = new MovementParameters
+ {
+ MoveTowards = true,
+ MoveAway = true,
+ DistanceToObject = 0.6f,
+ MinDistance = 0.2f,
+ };
+
+ // strictly inside [min, dto] — neither band fires
+ p.GetCommand(dist: 0.4f, headingDiff: 0f, out uint motion, out _, out _);
+
+ Assert.Equal(0u, motion);
+ }
+
+ // ── neither towards nor away (both clear) — falls to plain-towards path ──
+
+ [Fact]
+ public void NeitherTowardsNorAway_FallsToPlainTowardsBranch()
+ {
+ var p = new MovementParameters
+ {
+ MoveTowards = false,
+ MoveAway = false,
+ DistanceToObject = 0.6f,
+ };
+
+ p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out _, out bool movingAway);
+
+ Assert.Equal(MotionCommand.WalkForward, motion);
+ Assert.False(movingAway);
+ }
+
+ // ── walk-vs-run HoldKey cascade ────────────────────────────────────────
+
+ [Fact]
+ public void HoldKey_CanChargeSet_AlwaysRun_FastPath()
+ {
+ // THE fast-path ACE dropped: can_charge (0x10) short-circuits
+ // straight to HoldKey_Run regardless of distance/threshold.
+ var p = new MovementParameters
+ {
+ CanCharge = true,
+ CanRun = false, // even with can_run CLEAR
+ CanWalk = true,
+ WalkRunThreshhold = 15f,
+ DistanceToObject = 0.6f,
+ };
+
+ p.GetCommand(dist: 0.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _);
+
+ Assert.Equal(HoldKey.Run, holdKey);
+ }
+
+ [Fact]
+ public void HoldKey_CanRunClear_AlwaysWalk_RegardlessOfDistance()
+ {
+ var p = new MovementParameters
+ {
+ CanCharge = false,
+ CanRun = false,
+ CanWalk = true,
+ WalkRunThreshhold = 15f,
+ DistanceToObject = 0.6f,
+ };
+
+ p.GetCommand(dist: 1000f, headingDiff: 0f, out _, out HoldKey holdKey, out _);
+
+ Assert.Equal(HoldKey.None, holdKey);
+ }
+
+ [Fact]
+ public void HoldKey_CanRunSet_CanWalkClear_AlwaysRun_WalkIncapable()
+ {
+ // can_walk clear → the "close enough to walk" branch is skipped
+ // entirely; walk-incapable movers always run when can_run is set.
+ var p = new MovementParameters
+ {
+ CanCharge = false,
+ CanRun = true,
+ CanWalk = false,
+ WalkRunThreshhold = 15f,
+ DistanceToObject = 0.6f,
+ };
+
+ p.GetCommand(dist: 0.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _);
+
+ Assert.Equal(HoldKey.Run, holdKey);
+ }
+
+ [Fact]
+ public void HoldKey_CanRunAndCanWalk_WithinThreshold_Walk()
+ {
+ var p = new MovementParameters
+ {
+ CanCharge = false,
+ CanRun = true,
+ CanWalk = true,
+ WalkRunThreshhold = 15f,
+ DistanceToObject = 0.6f,
+ };
+
+ // dist - dto = 10 <= 15 → walk
+ p.GetCommand(dist: 10.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _);
+
+ Assert.Equal(HoldKey.None, holdKey);
+ }
+
+ [Fact]
+ public void HoldKey_CanRunAndCanWalk_BeyondThreshold_Run()
+ {
+ var p = new MovementParameters
+ {
+ CanCharge = false,
+ CanRun = true,
+ CanWalk = true,
+ WalkRunThreshhold = 15f,
+ DistanceToObject = 0.6f,
+ };
+
+ // dist - dto = 15.1 > 15 → run
+ p.GetCommand(dist: 15.7f, headingDiff: 0f, out _, out HoldKey holdKey, out _);
+
+ Assert.Equal(HoldKey.Run, holdKey);
+ }
+
+ [Fact]
+ public void HoldKey_ThresholdEdge_ExactlyAtThreshold_IsInclusive_Walk()
+ {
+ // retail: (dist - distance_to_object) <= walk_run_threshhold → WALK.
+ // The raw's `test ah,0x41` after `fcom` renders as an inclusive
+ // "not greater than" (≤) — the boundary itself walks, not runs.
+ var p = new MovementParameters
+ {
+ CanCharge = false,
+ CanRun = true,
+ CanWalk = true,
+ WalkRunThreshhold = 15f,
+ DistanceToObject = 0.6f,
+ };
+
+ // dist - dto = exactly 15.0
+ p.GetCommand(dist: 15.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _);
+
+ Assert.Equal(HoldKey.None, holdKey);
+ }
+
+ [Fact]
+ public void HoldKey_ThresholdEdge_JustOverThreshold_Run()
+ {
+ var p = new MovementParameters
+ {
+ CanCharge = false,
+ CanRun = true,
+ CanWalk = true,
+ WalkRunThreshhold = 15f,
+ DistanceToObject = 0.6f,
+ };
+
+ // dist - dto = 15.0 + epsilon
+ p.GetCommand(dist: 15.600001f, headingDiff: 0f, out _, out HoldKey holdKey, out _);
+
+ Assert.Equal(HoldKey.Run, holdKey);
+ }
+
+ [Fact]
+ public void HoldKey_CanChargeSet_OverridesWalkIncapableAndThreshold()
+ {
+ // CanCharge fast-path wins even when every other flag would say walk.
+ var p = new MovementParameters
+ {
+ CanCharge = true,
+ CanRun = true,
+ CanWalk = true,
+ WalkRunThreshhold = 1000f, // would otherwise force walk
+ DistanceToObject = 0.6f,
+ };
+
+ p.GetCommand(dist: 0.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _);
+
+ Assert.Equal(HoldKey.Run, holdKey);
+ }
+
+ // ── the four capability quadrants × plain-towards distance bands ──────
+
+ [Theory]
+ // (canRun, canWalk, canCharge, distBeyondThreshold) → expected HoldKey
+ [InlineData(true, true, false, false, HoldKey.None)] // both capable, close → walk
+ [InlineData(true, true, false, true, HoldKey.Run)] // both capable, far → run
+ [InlineData(true, false, false, false, HoldKey.Run)] // run-only, close → still run (no walk branch)
+ [InlineData(true, false, false, true, HoldKey.Run)] // run-only, far → run
+ [InlineData(false, true, false, false, HoldKey.None)] // walk-only → always walk
+ [InlineData(false, true, false, true, HoldKey.None)] // walk-only, far → still walk
+ [InlineData(false, false, false, false, HoldKey.None)] // neither capable, no charge → walk (falls through)
+ [InlineData(false, false, true, false, HoldKey.Run)] // can_charge alone → run regardless
+ public void HoldKey_FourCapabilityQuadrants_MatchRetailCascade(
+ bool canRun, bool canWalk, bool canCharge, bool distBeyondThreshold, HoldKey expected)
+ {
+ var p = new MovementParameters
+ {
+ CanRun = canRun,
+ CanWalk = canWalk,
+ CanCharge = canCharge,
+ WalkRunThreshhold = 15f,
+ DistanceToObject = 0.6f,
+ };
+
+ float dist = distBeyondThreshold ? 20f : 5f; // 20-0.6=19.4>15 ; 5-0.6=4.4<=15
+ p.GetCommand(dist, headingDiff: 0f, out _, out HoldKey holdKey, out _);
+
+ Assert.Equal(expected, holdKey);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetDesiredHeadingTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetDesiredHeadingTests.cs
new file mode 100644
index 00000000..7931a5f3
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetDesiredHeadingTests.cs
@@ -0,0 +1,67 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — MovementParameters::get_desired_heading (0x0052aad0),
+/// PINNED by direct Ghidra decompile of patchmem.gpr (see
+/// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P2 — fetched
+/// live during V0, ACE-shaped constants CONFIRMED exact):
+///
+/// forward|run + towards → 0 forward|run + away → 180
+/// backward + towards → 180 backward + away → 0
+/// any other command → 0
+///
+///
+public sealed class MovementParametersGetDesiredHeadingTests
+{
+ [Theory]
+ [InlineData(false, 0f)] // RunForward, towards → 0
+ [InlineData(true, 180f)] // RunForward, away → 180
+ public void RunForward_FourQuadrant(bool movingAway, float expected)
+ {
+ var p = new MovementParameters();
+ float h = p.GetDesiredHeading(MotionCommand.RunForward, movingAway);
+ Assert.Equal(expected, h);
+ }
+
+ [Theory]
+ [InlineData(false, 0f)] // WalkForward, towards → 0
+ [InlineData(true, 180f)] // WalkForward, away → 180
+ public void WalkForward_FourQuadrant(bool movingAway, float expected)
+ {
+ var p = new MovementParameters();
+ float h = p.GetDesiredHeading(MotionCommand.WalkForward, movingAway);
+ Assert.Equal(expected, h);
+ }
+
+ [Theory]
+ [InlineData(false, 180f)] // WalkBackward, towards → 180 (face the target while backing up)
+ [InlineData(true, 0f)] // WalkBackward, away → 0
+ public void WalkBackward_FourQuadrant(bool movingAway, float expected)
+ {
+ var p = new MovementParameters();
+ float h = p.GetDesiredHeading(MotionCommand.WalkBackward, movingAway);
+ Assert.Equal(expected, h);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void UnknownCommand_DefaultsToZero(bool movingAway)
+ {
+ var p = new MovementParameters();
+ float h = p.GetDesiredHeading(MotionCommand.TurnRight, movingAway);
+ Assert.Equal(0f, h);
+ }
+
+ [Fact]
+ public void ZeroCommand_DefaultsToZero()
+ {
+ var p = new MovementParameters();
+ Assert.Equal(0f, p.GetDesiredHeading(0u, movingAway: false));
+ Assert.Equal(0f, p.GetDesiredHeading(0u, movingAway: true));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTowardsAndAwayTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTowardsAndAwayTests.cs
new file mode 100644
index 00000000..25d942b4
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTowardsAndAwayTests.cs
@@ -0,0 +1,84 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — MovementParameters::towards_and_away (0x0052a9a0,
+/// raw 307917-307942), verbatim per r4-moveto-decomp.md §5d. Three bands:
+/// beyond distance_to_object → WalkForward towards; inside the
+/// min_distance epsilon band → WalkBackwards away (no turn, unlike
+/// the pure-away branch in §5c which uses WalkForward+turn-around); strictly
+/// between → idle (cmd 0).
+///
+public sealed class MovementParametersTowardsAndAwayTests
+{
+ [Fact]
+ public void DistGreaterThanDto_WalkForward_Towards()
+ {
+ var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f };
+
+ p.TowardsAndAway(dist: 5f, out uint cmd, out bool movingAway);
+
+ Assert.Equal(MotionCommand.WalkForward, cmd);
+ Assert.False(movingAway);
+ }
+
+ [Fact]
+ public void DistExactlyAtDto_NotGreater_FallsToMinBandCheck()
+ {
+ var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f };
+
+ // dist == dto is NOT > dto, so falls through to the min-band test;
+ // 0.6 - 0.2 = 0.4, not < epsilon → idle.
+ p.TowardsAndAway(dist: 0.6f, out uint cmd, out _);
+
+ Assert.Equal(0u, cmd);
+ }
+
+ [Fact]
+ public void InsideMinDistanceEpsilonBand_WalkBackwards_Away()
+ {
+ var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f };
+
+ // dist - min_distance < 0.000199999995f
+ p.TowardsAndAway(dist: 0.2f, out uint cmd, out bool movingAway);
+
+ Assert.Equal(MotionCommand.WalkBackward, cmd);
+ Assert.True(movingAway);
+ }
+
+ [Fact]
+ public void InsideMinDistanceEpsilonBand_JustBelowEpsilon_StillWalkBackwards()
+ {
+ var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f };
+
+ p.TowardsAndAway(dist: 0.2f + 0.0001f, out uint cmd, out bool movingAway);
+
+ Assert.Equal(MotionCommand.WalkBackward, cmd);
+ Assert.True(movingAway);
+ }
+
+ [Fact]
+ public void StrictlyBetweenMinAndDto_Idle()
+ {
+ var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f };
+
+ p.TowardsAndAway(dist: 0.4f, out uint cmd, out bool movingAway);
+
+ Assert.Equal(0u, cmd);
+ Assert.False(movingAway);
+ }
+
+ [Fact]
+ public void JustOutsideMinBand_NotYetIdle_Idle()
+ {
+ var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f };
+
+ // dist - min = 0.0003, just over epsilon (0.0002) → NOT in the min band → idle
+ p.TowardsAndAway(dist: 0.2003f, out uint cmd, out _);
+
+ Assert.Equal(0u, cmd);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementStructWideningTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementStructWideningTests.cs
new file mode 100644
index 00000000..bc9e9b87
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementStructWideningTests.cs
@@ -0,0 +1,108 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — widening per r4-moveto-decomp.md §0
+/// (acclient.h:38069, struct #4067):
+///
+/// struct __cppobj MovementStruct
+/// {
+/// MovementTypes::Type type;
+/// unsigned int motion; // types 1-4 only
+/// unsigned int object_id; // types 6, 8
+/// unsigned int top_level_id; // types 6, 8
+/// Position pos; // type 7
+/// float radius; // type 6
+/// float height; // type 6
+/// MovementParameters *params; // types 1-4, 6-9
+/// };
+///
+/// Additive-only (M11, mechanical) — no consumer wires these fields yet;
+/// this test just pins the shape exists and round-trips.
+///
+public sealed class MovementStructWideningTests
+{
+ [Fact]
+ public void ObjectId_TopLevelId_RoundTrip()
+ {
+ var mvs = new MovementStruct
+ {
+ Type = MovementType.MoveToObject,
+ ObjectId = 0x50001234u,
+ TopLevelId = 0x50005678u,
+ };
+
+ Assert.Equal(0x50001234u, mvs.ObjectId);
+ Assert.Equal(0x50005678u, mvs.TopLevelId);
+ }
+
+ [Fact]
+ public void Pos_RoundTrips_WorldPositionAndCell()
+ {
+ var pos = new Position(0x12340001u, new Vector3(10f, 20f, 3f), Quaternion.Identity);
+ var mvs = new MovementStruct
+ {
+ Type = MovementType.MoveToPosition,
+ Pos = pos,
+ };
+
+ Assert.Equal(pos, mvs.Pos);
+ Assert.Equal(0x12340001u, mvs.Pos.ObjCellId);
+ Assert.Equal(new Vector3(10f, 20f, 3f), mvs.Pos.Frame.Origin);
+ }
+
+ [Fact]
+ public void Radius_Height_RoundTrip()
+ {
+ var mvs = new MovementStruct
+ {
+ Type = MovementType.MoveToObject,
+ Radius = 0.75f,
+ Height = 1.8f,
+ };
+
+ Assert.Equal(0.75f, mvs.Radius);
+ Assert.Equal(1.8f, mvs.Height);
+ }
+
+ [Fact]
+ public void Params_HoldsMovementParametersReference()
+ {
+ var p = new MovementParameters { CanCharge = true };
+ var mvs = new MovementStruct
+ {
+ Type = MovementType.TurnToHeading,
+ Params = p,
+ };
+
+ Assert.Same(p, mvs.Params);
+ }
+
+ [Fact]
+ public void ExistingFields_Type_Motion_StillPresent_NoRegression()
+ {
+ // The pre-R4 fields (Type/Motion/Speed/Autonomous/ModifyInterpretedState/
+ // ModifyRawState) must survive the widening untouched — R4 is
+ // additive-only per the plan (M11, "no consumer changes").
+ var mvs = new MovementStruct
+ {
+ Type = MovementType.RawCommand,
+ Motion = 0x45000005u,
+ Speed = 1.5f,
+ Autonomous = true,
+ ModifyInterpretedState = true,
+ ModifyRawState = false,
+ };
+
+ Assert.Equal(MovementType.RawCommand, mvs.Type);
+ Assert.Equal(0x45000005u, mvs.Motion);
+ Assert.Equal(1.5f, mvs.Speed);
+ Assert.True(mvs.Autonomous);
+ Assert.True(mvs.ModifyInterpretedState);
+ Assert.False(mvs.ModifyRawState);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementTypeWideningTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementTypeWideningTests.cs
new file mode 100644
index 00000000..ce6f8f05
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementTypeWideningTests.cs
@@ -0,0 +1,33 @@
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V1 — widening to retail's full
+/// MovementTypes::Type enum (acclient.h:2856, enum #229):
+///
+/// Invalid=0, RawCommand=1, InterpretedCommand=2, StopRawCommand=3,
+/// StopInterpretedCommand=4, StopCompletely=5, MoveToObject=6,
+/// MoveToPosition=7, TurnToObject=8, TurnToHeading=9
+///
+/// Mechanical, additive-only pin (M11) — the 1-5 values must not shift
+/// (they're already load-bearing in MotionInterpreter.PerformMovement's
+/// switch).
+///
+public sealed class MovementTypeWideningTests
+{
+ [Theory]
+ [InlineData(MovementType.Invalid, 0)]
+ [InlineData(MovementType.RawCommand, 1)]
+ [InlineData(MovementType.InterpretedCommand, 2)]
+ [InlineData(MovementType.StopRawCommand, 3)]
+ [InlineData(MovementType.StopInterpretedCommand, 4)]
+ [InlineData(MovementType.StopCompletely, 5)]
+ [InlineData(MovementType.MoveToObject, 6)]
+ [InlineData(MovementType.MoveToPosition, 7)]
+ [InlineData(MovementType.TurnToObject, 8)]
+ [InlineData(MovementType.TurnToHeading, 9)]
+ public void EnumValues_MatchRetailMovementTypesTypeTable(MovementType value, int expected)
+ => Assert.Equal(expected, (int)value);
+}
diff --git a/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs b/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs
index 383e8b34..e42b23cc 100644
--- a/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs
@@ -23,6 +23,15 @@ public sealed class WeenieErrorCodeTableTests
public void NoPhysicsObject_Is0x08()
=> Assert.Equal(0x08u, (uint)WeenieError.NoPhysicsObject);
+ ///
+ /// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per
+ /// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants
+ /// inventory row (8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47).
+ ///
+ [Fact]
+ public void NoMotionInterpreter_Is0x0B()
+ => Assert.Equal(0x0Bu, (uint)WeenieError.NoMotionInterpreter);
+
[Fact]
public void NotGrounded_Is0x24()
=> Assert.Equal(0x24u, (uint)WeenieError.NotGrounded);
@@ -43,6 +52,38 @@ public sealed class WeenieErrorCodeTableTests
public void ChatEmoteOutsideNonCombat_Is0x42()
=> Assert.Equal(0x42u, (uint)WeenieError.ChatEmoteOutsideNonCombat);
+ ///
+ /// 0x36 — ActionCancelled. R4-V1 addition (M12). Site:
+ /// MoveToManager::PerformMovement (§3a @0052a901) — every new
+ /// moveto cancels the previous one with this code before dispatching;
+ /// also CPhysicsObj::interrupt_current_movement's
+ /// MovementManager::CancelMoveTo(0x36) call (§9e). Per §7c, the
+ /// arg is NEVER READ inside MoveToManager::CancelMoveTo's body in
+ /// this build — kept for parity/logging, not behavior.
+ ///
+ [Fact]
+ public void ActionCancelled_Is0x36()
+ => Assert.Equal(0x36u, (uint)WeenieError.ActionCancelled);
+
+ ///
+ /// 0x37 — ObjectGone. R4-V1 addition (M12). Site:
+ /// MoveToManager::HandleUpdateTarget (§6d @307866-307867) —
+ /// retarget delivery with a non-OK target status.
+ ///
+ [Fact]
+ public void ObjectGone_Is0x37()
+ => Assert.Equal(0x37u, (uint)WeenieError.ObjectGone);
+
+ ///
+ /// 0x38 — NoObject. R4-V1 addition (M12). Site:
+ /// MoveToManager::HandleUpdateTarget (§6d @307857-307858) — the
+ /// FIRST target callback arrives with a non-OK status (target never
+ /// resolved).
+ ///
+ [Fact]
+ public void NoObject_Is0x38()
+ => Assert.Equal(0x38u, (uint)WeenieError.NoObject);
+
[Fact]
public void ActionDepthExceeded_Is0x45()
=> Assert.Equal(0x45u, (uint)WeenieError.ActionDepthExceeded);
@@ -59,6 +100,15 @@ public sealed class WeenieErrorCodeTableTests
public void CantJumpLoadedDown_Is0x49()
=> Assert.Equal(0x49u, (uint)WeenieError.CantJumpLoadedDown);
+ ///
+ /// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site:
+ /// MoveToManager::HandleMoveToPosition Phase 2 arrival check —
+ /// fail_distance exceeded (r4-moveto-decomp.md §6b).
+ ///
+ [Fact]
+ public void YouChargedTooFar_Is0x3D()
+ => Assert.Equal(0x3Du, (uint)WeenieError.YouChargedTooFar);
+
///
/// Every code in the A10 table in one pass — guards against a
/// future partial edit desyncing an individual test above from the
@@ -67,7 +117,11 @@ public sealed class WeenieErrorCodeTableTests
[Theory]
[InlineData(WeenieError.None, 0x00u)]
[InlineData(WeenieError.NoPhysicsObject, 0x08u)]
+ [InlineData(WeenieError.NoMotionInterpreter, 0x0Bu)]
[InlineData(WeenieError.NotGrounded, 0x24u)]
+ [InlineData(WeenieError.ActionCancelled, 0x36u)]
+ [InlineData(WeenieError.ObjectGone, 0x37u)]
+ [InlineData(WeenieError.NoObject, 0x38u)]
[InlineData(WeenieError.CrouchInCombatStance, 0x3fu)]
[InlineData(WeenieError.SitInCombatStance, 0x40u)]
[InlineData(WeenieError.SleepInCombatStance, 0x41u)]
@@ -76,6 +130,7 @@ public sealed class WeenieErrorCodeTableTests
[InlineData(WeenieError.GeneralMovementFailure, 0x47u)]
[InlineData(WeenieError.YouCantJumpFromThisPosition, 0x48u)]
[InlineData(WeenieError.CantJumpLoadedDown, 0x49u)]
+ [InlineData(WeenieError.YouChargedTooFar, 0x3Du)]
public void A10Table_EveryCode_MatchesRetailNumericValue(WeenieError code, uint expected)
=> Assert.Equal(expected, (uint)code);
}