feat(R3-W1): retail state completion — action FIFOs, MovementParameters, MotionNode, WeenieError renumber (closes J2, J16-codes)

InterpretedMotionState + the unified RawMotionState (LegacyRawMotionState
folded away) gain retail's action FIFO + ApplyMotion/RemoveMotion, ported
verbatim from the raw named decomp (0x0051e790/0x0051eb60 region, pc
293252-293703) including two genuine retail quirks pinned by tests and
independently re-verified against the raw text before commit:
- RawMotionState::ApplyMotion's RunForward (0x44000007) dead branch — a
  cycle-class apply of literal RunForward writes NOTHING (retail encodes
  run as WalkForward + HoldKey_Run on the raw state; same family as the
  D6 apply_run_to_command early-return).
- InterpretedMotionState::RemoveMotion's exact-match-only TurnRight/
  SideStepRight handling (left variants fall through to the style/
  forward branches).

MovementParameters verbatim (A4 pin): 18 named flags per the absolute
mask table, ctor = 0x1EE0F expansion + distance_to_object 0.6 /
fail_distance FLT_MAX / speed 1 / walk_run_threshhold 15 / hold_key
Invalid — with the two ACE-divergence traps ported RETAIL-side
(can_charge FALSE where ACE defaults true; threshold 15.0 not 1.0).
MotionNode {ContextId, Motion, JumpErrorCode} (acclient.h:53293) — the
pending_motions node W2 consumes.

WeenieError renumbered to retail's numeric semantics (A10 table):
NotGrounded=0x24, GeneralMovementFailure 0x24→0x47, new 0x3f/0x40/0x41
combat-stance rejects + 0x42 chat-emote + 0x45 action-depth; the
airborne jump/action returns corrected 0x48→0x24 per the A10 sweep
(incl. jump_is_allowed's null-physics-obj 0x24-not-8 divergence from
ACE). Codes are local-only — wire untouched.

Outbound packer proof: RawMotionStatePacker unmodified; all 6
golden-byte RawMotionStatePackTests pass unmodified. TS-24 register row
updated (capability landed; outbound wiring still open).

Implemented by a dedicated agent against the W0-pinned spec; quirks,
scope, and suite independently verified. Full suite green: 3,531
(374+425+713+2015+4 pre-existing skips).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 22:12:33 +02:00
parent 220927d350
commit 8664959152
11 changed files with 1483 additions and 117 deletions

