feat(R4-V1): command-selection family + state widening (closes M2-mechanics, M11, M12, M15)

MovementParameters gains the verbatim selection family:
GetCommand 0x0052aa00 (the walk-vs-run cascade INCLUDING the CanCharge
0x10 fast-path ACE dropped - retail's default can_charge=false + the
fast-path present, the A13+A15 canceling-pair trap avoided; inclusive
threshold edge per the raw), TowardsAndAway, GetDesiredHeading per the
live Ghidra decompile (fwd-towards 0 / fwd-away 180 / back-towards 180
/ back-away 0), FromWire/FromWireTurnTo (UnPackNet semantics, all 18 A4
masks round-tripped).

New MoveToMath: HeadingDiff per the live Ghidra decompile of 0x00528fb0
(the 360-diff NOT-TurnRight mirror + F_EPSILON 0.000199999995f - the
BN "arg unused" artifact corrected), HeadingGreater (the visible
TurnRight idiom), PositionHeading/Get/SetHeading reusing the codebase's
single yaw-heading convention (P5), CylinderDistance (PDB arg order;
planar-minus-radii shape documented as the interpretation - the raw's
x87 body is garbled; seam noted).

MovementType gains Invalid + retail 6/7/8/9; MovementStruct widened
(ObjectId/TopLevelId/Pos/Radius/Height/Params, additive); WeenieError
+= 0x0B/0x36/0x37/0x38/0x3D with retail-meaning doc comments.

148 new conformance tests. Full suite: 3,860 passed.

Implemented by a dedicated agent against the V0-pinned spec; scope +
suite independently verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 11:13:15 +02:00
parent 386b1ce550
commit e0d2492cbb
14 changed files with 1973 additions and 4 deletions

View file

@ -0,0 +1,91 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <c>Position::cylinder_distance</c>, the pure-math shape per
/// r4-moveto-decomp.md §5a (<c>MoveToManager::GetCurrentDistance</c>,
/// <c>005291b0</c>): 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 <c>Position::distance</c> (§5a: "position moves use center
/// distance" — <see cref="MoveToMath.CylinderDistance"/> is the object-move
/// variant only; center distance is <c>Vector3.Distance</c>, already
/// available, not re-ported here).
///
/// <para>
/// 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
/// <c>distance_to_object</c>'s ctor default of 0.6 (melee range from
/// surface to surface, not center to center).
/// </para>
/// </summary>
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);
}
}

View file

@ -0,0 +1,164 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <c>heading_diff</c> (<c>0x00528fb0</c>), 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:
/// <code>
/// d = h1 - h2;
/// if (fabs(h1 - h2) &lt; F_EPSILON) d = 0;
/// if (d &lt; -F_EPSILON) d += 360;
/// if (F_EPSILON &lt; d &amp;&amp; turnCmd != TurnRight) d = 360 - d; // the mirror
/// return d;
/// </code>
/// 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).
/// </summary>
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);
}
}

View file

@ -0,0 +1,82 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <c>heading_greater</c> (<c>00528f60</c>, raw 306281-306323), per
/// r4-moveto-decomp.md §5f:
/// <code>
/// if (fabs(a - b) &gt; 180) greater = (b &gt; a); // wrapped case: compare flipped
/// else greater = (a &gt; b);
/// if (turnCmd == TurnRight) return greater;
/// return !greater; // TurnLeft: inverted
/// </code>
/// "Has the turn passed the target heading" — direction-aware, wrap-aware.
/// </summary>
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));
}
}

View file

@ -0,0 +1,127 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <c>Position::heading</c> / <c>Frame::get_heading</c> /
/// <c>Frame::set_heading</c>, per V0-pins.md §P5 (PINNED — compass degrees,
/// 0 = North (+Y), 90 = East (+X), CLOCKWISE, [0,360); identity quaternion
/// faces heading 0):
/// <code>
/// heading(from, to) = (450 - atan2Deg(dy, dx)) % 360
/// </code>
/// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270.
///
/// <para>
/// <b>The packer-reuse trap (V0-pins §P5 correction):</b> acdream's
/// outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is wire-correct at
/// the QUATERNION level but its internal scalar intermediate
/// (<c>headingDeg = 180 - yawDeg</c>) is holtburger's shifted convention,
/// NOT retail's wire convention. <see cref="MoveToMath.GetHeading"/> must
/// use the CORRECT scalar bridge from acdream yaw (yaw=0 faces +X, per
/// <c>PlayerMovementController.cs:1022-1025</c>): <c>heading = (90 -
/// yawDeg) mod 360</c> — NOT <c>180 - yawDeg</c>.
/// </para>
/// </summary>
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}");
}
}

View file

