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

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

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

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

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

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

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

View file

@ -1,3 +1,5 @@
using AcDream.Core.Physics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
@ -184,4 +186,287 @@ public sealed class MovementParameters
/// <summary>Retail default 0.</summary>
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 &amp;&amp; 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 &gt; DistanceToObject</c> → WalkForward,
/// !movingAway; else idle (cmd 0).</description></item>
/// <item><description><c>MoveAway</c> only: pure away —
/// <c>dist &lt; 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 &gt;
/// 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 &gt; DistanceToObject</c> → WalkForward,
/// towards (not moving away).</description></item>
/// <item><description><c>dist - MinDistance &lt; 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-&gt;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;
}
}