View file

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics;
@ -107,81 +109,119 @@ public enum MovementType
}
/// <summary>
/// WeenieError codes returned by CMotionInterp methods.
/// Values are the hex constants used directly in the decompiled C code.
/// WeenieError-shaped codes returned by CMotionInterp methods. Values are
/// the hex constants used directly in the decompiled C code.
///
/// <para>
/// <b>R3-W1 renumber (closes J16-codes/A10).</b> Prior to this slice, the
/// names were shuffled relative to retail's actual numeric semantics
/// (<c>GeneralMovementFailure</c> was assigned 0x24 and used at the
/// airborne-jump gate, but retail's 0x24 means "not grounded / no contact"
/// and retail's 0x47 is the general-movement-failure code; 0x48 meant
/// "cannot jump while in the air" here, but retail's 0x48 means "jump
/// blocked by the CURRENT MOTION OR POSITION" — a blocklist check
/// (<c>motion_allows_jump</c>), not an airborne check). Renumbered per the
/// definitive, exhaustively-swept table in
/// <c>docs/research/2026-07-02-r3-motioninterp/W0-pins.md</c> §A10 (19
/// return sites + 1 store site over raw 304908-306277 + 300150-300540).
/// These codes are LOCAL-ONLY — never serialized to the wire — so the
/// renumber is safe (verified: no consumer outside
/// <c>MotionInterpreter.cs</c> pattern-matches on the numeric value).
/// </para>
/// </summary>
public enum WeenieError : uint
{
/// <summary>0x00 — success.</summary>
None = 0x00,
/// <summary>0x08 — PhysicsObj is null.</summary>
/// <summary>
/// 0x08 — no <c>physics_obj</c>. Sites (A10): StopCompletely @305214;
/// DoInterpretedMotion @305579; StopInterpretedMotion @305639;
/// StopMotion @305680; jump @305798; DoMotion @306165.
/// </summary>
NoPhysicsObject = 0x08,
/// <summary>0x24 — general movement failure.</summary>
GeneralMovementFailure = 0x24,
/// <summary>0x47 — cannot jump from this position (motion state blocks it).</summary>
YouCantJumpFromThisPosition = 0x47,
/// <summary>0x48 — cannot jump while in the air.</summary>
YouCantJumpWhileInTheAir = 0x48,
/// <summary>0x49 — loaded down / weenie blocked the jump.</summary>
/// <summary>
/// 0x24 — not grounded / no contact. Sites (A10):
/// <c>jump_is_allowed</c> @305570 (gravity-active creature without
/// Contact+OnWalkable; also the <c>physics_obj == null</c> case, which
/// falls out to this same code per the A10 note — NOT 8);
/// <c>DoInterpretedMotion</c> @305622-305623 (action-class motion
/// blocked by <c>contact_allows_move</c>).
/// </summary>
NotGrounded = 0x24,
/// <summary>
/// 0x3f — Crouch (0x41000012) rejected while in combat stance. Site:
/// DoMotion @306196.
/// </summary>
CrouchInCombatStance = 0x3f,
/// <summary>
/// 0x40 — Sitting (0x41000013) rejected while in combat stance. Site:
/// DoMotion @306199.
/// </summary>
SitInCombatStance = 0x40,
/// <summary>
/// 0x41 — Sleeping (0x41000014) rejected while in combat stance. Site:
/// DoMotion @306202.
/// </summary>
SleepInCombatStance = 0x41,
/// <summary>
/// 0x42 — <c>motion &amp; 0x2000000</c> (the chat-emote bit) rejected
/// outside NonCombat (0x8000003d). Site: DoMotion @306205.
/// </summary>
ChatEmoteOutsideNonCombat = 0x42,
/// <summary>
/// 0x45 — action-queue depth cap: an action-class motion (bit
/// 0x10000000) with <c>GetNumActions() &gt;= 6</c> pending. Site:
/// DoMotion @306209.
/// </summary>
ActionDepthExceeded = 0x45,
/// <summary>
/// 0x47 — general movement failure. Sites (A10):
/// <c>jump_is_allowed</c> @305525 (<c>IsFullyConstrained</c>);
/// @305549+305556 (<c>JumpStaminaCost</c> refusal);
/// <c>CMotionInterp::PerformMovement</c> @306227 (dispatch type-1 &gt; 4);
/// <c>MovementManager::PerformMovement</c> @300201 (dispatch type-1 &gt; 8).
/// </summary>
GeneralMovementFailure = 0x47,
/// <summary>
/// 0x48 — jump BLOCKED by the current motion or position (A1: the
/// <c>motion_allows_jump</c> literal-range blocklist — NOT an airborne
/// check; airborne is 0x24). Sites (A10):
/// <c>motion_allows_jump</c> @304930; <c>jump_charge_is_allowed</c>
/// @304948 (Fallen or Crouch..Sleeping); <c>charge_jump</c> @305459
/// (same predicate); STORED (not returned) as the queue node's
/// <c>jump_error_code</c> in <c>DoInterpretedMotion</c> @305605 when
/// <c>disable_jump_during_link</c> is set.
/// </summary>
YouCantJumpFromThisPosition = 0x48,
/// <summary>
/// 0x49 — the weenie's <c>CanJump(jump_extent)</c> virtual refused
/// (e.g. stamina/burden gate). Sites: <c>jump_charge_is_allowed</c>
/// @304941; <c>charge_jump</c> @305454.
/// </summary>
CantJumpLoadedDown = 0x49,
}
// ── Motion state structs ───────────────────────────────────────────────────────
/// <summary>
/// Raw (network-derived) motion state for the local player, as consumed by
/// the (still-approximate) <see cref="MotionInterpreter"/> input-state path.
/// Struct layout in chunk_00520000 starts at offset +0x14 (struct field +0x20 =
/// ForwardCommand, +0x28 = ForwardSpeed, etc.).
/// Interpreted motion state, derived from the raw state (retail
/// <c>InterpretedMotionState</c>, ctor 0x0051e8d0, decomp 293418-293431).
/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50).
///
/// <para>
/// Renamed from <c>RawMotionState</c> during the L.2b wire-parity slice
/// (2026-06-30) to free that name for the retail-faithful, full-field
/// <c>AcDream.Core.Physics.RawMotionState</c> (mirrors
/// <c>acclient.h RawMotionState::PackBitfield</c>) used by the outbound
/// wire packer. This partial type is D6/Phase-2 territory (the
/// <c>adjust_motion</c>/<c>apply_raw_movement</c> input-state port) —
/// out of scope for this slice. It is expected to be folded into or
/// replaced by the retail-faithful type when D6 lands.
/// <b>R3-W1 (closes J2):</b> gains retail's action FIFO
/// (<see cref="AddAction"/>/<see cref="RemoveAction"/>/<see cref="GetNumActions"/>/
/// <see cref="ApplyMotion"/>/<see cref="RemoveMotion"/>) — previously this was
/// a flat 6-field struct with no action tracking, so
/// <c>DoMotion</c>'s <c>GetNumActions() &gt;= 6</c> depth cap and
/// <c>MotionDone</c>'s action-class <c>RemoveAction</c> pop had nothing to
/// operate on. The action list is backed by a private
/// <see cref="List{T}"/> lazily created on first mutation (defensive
/// against a bare <c>default(InterpretedMotionState)</c>, which C# structs
/// permit even though every constructor in this file routes through
/// <see cref="Default"/>).
/// </para>
/// </summary>
public struct LegacyRawMotionState
{
/// <summary>Current style / stance (retail raw_state.current_style,
/// offset +0x18). Adopted from the inbound InterpretedMotionState by
/// move_to_interpreted_state (0x005289c0 line 305944). Default
/// NonCombat 0x8000003D.</summary>
public uint CurrentStyle;
/// <summary>Forward/backward motion command (offset +0x20).</summary>
public uint ForwardCommand;
/// <summary>Speed scalar for forward motion (offset +0x28).</summary>
public float ForwardSpeed;
/// <summary>Sidestep command (offset +0x2C).</summary>
public uint SideStepCommand;
/// <summary>Speed scalar for sidestep (offset +0x34, inferred from ACE).</summary>
public float SideStepSpeed;
/// <summary>Turn command (offset +0x38).</summary>
public uint TurnCommand;
/// <summary>Speed scalar for turn (offset +0x40, inferred).</summary>
public float TurnSpeed;
/// <summary>Initialize to the idle/ready state (1.0 speed, Ready command).</summary>
public static LegacyRawMotionState Default() => new()
{
CurrentStyle = 0x8000003Du,
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1.0f,
SideStepCommand = 0,
SideStepSpeed = 1.0f,
TurnCommand = 0,
TurnSpeed = 1.0f,
};
}
/// <summary>
/// Interpreted motion state, derived from the raw state.
/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50).
/// </summary>
public struct InterpretedMotionState
{
/// <summary>Forward/backward interpreted command (offset +0x4C).</summary>
@ -196,6 +236,20 @@ public struct InterpretedMotionState
public uint TurnCommand;
/// <summary>Speed scalar for turn (offset +0x60).</summary>
public float TurnSpeed;
/// <summary>Current style / stance (retail current_style). Adopted from
/// the raw state's style channel; NOT part of retail's
/// <c>InterpretedMotionState::ApplyMotion</c> dispatch itself (that
/// writes <c>this-&gt;current_style</c> only via the negative-motion
/// branch — see <see cref="ApplyMotion"/>). Default NonCombat
/// 0x8000003D.</summary>
public uint CurrentStyle;
private List<RawMotionAction>? _actions;
/// <summary>Action FIFO in retail order (oldest first). Empty (never
/// null) when read — the private field is lazily created.</summary>
public readonly IReadOnlyList<RawMotionAction> Actions
=> (IReadOnlyList<RawMotionAction>?)_actions ?? Array.Empty<RawMotionAction>();
/// <summary>Initialize to the idle/ready state.</summary>
public static InterpretedMotionState Default() => new()
@ -206,7 +260,141 @@ public struct InterpretedMotionState
SideStepSpeed = 1.0f,
TurnCommand = 0,
TurnSpeed = 1.0f,
CurrentStyle = 0x8000003Du,
};
/// <summary>
/// <c>InterpretedMotionState::AddAction</c> (0x0051e9e0, decomp
/// 293500-293527): unconditional tail-append of
/// <c>{motion, speed, action_stamp, autonomous}</c>. Identical shape to
/// <see cref="RawMotionState.AddAction"/> — retail duplicates the
/// LListData append logic per state type rather than sharing it.
/// </summary>
public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous)
{
_actions ??= new List<RawMotionAction>();
_actions.Add(new RawMotionAction(
Command: (ushort)motion,
Stamp: (ushort)actionStamp,
Autonomous: autonomous,
Speed: speed));
}
/// <summary>
/// <c>InterpretedMotionState::RemoveAction</c> (0x0051ead0, decomp
/// 293568-293586): pop the FIFO head unconditionally, returning its
/// <c>motion</c> field (0 when empty).
/// </summary>
public uint RemoveAction()
{
if (_actions is null || _actions.Count == 0)
return 0;
var head = _actions[0];
_actions.RemoveAt(0);
return head.Command;
}
/// <summary>
/// <c>InterpretedMotionState::GetNumActions</c> (0x0051eb00, decomp
/// 293590-293603): count the FIFO by walking it (retail has no O(1)
/// count field on the LList).
/// </summary>
public readonly uint GetNumActions() => (uint)(_actions?.Count ?? 0);
/// <summary>
/// <c>InterpretedMotionState::ApplyMotion</c> (0x0051ea40, decomp
/// 293531-293564). Verbatim dispatch, quoted from the raw named decomp:
/// <code>
/// if (arg2 == 0x6500000d) { turn_command = arg2; turn_speed = params.speed; return; }
/// if (arg2 == 0x6500000f) { sidestep_command = arg2; sidestep_speed = params.speed; return; }
/// if ((arg2 &amp; 0x40000000) != 0) { forward_command = arg2; forward_speed = params.speed; return; }
/// if (arg2 &lt; 0) { forward_command = 0x41000003; current_style = arg2; return; }
/// if ((arg2 &amp; 0x10000000) != 0)
/// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield&gt;&gt;0xc)&amp;1);
/// </code>
/// Note only TurnRight (0x6500000d) and SideStepRight (0x6500000f) are
/// tested here — unlike <see cref="RawMotionState.ApplyMotion"/>, the
/// LEFT variants (TurnLeft/SideStepLeft) are NOT separately cased;
/// retail's <c>adjust_motion</c> normalizes Left→Right upstream before
/// this ever runs, so this asymmetry is verbatim, not a gap.
/// </summary>
/// <param name="motion">Retail <c>arg2</c> — the motion id AFTER
/// <c>adjust_motion</c> normalization (the ADJUSTED id, unlike
/// <see cref="RawMotionState.ApplyMotion"/> which takes the ORIGINAL).</param>
/// <param name="p">Retail <c>arg3</c> (<c>MovementParameters const*</c>).</param>
public void ApplyMotion(uint motion, MovementParameters p)
{
if (motion == 0x6500000du) // TurnRight
{
TurnCommand = motion;
TurnSpeed = p.Speed;
return;
}
if (motion == 0x6500000fu) // SideStepRight
{
SideStepCommand = motion;
SideStepSpeed = p.Speed;
return;
}
if ((motion & 0x40000000u) != 0)
{
ForwardCommand = motion;
ForwardSpeed = p.Speed;
return;
}
if (motion >= 0x80000000u) // arg2 < 0 as signed int32
{
ForwardCommand = 0x41000003u;
CurrentStyle = motion;
return;
}
if ((motion & 0x10000000u) != 0)
AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous);
}
/// <summary>
/// <c>InterpretedMotionState::RemoveMotion</c> (0x0051e790, decomp
/// 293315-293340):
/// <code>
/// if (arg2 == 0x6500000d) { turn_command = 0; return; }
/// if (arg2 == 0x6500000f) { sidestep_command = 0; return; }
/// if ((arg2 &amp; 0x40000000) == 0) {
/// if (arg2 &lt; 0 &amp;&amp; arg2 == current_style) current_style = 0x8000003d;
/// } else if (arg2 == forward_command) {
/// forward_command = 0x41000003; forward_speed = 1f;
/// }
/// </code>
/// Note the asymmetric range test vs <see cref="RawMotionState.RemoveMotion"/>
/// (which uses <c>(arg2 - 0x6500000d) &gt; 3</c> covering all four
/// turn/sidestep ids) — this one only special-cases the RIGHT variants
/// by exact equality; TurnLeft/SideStepLeft fall through to the
/// style/forward-command branch below. Verbatim, not a gap (mirrors
/// retail's own asymmetry between the two RemoveMotion bodies).
/// </summary>
/// <param name="motion">Retail <c>arg2</c>.</param>
public void RemoveMotion(uint motion)
{
if (motion == 0x6500000du)
{
TurnCommand = 0;
return;
}
if (motion == 0x6500000fu)
{
SideStepCommand = 0;
return;
}
if ((motion & 0x40000000u) == 0)
{
if (motion >= 0x80000000u && motion == CurrentStyle)
CurrentStyle = 0x8000003du;
}
else if (motion == ForwardCommand)
{
ForwardCommand = 0x41000003u;
ForwardSpeed = 1f;
}
}
}
/// <summary>
@ -317,8 +505,18 @@ public sealed class MotionInterpreter
/// <summary>Optional WeenieObject for stamina / run-rate queries (struct offset +0x04).</summary>
public IWeenieObject? WeenieObj { get; set; }
/// <summary>Raw (network-derived) motion state (struct offsets +0x14..+0x44).</summary>
public LegacyRawMotionState RawState;
/// <summary>
/// Raw (network-derived) motion state (struct offsets +0x14..+0x44).
///
/// <para>
/// R3-W1 fold (closes J2): this now holds the SAME retail-faithful,
/// full-field <see cref="Physics.RawMotionState"/> type the outbound
/// wire packer reads from — the former flat <c>LegacyRawMotionState</c>
/// struct (no action FIFO, no <c>ApplyMotion</c>/<c>RemoveMotion</c>)
/// is deleted. One raw-state type end to end.
/// </para>
/// </summary>
public RawMotionState RawState;
/// <summary>Interpreted motion state derived from raw (struct offsets +0x44..+0x7C).</summary>
public InterpretedMotionState InterpretedState;
@ -385,7 +583,7 @@ public sealed class MotionInterpreter
public MotionInterpreter()
{
RawState = LegacyRawMotionState.Default();
RawState = new RawMotionState();
InterpretedState = InterpretedMotionState.Default();
}
@ -393,7 +591,7 @@ public sealed class MotionInterpreter
{
PhysicsObj = physicsObj;
WeenieObj = weenieObj;
RawState = LegacyRawMotionState.Default();
RawState = new RawMotionState();
InterpretedState = InterpretedMotionState.Default();
}
@ -477,9 +675,12 @@ public sealed class MotionInterpreter
if (!contact_allows_move(motion))
{
// Action commands (bit 0x10000000) are blocked mid-air.
// Action commands (bit 0x10000000) are blocked mid-air. A10:
// DoInterpretedMotion @305622-305623 returns 0x24 (NotGrounded)
// here, not 0x48 (that code is reserved for the
// motion_allows_jump literal-range blocklist).
if ((motion & 0x10000000u) != 0)
return WeenieError.YouCantJumpWhileInTheAir;
return WeenieError.NotGrounded;
// Non-action motions are queued silently; state still updates.
}
@ -505,10 +706,10 @@ public sealed class MotionInterpreter
RawState.ForwardCommand = MotionCommand.Ready;
RawState.ForwardSpeed = 1.0f;
}
if (RawState.SideStepCommand == motion)
if (RawState.SidestepCommand == motion)
{
RawState.SideStepCommand = 0;
RawState.SideStepSpeed = 1.0f;
RawState.SidestepCommand = 0;
RawState.SidestepSpeed = 1.0f;
}
if (RawState.TurnCommand == motion)
{
@ -563,7 +764,7 @@ public sealed class MotionInterpreter
/// uVar1 = FUN_005285e0(InterpretedState.ForwardCommand) — motion_allows_jump
/// *(+0x20) = 0x41000003 (RawState.ForwardCommand = Ready)
/// *(+0x28) = 0x3f800000 (RawState.ForwardSpeed = 1.0f)
/// *(+0x2c) = 0 (RawState.SideStepCommand = 0)
/// *(+0x2c) = 0 (RawState.SidestepCommand = 0)
/// *(+0x38) = 0 (RawState.TurnCommand = 0)
/// *(+0x4c) = 0x41000003 (InterpretedState.ForwardCommand = Ready)
/// *(+0x50) = 0x3f800000 (InterpretedState.ForwardSpeed = 1.0f)
@ -582,8 +783,8 @@ public sealed class MotionInterpreter
// Reset raw state
RawState.ForwardCommand = MotionCommand.Ready;
RawState.ForwardSpeed = 1.0f;
RawState.SideStepCommand = 0;
RawState.SideStepSpeed = 1.0f;
RawState.SidestepCommand = 0;
RawState.SidestepSpeed = 1.0f;
RawState.TurnCommand = 0;
RawState.TurnSpeed = 1.0f;
@ -1039,7 +1240,7 @@ public sealed class MotionInterpreter
/// elif WeenieObj.IsCreature() returns false: proceed
/// iVar2 = PhysicsObj
/// if Gravity flag NOT set OR (Contact AND OnWalkable): ← grounded or no gravity
/// return 0x24 (GeneralMovementFailure)
/// return 0x24 (NotGrounded)
/// if FUN_0050f730() (IsFullyConstrained) != 0: return 0x47
/// if pending queue action has non-zero jump error: return that error
/// iVar2 = FUN_00528660() (jump_charge_is_allowed)
@ -1052,8 +1253,13 @@ public sealed class MotionInterpreter
/// </summary>
public WeenieError jump_is_allowed(float extent, int staminaCost)
{
// A10: jump_is_allowed returns 0x24 (NotGrounded), NOT 8, when
// physics_obj == null — the "8 = no physics obj" convention holds
// everywhere else in CMotionInterp but not here (raw structure
// 305512 + 305570: the `if (physics_obj != 0) {...}` wrapper falls
// out to `return 0x24;`).
if (PhysicsObj is null)
return WeenieError.GeneralMovementFailure;
return WeenieError.NotGrounded;
// Must have gravity and be grounded (Contact + OnWalkable) to start a jump.
bool hasGravity = PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity);
@ -1061,7 +1267,7 @@ public sealed class MotionInterpreter
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (!hasGravity || !isGrounded)
return WeenieError.YouCantJumpWhileInTheAir;
return WeenieError.NotGrounded;
// Delegate jump eligibility to WeenieObj if present.
if (WeenieObj is not null && !WeenieObj.CanJump(extent))
@ -1421,6 +1627,6 @@ public sealed class MotionInterpreter
if ((motion & 0x10000000u) == 0)
return WeenieError.None; // apply-only: state kept, no cycle change
return WeenieError.GeneralMovementFailure; // retail 0x24
return WeenieError.NotGrounded; // retail 0x24 (A10)
}
}