@ -0,0 +1,186 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <c>MovementParameters::UnPackNet</c> (<c>0x0052ac50</c>, 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 <c>bitfield, distance_to_object, min_distance,
/// fail_distance, speed, walk_run_threshhold, desired_heading</c> — the SAME
/// field order <c>UpdateMotion.TryParseMoveToPayload</c> 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).
/// </summary>
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;
}
}

View file

@ -0,0 +1,340 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <c>MovementParameters::get_command</c> (<c>0x0052aa00</c>, 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-&lt; edge (retail: dist - dto ≤
/// threshold → walk; the raw's `test ah,0x41` is the inclusive ≤ reading,
/// §5c @308003).
/// </summary>
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);
}
}

View file

@ -0,0 +1,67 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <c>MovementParameters::get_desired_heading</c> (<c>0x0052aad0</c>),
/// PINNED by direct Ghidra decompile of <c>patchmem.gpr</c> (see
/// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P2 — fetched
/// live during V0, ACE-shaped constants CONFIRMED exact):
/// <code>
/// forward|run + towards → 0 forward|run + away → 180
/// backward + towards → 180 backward + away → 0
/// any other command → 0
/// </code>
/// </summary>
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));
}
}

View file

@ -0,0 +1,84 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <c>MovementParameters::towards_and_away</c> (<c>0x0052a9a0</c>,
/// raw 307917-307942), verbatim per r4-moveto-decomp.md §5d. Three bands:
/// beyond <c>distance_to_object</c> → WalkForward towards; inside the
/// <c>min_distance</c> epsilon band → WalkBackwards away (no turn, unlike
/// the pure-away branch in §5c which uses WalkForward+turn-around); strictly
/// between → idle (cmd 0).
/// </summary>
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);
}
}

View file

@ -0,0 +1,108 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <see cref="MovementStruct"/> widening per r4-moveto-decomp.md §0
/// (acclient.h:38069, struct #4067):
/// <code>
/// 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
/// };
/// </code>
/// Additive-only (M11, mechanical) — no consumer wires these fields yet;
/// this test just pins the shape exists and round-trips.
/// </summary>
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);
}
}

View file

@ -0,0 +1,33 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V1 — <see cref="MovementType"/> widening to retail's full
/// <c>MovementTypes::Type</c> enum (acclient.h:2856, enum #229):
/// <code>
/// Invalid=0, RawCommand=1, InterpretedCommand=2, StopRawCommand=3,
/// StopInterpretedCommand=4, StopCompletely=5, MoveToObject=6,
/// MoveToPosition=7, TurnToObject=8, TurnToHeading=9
/// </code>
/// Mechanical, additive-only pin (M11) — the 1-5 values must not shift
/// (they're already load-bearing in <c>MotionInterpreter.PerformMovement</c>'s
/// switch).
/// </summary>
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);
}

View file

@ -23,6 +23,15 @@ public sealed class WeenieErrorCodeTableTests
public void NoPhysicsObject_Is0x08()
=> Assert.Equal(0x08u, (uint)WeenieError.NoPhysicsObject);
/// <summary>
/// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per
/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants
/// inventory row (<c>8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47</c>).
/// </summary>
[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);
/// <summary>
/// 0x36 — ActionCancelled. R4-V1 addition (M12). Site:
/// <c>MoveToManager::PerformMovement</c> (§3a @0052a901) — every new
/// moveto cancels the previous one with this code before dispatching;
/// also <c>CPhysicsObj::interrupt_current_movement</c>'s
/// <c>MovementManager::CancelMoveTo(0x36)</c> call (§9e). Per §7c, the
/// arg is NEVER READ inside <c>MoveToManager::CancelMoveTo</c>'s body in
/// this build — kept for parity/logging, not behavior.
/// </summary>
[Fact]
public void ActionCancelled_Is0x36()
=> Assert.Equal(0x36u, (uint)WeenieError.ActionCancelled);
/// <summary>
/// 0x37 — ObjectGone. R4-V1 addition (M12). Site:
/// <c>MoveToManager::HandleUpdateTarget</c> (§6d @307866-307867) —
/// retarget delivery with a non-OK target status.
/// </summary>
[Fact]
public void ObjectGone_Is0x37()
=> Assert.Equal(0x37u, (uint)WeenieError.ObjectGone);
/// <summary>
/// 0x38 — NoObject. R4-V1 addition (M12). Site:
/// <c>MoveToManager::HandleUpdateTarget</c> (§6d @307857-307858) — the
/// FIRST target callback arrives with a non-OK status (target never
/// resolved).
/// </summary>
[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);
/// <summary>
/// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site:
/// <c>MoveToManager::HandleMoveToPosition</c> Phase 2 arrival check —
/// <c>fail_distance</c> exceeded (r4-moveto-decomp.md §6b).
/// </summary>
[Fact]
public void YouChargedTooFar_Is0x3D()
=> Assert.Equal(0x3Du, (uint)WeenieError.YouChargedTooFar);
/// <summary>
/// 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);
}