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:
parent
386b1ce550
commit
e0d2492cbb
14 changed files with 1973 additions and 4 deletions
201
src/AcDream.Core/Physics/Motion/MoveToMath.cs
Normal file
201
src/AcDream.Core/Physics/Motion/MoveToMath.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-V1 — pure-math free functions consumed by the (future) MoveToManager
|
||||||
|
/// port: <c>heading_diff</c>, <c>heading_greater</c>, <c>Position::heading</c>
|
||||||
|
/// / <c>Frame::get_heading</c> / <c>Frame::set_heading</c>, and
|
||||||
|
/// <c>Position::cylinder_distance</c>. No GL/App dependency — Core-only,
|
||||||
|
/// per the Code Structure Rules. NAME WATCH: this file (not
|
||||||
|
/// <c>MoveToManager.cs</c>, per r4-port-plan.md §3 "New code target") is the
|
||||||
|
/// R4-V1 deliverable; the manager itself is R4-V2.
|
||||||
|
/// </summary>
|
||||||
|
public static class MoveToMath
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Universal heading/distance epsilon (same literal as R3's A5/A6 —
|
||||||
|
/// r4-moveto-decomp.md §12 constants inventory).
|
||||||
|
/// </summary>
|
||||||
|
public const float Epsilon = 0.000199999995f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>heading_diff</c> (<c>0x00528fb0</c>, 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):
|
||||||
|
/// <code>
|
||||||
|
/// 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;
|
||||||
|
/// </code>
|
||||||
|
/// 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: <c>BeginTurnToHeading</c>
|
||||||
|
/// passes the CONSTANT TurnRight (mirror explicitly disabled — the
|
||||||
|
/// direction pick stays the ≤180 test elsewhere); <c>HandleTurnToHeading</c>
|
||||||
|
/// passes the LIVE <c>current_command</c> (can be TurnLeft).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="h1">First heading, degrees.</param>
|
||||||
|
/// <param name="h2">Second heading, degrees.</param>
|
||||||
|
/// <param name="turnCmd">The active turn command id — gates the mirror.</param>
|
||||||
|
/// <returns>Normalized heading difference, degrees.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>heading_greater</c> (<c>00528f60</c>, free function, raw
|
||||||
|
/// 306281-306323), verbatim per r4-moveto-decomp.md §5f:
|
||||||
|
/// <code>
|
||||||
|
/// 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
|
||||||
|
/// </code>
|
||||||
|
/// "Has the turn passed the target heading" — direction-aware,
|
||||||
|
/// 360°-wrap-aware. The visible TurnRight-arg idiom: the gate is
|
||||||
|
/// <c>== TurnRight</c> (not <c>!= TurnRight</c> as in
|
||||||
|
/// <see cref="HeadingDiff"/>'s mirror) — every OTHER command inverts,
|
||||||
|
/// not just TurnLeft specifically.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>Position::heading(from, to)</c> (<c>0x005a9520</c>, raw
|
||||||
|
/// 438288-438290), PINNED per V0-pins.md §P5: compass degrees, 0 =
|
||||||
|
/// North (+Y), 90 = East (+X), CLOCKWISE, range [0,360).
|
||||||
|
/// <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.
|
||||||
|
/// 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 <c>SceneryHelpers.cs:75</c> (render-side,
|
||||||
|
/// independently verified — not reused directly to keep this file
|
||||||
|
/// GL-free per the Code Structure Rules, but the formula is identical).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>Frame::get_heading</c> (<c>0x00535760</c>, raw 319781) —
|
||||||
|
/// extracts the compass heading (P5 convention) from a body orientation
|
||||||
|
/// quaternion. <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. This method uses the CORRECT
|
||||||
|
/// scalar bridge derived from acdream's own body convention
|
||||||
|
/// (<c>PlayerMovementController.cs:1022-1025</c>: <c>Orientation =
|
||||||
|
/// AxisAngle(Z, Yaw - PI/2)</c>, local-forward = +Y, Yaw=0 faces +X):
|
||||||
|
/// world-forward = <c>(cos Yaw, sin Yaw)</c>, so
|
||||||
|
/// <c>YawDeg = atan2Deg(forward.Y, forward.X)</c> and
|
||||||
|
/// <c>heading = (90 - YawDeg) mod 360</c> — the exact inverse of
|
||||||
|
/// <see cref="SetHeading"/>. Identity quaternion (Yaw=PI/2, i.e. facing
|
||||||
|
/// +Y/North) → heading 0, matching P5's "identity quaternion faces
|
||||||
|
/// heading 0" pin.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>Frame::set_heading</c> (<c>0x00535e40</c>, raw
|
||||||
|
/// 320055-320066) — builds a body orientation quaternion facing
|
||||||
|
/// <paramref name="headingDeg"/> (P5 compass convention), preserving
|
||||||
|
/// acdream's body-orientation convention (rotation about world Z only;
|
||||||
|
/// <paramref name="baseOrientation"/>'s pitch/roll, if any, is
|
||||||
|
/// discarded — matching retail's <c>set_heading</c>, which is a pure
|
||||||
|
/// yaw-about-Z setter). Exact inverse of <see cref="GetHeading"/>:
|
||||||
|
/// <c>YawDeg = 90 - headingDeg</c>, then <c>Orientation =
|
||||||
|
/// AxisAngle(Z, Yaw - PI/2)</c> per
|
||||||
|
/// <c>PlayerMovementController.cs:1025</c>'s convention.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="baseOrientation">Unused beyond signature parity with
|
||||||
|
/// the render-side <c>SceneryHelpers.SetHeading</c> twin — retail's
|
||||||
|
/// <c>set_heading</c> 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.</param>
|
||||||
|
/// <param name="headingDeg">Desired compass heading, degrees.</param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>Position::cylinder_distance</c>, the pure-math shape
|
||||||
|
/// consumed by <c>MoveToManager::GetCurrentDistance</c>
|
||||||
|
/// (<c>005291b0</c>, r4-moveto-decomp.md §5a) when <c>use_spheres</c>
|
||||||
|
/// (wire bit 0x400) is set — object moves use edge-to-edge cylinder
|
||||||
|
/// distance; position moves use plain center (Euclidean) distance
|
||||||
|
/// instead (not ported here — <c>Vector3.Distance</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
namespace AcDream.Core.Physics.Motion;
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -184,4 +186,287 @@ public sealed class MovementParameters
|
||||||
|
|
||||||
/// <summary>Retail default 0.</summary>
|
/// <summary>Retail default 0.</summary>
|
||||||
public uint ActionStamp { get; set; }
|
public uint ActionStamp { get; set; }
|
||||||
|
|
||||||
|
// ── R4-V1: command-selection family (closes M2-mechanics) ─────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <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. Picks the
|
||||||
|
/// motion command + moving-away flag from the towards/away bitfield
|
||||||
|
/// combination, THEN the walk-vs-run <see cref="HoldKey"/> cascade.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Command pick</b> (mirrors <c>towards_and_away</c>'s bands but is
|
||||||
|
/// NOT identical — see the asymmetry note on <see cref="TowardsAndAway"/>):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>MoveTowards && MoveAway</c> → delegate
|
||||||
|
/// to <see cref="TowardsAndAway"/> (the three-band form).</description></item>
|
||||||
|
/// <item><description><c>MoveTowards</c> only (or neither flag set —
|
||||||
|
/// retail's <c>else if</c> falls through to the SAME branch): plain
|
||||||
|
/// towards — <c>dist > DistanceToObject</c> → WalkForward,
|
||||||
|
/// !movingAway; else idle (cmd 0).</description></item>
|
||||||
|
/// <item><description><c>MoveAway</c> only: pure away —
|
||||||
|
/// <c>dist < MinDistance</c> → WalkForward, movingAway=true (the
|
||||||
|
/// heading flips +180 via <see cref="GetDesiredHeading"/> — turn-around,
|
||||||
|
/// NOT WalkBackwards, unlike <see cref="TowardsAndAway"/>'s min-band);
|
||||||
|
/// else idle.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>THE walk-vs-run rule</b> (confirms
|
||||||
|
/// <c>feedback_autowalk_cancharge_bit</c> — port RETAIL's version of
|
||||||
|
/// BOTH the fast-path ACE dropped and the threshold-close-walk pair):
|
||||||
|
/// <c>HoldKey.Run</c> ⇐ <c>CanCharge</c> set (the fast-path — wins
|
||||||
|
/// regardless of CanRun/CanWalk/distance), OR (<c>CanRun</c> set AND
|
||||||
|
/// (<c>CanWalk</c> clear OR <c>dist - DistanceToObject >
|
||||||
|
/// WalkRunThreshhold</c>)). <c>HoldKey.None</c> (walk) ⇐ no CanRun, or
|
||||||
|
/// walk-capable within the threshold (INCLUSIVE ≤ — the raw's
|
||||||
|
/// <c>test ah,0x41</c> after the fcom is the not-greater-than reading,
|
||||||
|
/// §5c @308003).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dist">Current distance-to-target (retail's
|
||||||
|
/// <c>GetCurrentDistance</c> result — center or cylinder distance per
|
||||||
|
/// <see cref="MoveToMath.CylinderDistance"/>).</param>
|
||||||
|
/// <param name="headingDiff">Heading-to-target minus current heading,
|
||||||
|
/// normalized [0,360) — UNUSED by <c>get_command</c> itself (the raw
|
||||||
|
/// signature carries it for parity with the caller's local; retail's
|
||||||
|
/// body never reads <c>arg3</c> in this build). Kept as a parameter for
|
||||||
|
/// call-site symmetry with <c>BeginMoveForward</c> (§4c), which computes
|
||||||
|
/// it immediately before calling <c>get_command</c>.</param>
|
||||||
|
/// <param name="motion">Chosen motion command id, or 0 if no movement
|
||||||
|
/// is needed (already in range).</param>
|
||||||
|
/// <param name="holdKey">Chosen hold key (walk vs run).</param>
|
||||||
|
/// <param name="movingAway">True if the chosen motion moves the mover
|
||||||
|
/// AWAY from the target (feeds <see cref="GetDesiredHeading"/> and the
|
||||||
|
/// arrival predicate's polarity).</param>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>MovementParameters::towards_and_away</c>
|
||||||
|
/// (<c>0x0052a9a0</c>, raw 307917-307942), VERBATIM per
|
||||||
|
/// r4-moveto-decomp.md §5d. Three bands:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>dist > DistanceToObject</c> → WalkForward,
|
||||||
|
/// towards (not moving away).</description></item>
|
||||||
|
/// <item><description><c>dist - MinDistance < F_EPSILON</c> (inside
|
||||||
|
/// the min-distance band) → WalkBackward, moving away. NOTE the
|
||||||
|
/// asymmetry vs <see cref="GetCommand"/>'s pure-away branch: this backs
|
||||||
|
/// up with WalkBackwards (no turn-around), not WalkForward+heading-flip
|
||||||
|
/// (r4-moveto-decomp.md :656).</description></item>
|
||||||
|
/// <item><description>otherwise (strictly inside [MinDistance,
|
||||||
|
/// DistanceToObject]) → idle (cmd 0).</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dist">Current distance-to-target.</param>
|
||||||
|
/// <param name="cmd">Chosen motion command, or 0 if already in the dead
|
||||||
|
/// band.</param>
|
||||||
|
/// <param name="movingAway">True only for the WalkBackward (min-band)
|
||||||
|
/// case.</param>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>MovementParameters::get_desired_heading</c>
|
||||||
|
/// (<c>0x0052aad0</c>), PINNED by direct Ghidra decompile of
|
||||||
|
/// <c>patchmem.gpr</c> (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):
|
||||||
|
/// <code>
|
||||||
|
/// __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;
|
||||||
|
/// }
|
||||||
|
/// </code>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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) ─────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for the retail <c>MovementParameters::UnPackNet</c> 7-dword
|
||||||
|
/// MoveTo wire form (<c>0x0052ac50</c>, 0x1c bytes, raw 308118-308205 —
|
||||||
|
/// r4-moveto-decomp.md §2g): <c>bitfield, distance_to_object,
|
||||||
|
/// min_distance, fail_distance, speed, walk_run_threshhold,
|
||||||
|
/// desired_heading</c>. Used by MoveToObject (type 6) and
|
||||||
|
/// MoveToPosition (type 7) wire payloads — the SAME field order
|
||||||
|
/// <c>UpdateMotion.TryParseMoveToPayload</c> 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 <paramref name="bitfield"/> resolves to false); the wire
|
||||||
|
/// bitfield carries <c>can_charge</c> (0x10), the walk-vs-run answer
|
||||||
|
/// consumed by <see cref="GetCommand"/> (cross-ref
|
||||||
|
/// <c>feedback_autowalk_cancharge_bit</c>).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for the retail <c>MovementParameters::UnPackNet</c> 3-dword
|
||||||
|
/// TurnTo wire form (0xc bytes, r4-moveto-decomp.md §2g): <c>bitfield,
|
||||||
|
/// speed, desired_heading</c>. Used by TurnToObject (type 8) and
|
||||||
|
/// TurnToHeading (type 9) wire payloads. Distance-related scalars
|
||||||
|
/// (<c>DistanceToObject</c>/<c>MinDistance</c>/<c>FailDistance</c>/
|
||||||
|
/// <c>WalkRunThreshhold</c>) are NOT on this wire form and keep the
|
||||||
|
/// <see cref="MovementParameters"/> ctor defaults — retail's UnPackNet
|
||||||
|
/// for this form only ever writes the three fields named here.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode the A4-pinned bitfield masks onto the named bool properties.
|
||||||
|
/// Shared by <see cref="FromWire"/>/<see cref="FromWireTurnTo"/> — the
|
||||||
|
/// wire bitfield always fully overwrites (retail's UnPackNet assigns
|
||||||
|
/// the raw dword straight into <c>this->bitfield</c>, no merge).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,31 @@ public static class MotionCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Movement type passed in PerformMovement's switch statement.
|
/// Movement type passed in PerformMovement's switch statement. Matches
|
||||||
/// Matches the 5-case switch at FUN_00529a90.
|
/// retail's <c>MovementTypes::Type</c> (acclient.h:2856, enum #229) in full.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>R4-V1 widening (closes M11).</b> Values 1-5 dispatch through
|
||||||
|
/// <c>CMotionInterp</c> (the 5-case switch at FUN_00529a90, unchanged since
|
||||||
|
/// R1-R3); values 6-9 dispatch through <c>MoveToManager</c>
|
||||||
|
/// (<c>MovementManager::PerformMovement</c>, r4-moveto-decomp.md §2b:
|
||||||
|
/// <c>(type - 1) > 8 → 0x47</c>, case 0-4 → CMotionInterp, case 5-8 →
|
||||||
|
/// MoveToManager). <c>Invalid=0</c> and values > 9 both fail with
|
||||||
|
/// <see cref="WeenieError.GeneralMovementFailure"/> (0x47) at the
|
||||||
|
/// MovementManager level — no consumer wiring changes in this slice
|
||||||
|
/// (mechanical, additive-only; MoveToManager itself is R4-V2+).
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum MovementType
|
public enum MovementType
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 0 — no movement in progress / uninitialized. R4-V1 addition (M11).
|
||||||
|
/// <c>MoveToManager::InitializeLocalVariables</c> resets
|
||||||
|
/// <c>movement_type</c> to this value; <c>MovementManager::PerformMovement</c>
|
||||||
|
/// rejects it with 0x47 (§2b: <c>(type - 1) > 8</c> underflows to a
|
||||||
|
/// huge unsigned value for type 0, which is always > 8).
|
||||||
|
/// </summary>
|
||||||
|
Invalid = 0,
|
||||||
/// <summary>case 1 — raw motion command (DoMotion).</summary>
|
/// <summary>case 1 — raw motion command (DoMotion).</summary>
|
||||||
RawCommand = 1,
|
RawCommand = 1,
|
||||||
/// <summary>case 2 — interpreted motion command (DoInterpretedMotion).</summary>
|
/// <summary>case 2 — interpreted motion command (DoInterpretedMotion).</summary>
|
||||||
|
|
@ -110,6 +130,31 @@ public enum MovementType
|
||||||
StopInterpretedCommand = 4,
|
StopInterpretedCommand = 4,
|
||||||
/// <summary>case 5 — stop completely (StopCompletely).</summary>
|
/// <summary>case 5 — stop completely (StopCompletely).</summary>
|
||||||
StopCompletely = 5,
|
StopCompletely = 5,
|
||||||
|
/// <summary>
|
||||||
|
/// 6 — MoveToObject. R4-V1 addition (M11). Dispatches to
|
||||||
|
/// <c>MoveToManager::MoveToObject</c> (r4-moveto-decomp.md §3b); uses
|
||||||
|
/// <see cref="MovementStruct.ObjectId"/>/<see cref="MovementStruct.TopLevelId"/>/
|
||||||
|
/// <see cref="MovementStruct.Radius"/>/<see cref="MovementStruct.Height"/>.
|
||||||
|
/// </summary>
|
||||||
|
MoveToObject = 6,
|
||||||
|
/// <summary>
|
||||||
|
/// 7 — MoveToPosition. R4-V1 addition (M11). Dispatches to
|
||||||
|
/// <c>MoveToManager::MoveToPosition</c> (§3c); uses
|
||||||
|
/// <see cref="MovementStruct.Pos"/>.
|
||||||
|
/// </summary>
|
||||||
|
MoveToPosition = 7,
|
||||||
|
/// <summary>
|
||||||
|
/// 8 — TurnToObject. R4-V1 addition (M11). Dispatches to
|
||||||
|
/// <c>MoveToManager::TurnToObject</c> (§3d); uses
|
||||||
|
/// <see cref="MovementStruct.ObjectId"/>/<see cref="MovementStruct.TopLevelId"/>.
|
||||||
|
/// </summary>
|
||||||
|
TurnToObject = 8,
|
||||||
|
/// <summary>
|
||||||
|
/// 9 — TurnToHeading. R4-V1 addition (M11). Dispatches to
|
||||||
|
/// <c>MoveToManager::TurnToHeading</c> (§3e); uses
|
||||||
|
/// <see cref="MovementStruct.Params"/>'s <c>DesiredHeading</c>.
|
||||||
|
/// </summary>
|
||||||
|
TurnToHeading = 9,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -144,6 +189,15 @@ public enum WeenieError : uint
|
||||||
/// </summary>
|
/// </summary>
|
||||||
NoPhysicsObject = 0x08,
|
NoPhysicsObject = 0x08,
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per
|
||||||
|
/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants
|
||||||
|
/// inventory (<c>8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47</c>). 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).
|
||||||
|
/// </summary>
|
||||||
|
NoMotionInterpreter = 0x0B,
|
||||||
|
/// <summary>
|
||||||
/// 0x24 — not grounded / no contact. Sites (A10):
|
/// 0x24 — not grounded / no contact. Sites (A10):
|
||||||
/// <c>jump_is_allowed</c> @305570 (gravity-active creature without
|
/// <c>jump_is_allowed</c> @305570 (gravity-active creature without
|
||||||
/// Contact+OnWalkable; also the <c>physics_obj == null</c> case, which
|
/// Contact+OnWalkable; also the <c>physics_obj == null</c> case, which
|
||||||
|
|
@ -173,6 +227,30 @@ public enum WeenieError : uint
|
||||||
/// </summary>
|
/// </summary>
|
||||||
ChatEmoteOutsideNonCombat = 0x42,
|
ChatEmoteOutsideNonCombat = 0x42,
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// 0x36 — ActionCancelled. R4-V1 addition (M12). Site:
|
||||||
|
/// <c>MoveToManager::PerformMovement</c> (r4-moveto-decomp.md §3a
|
||||||
|
/// @0052a901) — every new moveto cancels the previous one with this
|
||||||
|
/// code before dispatching; also <c>CPhysicsObj::interrupt_current_movement</c>
|
||||||
|
/// → <c>MovementManager::CancelMoveTo(0x36)</c> (§9e — the TS-36 cancel
|
||||||
|
/// entry). Per §7c, <c>MoveToManager::CancelMoveTo</c>'s WeenieError arg
|
||||||
|
/// is NEVER READ in this build's body — kept for parity/logging only.
|
||||||
|
/// </summary>
|
||||||
|
ActionCancelled = 0x36,
|
||||||
|
/// <summary>
|
||||||
|
/// 0x37 — ObjectGone. R4-V1 addition (M12). Site:
|
||||||
|
/// <c>MoveToManager::HandleUpdateTarget</c> (§6d @307866-307867) — a
|
||||||
|
/// RETARGET delivery arrives with a non-OK target status (the target
|
||||||
|
/// object was already being tracked, then went away).
|
||||||
|
/// </summary>
|
||||||
|
ObjectGone = 0x37,
|
||||||
|
/// <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 (the target never
|
||||||
|
/// resolved in the first place).
|
||||||
|
/// </summary>
|
||||||
|
NoObject = 0x38,
|
||||||
|
/// <summary>
|
||||||
/// 0x45 — action-queue depth cap: an action-class motion (bit
|
/// 0x45 — action-queue depth cap: an action-class motion (bit
|
||||||
/// 0x10000000) with <c>GetNumActions() >= 6</c> pending. Site:
|
/// 0x10000000) with <c>GetNumActions() >= 6</c> pending. Site:
|
||||||
/// DoMotion @306209.
|
/// DoMotion @306209.
|
||||||
|
|
@ -203,6 +281,17 @@ public enum WeenieError : uint
|
||||||
/// @304941; <c>charge_jump</c> @305454.
|
/// @304941; <c>charge_jump</c> @305454.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
CantJumpLoadedDown = 0x49,
|
CantJumpLoadedDown = 0x49,
|
||||||
|
/// <summary>
|
||||||
|
/// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site:
|
||||||
|
/// <c>MoveToManager::HandleMoveToPosition</c> Phase 2 arrival check
|
||||||
|
/// (r4-moveto-decomp.md §6b) — the <c>fail_distance</c> progress gate
|
||||||
|
/// exceeded (<c>CheckProgressMade</c> §5b failing for >1s AND the
|
||||||
|
/// mover overshot <c>fail_distance</c>). 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).
|
||||||
|
/// </summary>
|
||||||
|
YouChargedTooFar = 0x3D,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Motion state structs ───────────────────────────────────────────────────────
|
// ── Motion state structs ───────────────────────────────────────────────────────
|
||||||
|
|
@ -404,12 +493,40 @@ public struct InterpretedMotionState
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lightweight struct passed into PerformMovement.
|
/// Lightweight struct passed into PerformMovement.
|
||||||
/// Fields correspond to what the retail dispatcher read from param_1 (the movement packet struct).
|
/// Fields correspond to what the retail dispatcher read from param_1 (the movement packet struct).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>R4-V1 widening (closes M11).</b> Retail's full <c>MovementStruct</c>
|
||||||
|
/// (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>
|
||||||
|
/// <see cref="ObjectId"/>/<see cref="TopLevelId"/>/<see cref="Pos"/>/
|
||||||
|
/// <see cref="Radius"/>/<see cref="Height"/>/<see cref="Params"/> are the
|
||||||
|
/// R4-V1 additions — additive only, no consumer wiring in this slice
|
||||||
|
/// (MoveToManager itself is R4-V2). The pre-R4 fields (<see cref="Type"/>/
|
||||||
|
/// <see cref="Motion"/>/<see cref="Speed"/>/<see cref="Autonomous"/>/
|
||||||
|
/// <see cref="ModifyInterpretedState"/>/<see cref="ModifyRawState"/>) are
|
||||||
|
/// untouched. <see cref="Pos"/> uses acdream's <see cref="Position"/>
|
||||||
|
/// (ObjCellId + CellFrame) rather than retail's block-local Position —
|
||||||
|
/// V0-pins.md §P5: distances are equivalent after rebase in acdream's
|
||||||
|
/// streaming-world space.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct MovementStruct
|
public struct MovementStruct
|
||||||
{
|
{
|
||||||
/// <summary>Which of the 5 motion types to dispatch.</summary>
|
/// <summary>Which movement type to dispatch (retail <c>MovementTypes::Type</c>, full 0-9 range).</summary>
|
||||||
public MovementType Type;
|
public MovementType Type;
|
||||||
/// <summary>Motion command ID (e.g. WalkForward).</summary>
|
/// <summary>Motion command ID (e.g. WalkForward). Types 1-4 only.</summary>
|
||||||
public uint Motion;
|
public uint Motion;
|
||||||
/// <summary>Speed scalar for this motion.</summary>
|
/// <summary>Speed scalar for this motion.</summary>
|
||||||
public float Speed;
|
public float Speed;
|
||||||
|
|
@ -419,6 +536,35 @@ public struct MovementStruct
|
||||||
public bool ModifyInterpretedState;
|
public bool ModifyInterpretedState;
|
||||||
/// <summary>Whether to modify the raw state.</summary>
|
/// <summary>Whether to modify the raw state.</summary>
|
||||||
public bool ModifyRawState;
|
public bool ModifyRawState;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-V1 — retail <c>object_id</c>. Types 6 (MoveToObject), 8
|
||||||
|
/// (TurnToObject) only.
|
||||||
|
/// </summary>
|
||||||
|
public uint ObjectId;
|
||||||
|
/// <summary>
|
||||||
|
/// R4-V1 — retail <c>top_level_id</c>. Types 6 (MoveToObject), 8
|
||||||
|
/// (TurnToObject) only.
|
||||||
|
/// </summary>
|
||||||
|
public uint TopLevelId;
|
||||||
|
/// <summary>
|
||||||
|
/// R4-V1 — retail <c>pos</c> (world position + cell). Type 7
|
||||||
|
/// (MoveToPosition) only.
|
||||||
|
/// </summary>
|
||||||
|
public Position Pos;
|
||||||
|
/// <summary>
|
||||||
|
/// R4-V1 — retail <c>radius</c>. Type 6 (MoveToObject) only.
|
||||||
|
/// </summary>
|
||||||
|
public float Radius;
|
||||||
|
/// <summary>
|
||||||
|
/// R4-V1 — retail <c>height</c>. Type 6 (MoveToObject) only.
|
||||||
|
/// </summary>
|
||||||
|
public float Height;
|
||||||
|
/// <summary>
|
||||||
|
/// R4-V1 — retail <c>params</c> (a pointer in retail; a reference
|
||||||
|
/// here). Types 1-4 and 6-9.
|
||||||
|
/// </summary>
|
||||||
|
public Motion.MovementParameters? Params;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Optional WeenieObject interface ──────────────────────────────────────────
|
// ── Optional WeenieObject interface ──────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) < F_EPSILON) d = 0;
|
||||||
|
/// if (d < -F_EPSILON) d += 360;
|
||||||
|
/// if (F_EPSILON < d && 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) > 180) greater = (b > a); // wrapped case: compare flipped
|
||||||
|
/// else greater = (a > 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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-< 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,15 @@ public sealed class WeenieErrorCodeTableTests
|
||||||
public void NoPhysicsObject_Is0x08()
|
public void NoPhysicsObject_Is0x08()
|
||||||
=> Assert.Equal(0x08u, (uint)WeenieError.NoPhysicsObject);
|
=> 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]
|
[Fact]
|
||||||
public void NotGrounded_Is0x24()
|
public void NotGrounded_Is0x24()
|
||||||
=> Assert.Equal(0x24u, (uint)WeenieError.NotGrounded);
|
=> Assert.Equal(0x24u, (uint)WeenieError.NotGrounded);
|
||||||
|
|
@ -43,6 +52,38 @@ public sealed class WeenieErrorCodeTableTests
|
||||||
public void ChatEmoteOutsideNonCombat_Is0x42()
|
public void ChatEmoteOutsideNonCombat_Is0x42()
|
||||||
=> Assert.Equal(0x42u, (uint)WeenieError.ChatEmoteOutsideNonCombat);
|
=> 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]
|
[Fact]
|
||||||
public void ActionDepthExceeded_Is0x45()
|
public void ActionDepthExceeded_Is0x45()
|
||||||
=> Assert.Equal(0x45u, (uint)WeenieError.ActionDepthExceeded);
|
=> Assert.Equal(0x45u, (uint)WeenieError.ActionDepthExceeded);
|
||||||
|
|
@ -59,6 +100,15 @@ public sealed class WeenieErrorCodeTableTests
|
||||||
public void CantJumpLoadedDown_Is0x49()
|
public void CantJumpLoadedDown_Is0x49()
|
||||||
=> Assert.Equal(0x49u, (uint)WeenieError.CantJumpLoadedDown);
|
=> 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>
|
/// <summary>
|
||||||
/// Every code in the A10 table in one pass — guards against a
|
/// Every code in the A10 table in one pass — guards against a
|
||||||
/// future partial edit desyncing an individual test above from the
|
/// future partial edit desyncing an individual test above from the
|
||||||
|
|
@ -67,7 +117,11 @@ public sealed class WeenieErrorCodeTableTests
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(WeenieError.None, 0x00u)]
|
[InlineData(WeenieError.None, 0x00u)]
|
||||||
[InlineData(WeenieError.NoPhysicsObject, 0x08u)]
|
[InlineData(WeenieError.NoPhysicsObject, 0x08u)]
|
||||||
|
[InlineData(WeenieError.NoMotionInterpreter, 0x0Bu)]
|
||||||
[InlineData(WeenieError.NotGrounded, 0x24u)]
|
[InlineData(WeenieError.NotGrounded, 0x24u)]
|
||||||
|
[InlineData(WeenieError.ActionCancelled, 0x36u)]
|
||||||
|
[InlineData(WeenieError.ObjectGone, 0x37u)]
|
||||||
|
[InlineData(WeenieError.NoObject, 0x38u)]
|
||||||
[InlineData(WeenieError.CrouchInCombatStance, 0x3fu)]
|
[InlineData(WeenieError.CrouchInCombatStance, 0x3fu)]
|
||||||
[InlineData(WeenieError.SitInCombatStance, 0x40u)]
|
[InlineData(WeenieError.SitInCombatStance, 0x40u)]
|
||||||
[InlineData(WeenieError.SleepInCombatStance, 0x41u)]
|
[InlineData(WeenieError.SleepInCombatStance, 0x41u)]
|
||||||
|
|
@ -76,6 +130,7 @@ public sealed class WeenieErrorCodeTableTests
|
||||||
[InlineData(WeenieError.GeneralMovementFailure, 0x47u)]
|
[InlineData(WeenieError.GeneralMovementFailure, 0x47u)]
|
||||||
[InlineData(WeenieError.YouCantJumpFromThisPosition, 0x48u)]
|
[InlineData(WeenieError.YouCantJumpFromThisPosition, 0x48u)]
|
||||||
[InlineData(WeenieError.CantJumpLoadedDown, 0x49u)]
|
[InlineData(WeenieError.CantJumpLoadedDown, 0x49u)]
|
||||||
|
[InlineData(WeenieError.YouChargedTooFar, 0x3Du)]
|
||||||
public void A10Table_EveryCode_MatchesRetailNumericValue(WeenieError code, uint expected)
|
public void A10Table_EveryCode_MatchesRetailNumericValue(WeenieError code, uint expected)
|
||||||
=> Assert.Equal(expected, (uint)code);
|
=> Assert.Equal(expected, (uint)code);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue