feat(R3-W5): DoMotion family verbatim + the ONE DoInterpretedMotion + per-op zero-tick flush (closes J3, J4, J9, J14)
The two parallel DoInterpretedMotion implementations MERGE into one verbatim pair (0x00528360/@305639): contact gate, StandingLongJump state-only branch, Dead → RemoveLinkAnimations, the jump_error_code double-check WITH the DisableJumpDuringLink 0x20000 leg (W2's TODO(W5) resolved — MovementParameters now flows), ModifyInterpretedState gating, CurCell-null tail, AddToQueue with the real ContextId; StopInterpretedMotion's post-stop Ready node + raw-mirror RemoveMotion. Legacy overloads + ApplyMotionToInterpretedState DELETED. The funnel's public surface unchanged (183-case suite compiles + passes as-is). DoMotion 0x00528d20 verbatim: cancel_moveto interrupt; SetHoldKey(key, cancelMoveToBit) BEFORE adjust_motion; params re-default for the interpreted call; combat-stance gates on the ORIGINAL id (Crouch/Sit/ Sleep → 0x3f/0x40/0x41; & 0x2000000 → 0x42 outside NonCombat); action depth cap ≥6 → 0x45; raw mirror ORIGINAL id. StopMotion 0x00528530 mirror shape. StopCompletely 0x00527e40 with the A9 snapshot quirk (motion_allows_jump on the OLD forward command, stashed in the queued node) + the J9 fix (sidestep/turn SPEEDS untouched — the 1.0 resets were a divergence). PerformMovement flushes zero-tick completions after every dispatched op via the new CheckForCompletedMotions seam (0x0050fe30) — bound App-side per entity (remotes via EnsureRemoteMotionBindings, player at the sequencer bind site). PORT DISCOVERY (not in the W0 pins): retail's DoInterpretedMotion RESULT gates both add_to_queue AND the state write — a void sink let the style dispatch's apply-only failure clobber ForwardCommand before the forward axis read it, regressing 74/183 live-trace cases. IInterpretedMotionSink.ApplyMotion/StopMotion now return bool (documented on the interface with the raw anchors); one funnel assertion corrected with raw-line citations (airborne ForwardCommand ends at Falling under the fully-wired verbatim algorithm). 62 new gate-table tests. Full suite: 3,729 passed. Implemented by a dedicated agent against the W0-pinned spec; seam bound + suite independently re-verified by the orchestrator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e214acdf23
commit
df7b096d6f
7 changed files with 1522 additions and 241 deletions
|
|
@ -0,0 +1,878 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MotionInterpreterDoMotionFamilyTests — R3-W5 (closes J3, J4, J9, J14).
|
||||
//
|
||||
// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md
|
||||
// §2 DoMotion 0x00528d20 @306159
|
||||
// §5a StopCompletely 0x00527e40 @305208
|
||||
// §5b StopMotion 0x00528530 @305674
|
||||
// §1a add_to_queue 0x00527b80 @305032
|
||||
// docs/research/2026-07-02-r3-motioninterp/W0-pins.md A4 (MovementParameters
|
||||
// bit table), A9 (StopCompletely jump-snapshot quirk), A10 (combat-stance /
|
||||
// depth-cap error codes).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Fake WeenieObject — IsThePlayer/IsCreature/CanJump independently
|
||||
/// configurable, matching the ground-lifecycle test file's convention.</summary>
|
||||
file sealed class FakeWeenie : IWeenieObject
|
||||
{
|
||||
public bool IsThePlayerResult;
|
||||
public bool IsCreatureResult = true;
|
||||
public bool CanJumpResult = true;
|
||||
|
||||
public bool InqJumpVelocity(float extent, out float vz) { vz = 10f; return true; }
|
||||
public bool InqRunRate(out float rate) { rate = 1f; return true; }
|
||||
public bool CanJump(float extent) => CanJumpResult;
|
||||
public bool IsThePlayer() => IsThePlayerResult;
|
||||
public bool IsCreature() => IsCreatureResult;
|
||||
}
|
||||
|
||||
public sealed class MotionInterpreterDoMotionFamilyTests
|
||||
{
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static PhysicsBody MakeGrounded()
|
||||
{
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||
};
|
||||
body.TransientState = TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable
|
||||
| TransientStateFlags.Active;
|
||||
return body;
|
||||
}
|
||||
|
||||
private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null)
|
||||
{
|
||||
body ??= MakeGrounded();
|
||||
return new MotionInterpreter(body, weenie);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DoMotion — 0x00528d20 @306159
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_NullPhysicsObj_Returns8()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
|
||||
var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_CancelMoveToBit_FiresInterruptSeam()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
bool fired = false;
|
||||
interp.InterruptCurrentMovement = () => fired = true;
|
||||
var p = new MovementParameters { CancelMoveTo = true };
|
||||
|
||||
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
Assert.True(fired, "the sign-bit (CancelMoveTo) must fire InterruptCurrentMovement before adjust_motion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_CancelMoveToClear_DoesNotFireInterruptSeam()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
bool fired = false;
|
||||
interp.InterruptCurrentMovement = () => fired = true;
|
||||
var p = new MovementParameters { CancelMoveTo = false };
|
||||
|
||||
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
Assert.False(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_SetHoldKeyBit_FiresSetHoldKey_WithCancelMoveToBitAsSecondArg()
|
||||
{
|
||||
// A4: @306188 SetHoldKey(hold_key_to_apply, ((__inner0_1 >> 0xf) & 1)) —
|
||||
// the SECOND arg is the cancel_moveto (sign) bit, not a constant.
|
||||
var interp = MakeInterp();
|
||||
var p = new MovementParameters
|
||||
{
|
||||
SetHoldKey = true,
|
||||
CancelMoveTo = true,
|
||||
HoldKeyToApply = HoldKey.Run,
|
||||
};
|
||||
|
||||
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_SetHoldKeyBit_FiresBeforeAdjustMotion()
|
||||
{
|
||||
// SetHoldKey must land BEFORE adjust_motion runs so the new hold-key
|
||||
// affects the walk/run reinterpretation in THIS same call (Run
|
||||
// promotion of WalkForward).
|
||||
var interp = MakeInterp();
|
||||
var p = new MovementParameters
|
||||
{
|
||||
SetHoldKey = true,
|
||||
HoldKeyToApply = HoldKey.Run,
|
||||
};
|
||||
|
||||
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
// adjust_motion promotes WalkForward -> RunForward only when the
|
||||
// hold key is (already, as of THIS call) Run.
|
||||
Assert.Equal(MotionCommand.RunForward, interp.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_SetHoldKeyBitClear_DoesNotChangeHoldKey()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.RawState.CurrentHoldKey = HoldKey.None;
|
||||
var p = new MovementParameters
|
||||
{
|
||||
SetHoldKey = false,
|
||||
HoldKeyToApply = HoldKey.Run,
|
||||
};
|
||||
|
||||
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_ParamsReDefaulted_CallerDistanceHeadingDiscarded()
|
||||
{
|
||||
// DoMotion re-derives a canonical local MovementParameters for the
|
||||
// DoInterpretedMotion call — caller-supplied distance/heading fields
|
||||
// must not leak into anything the interpreted call reads (the
|
||||
// params ARE discarded per the decomp; this test pins that a
|
||||
// caller passing bizarre distance/heading values doesn't change
|
||||
// dispatch behavior vs the defaults).
|
||||
var interp1 = MakeInterp();
|
||||
var interp2 = MakeInterp();
|
||||
var weird = new MovementParameters { DistanceToObject = 999f, DesiredHeading = 12345f, FailDistance = 1f };
|
||||
var plain = new MovementParameters();
|
||||
|
||||
var r1 = interp1.DoMotion(MotionCommand.WalkForward, weird);
|
||||
var r2 = interp2.DoMotion(MotionCommand.WalkForward, plain);
|
||||
|
||||
Assert.Equal(r2, r1);
|
||||
Assert.Equal(interp2.InterpretedState.ForwardCommand, interp1.InterpretedState.ForwardCommand);
|
||||
Assert.Equal(interp2.InterpretedState.ForwardSpeed, interp1.InterpretedState.ForwardSpeed);
|
||||
}
|
||||
|
||||
// ── combat-stance gate (A10: 0x3f/0x40/0x41/0x42) ──────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(MotionCommand.Crouch, WeenieError.CrouchInCombatStance)]
|
||||
[InlineData(MotionCommand.Sitting, WeenieError.SitInCombatStance)]
|
||||
[InlineData(MotionCommand.Sleeping, WeenieError.SleepInCombatStance)]
|
||||
public void DoMotion_JumpChargeMotion_RejectedInCombatStance(uint motion, WeenieError expected)
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.CurrentStyle = 0x80000001u; // any non-NonCombat stance
|
||||
|
||||
var result = interp.DoMotion(motion, new MovementParameters());
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MotionCommand.Crouch)]
|
||||
[InlineData(MotionCommand.Sitting)]
|
||||
[InlineData(MotionCommand.Sleeping)]
|
||||
public void DoMotion_JumpChargeMotion_AllowedInNonCombatStance(uint motion)
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.CurrentStyle = 0x8000003Du; // NonCombat
|
||||
|
||||
var result = interp.DoMotion(motion, new MovementParameters());
|
||||
|
||||
Assert.NotEqual(WeenieError.CrouchInCombatStance, result);
|
||||
Assert.NotEqual(WeenieError.SitInCombatStance, result);
|
||||
Assert.NotEqual(WeenieError.SleepInCombatStance, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_ChatEmoteBit_RejectedInCombatStance()
|
||||
{
|
||||
// motion & 0x2000000 outside NonCombat -> 0x42.
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.CurrentStyle = 0x80000001u;
|
||||
uint motion = 0x10000000u | 0x2000000u; // action-class + chat-emote bit
|
||||
|
||||
var result = interp.DoMotion(motion, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.ChatEmoteOutsideNonCombat, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_ChatEmoteBit_AllowedInNonCombatStance()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.CurrentStyle = 0x8000003Du; // NonCombat
|
||||
uint motion = 0x10000000u | 0x2000000u;
|
||||
|
||||
var result = interp.DoMotion(motion, new MovementParameters());
|
||||
|
||||
Assert.NotEqual(WeenieError.ChatEmoteOutsideNonCombat, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_NonJumpChargeMotion_NotGatedByCombatStance()
|
||||
{
|
||||
// WalkForward (no 0x2000000 bit, not a jump-charge id) must pass
|
||||
// through the combat-stance gate regardless of stance.
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.CurrentStyle = 0x80000001u; // combat stance
|
||||
|
||||
var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
// ── action-depth gate (A10: 0x45, GetNumActions >= 6) ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_ActionClassMotion_DepthBelowSix_Allowed()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
for (int i = 0; i < 5; i++)
|
||||
interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false);
|
||||
|
||||
uint actionMotion = 0x10000060u; // action-class bit set, not itself a jump-charge id
|
||||
var result = interp.DoMotion(actionMotion, new MovementParameters());
|
||||
|
||||
Assert.NotEqual(WeenieError.ActionDepthExceeded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_ActionClassMotion_DepthExactlySix_Rejected()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
for (int i = 0; i < 6; i++)
|
||||
interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false);
|
||||
|
||||
uint actionMotion = 0x10000060u;
|
||||
var result = interp.DoMotion(actionMotion, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.ActionDepthExceeded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_ActionClassMotion_DepthFive_NotRejected()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
for (int i = 0; i < 5; i++)
|
||||
interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false);
|
||||
|
||||
uint actionMotion = 0x10000060u;
|
||||
var result = interp.DoMotion(actionMotion, new MovementParameters());
|
||||
|
||||
Assert.NotEqual(WeenieError.ActionDepthExceeded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_NonActionClassMotion_IgnoresDepthCap()
|
||||
{
|
||||
// Bit 0x10000000 clear -> the depth cap never applies, even with 6+
|
||||
// queued actions.
|
||||
var interp = MakeInterp();
|
||||
for (int i = 0; i < 10; i++)
|
||||
interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false);
|
||||
|
||||
var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||
|
||||
Assert.NotEqual(WeenieError.ActionDepthExceeded, result);
|
||||
}
|
||||
|
||||
// ── raw mirror discipline: raw gets ORIGINAL id, interpreted gets ADJUSTED ──
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_ModifyRawStateBit_MirrorsOriginalId_NotAdjusted()
|
||||
{
|
||||
// WalkBackward (ORIGINAL) adjusts to WalkForward w/ negated speed
|
||||
// (interpreted) but RawState.ApplyMotion must be called with the
|
||||
// ORIGINAL WalkBackward id (ebp), not the adjusted WalkForward id.
|
||||
var interp = MakeInterp();
|
||||
var p = new MovementParameters { ModifyRawState = true, Speed = 1.0f };
|
||||
|
||||
interp.DoMotion(MotionCommand.WalkBackward, p);
|
||||
|
||||
Assert.Equal(MotionCommand.WalkBackward, interp.RawState.ForwardCommand);
|
||||
// Interpreted state, meanwhile, adopted the ADJUSTED (WalkForward) id.
|
||||
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_ModifyRawStateBitClear_DoesNotMirror()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
var p = new MovementParameters { ModifyRawState = false };
|
||||
|
||||
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
// RawState.ForwardCommand must remain at its ctor default (Ready) —
|
||||
// no mirror happened.
|
||||
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_FailedDispatch_DoesNotMirrorToRawState()
|
||||
{
|
||||
// result == 0 is required for the mirror per @306184 ("if (result ==
|
||||
// 0 && bit0x2000)"). A combat-stance rejection must not mirror.
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.CurrentStyle = 0x80000001u;
|
||||
var p = new MovementParameters { ModifyRawState = true };
|
||||
|
||||
var result = interp.DoMotion(MotionCommand.Crouch, p);
|
||||
|
||||
Assert.Equal(WeenieError.CrouchInCombatStance, result);
|
||||
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); // untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoMotion_2Arg_AppOverload_StillCompiles_AndDispatches()
|
||||
{
|
||||
// App-facing compat overload: DoMotion(uint, float speed = 1.0f).
|
||||
var interp = MakeInterp();
|
||||
|
||||
var result = interp.DoMotion(MotionCommand.WalkForward, 0.8f);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// StopMotion — 0x00528530 @305674 (mirror shape, no gates)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void StopMotion_NullPhysicsObj_Returns8()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
|
||||
var result = interp.StopMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopMotion_NoCombatStanceGate_CrouchStopsEvenInCombatStance()
|
||||
{
|
||||
// Unlike DoMotion, StopMotion has NO combat-stance/depth gating.
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.CurrentStyle = 0x80000001u;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Crouch;
|
||||
|
||||
var result = interp.StopMotion(MotionCommand.Crouch, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopMotion_ModifyRawStateBit_RemovesOriginalId_NotAdjusted()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.RawState.ForwardCommand = MotionCommand.WalkBackward;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
var p = new MovementParameters { ModifyRawState = true };
|
||||
|
||||
interp.StopMotion(MotionCommand.WalkBackward, p);
|
||||
|
||||
// RemoveMotion(ORIGINAL WalkBackward) hits RawState.ForwardCommand
|
||||
// via the forward-class branch (adjusted WalkForward would also
|
||||
// match here in this simple case, but the ORIGINAL id is what must
|
||||
// be passed — see the mismatch test below for a case that
|
||||
// distinguishes them).
|
||||
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopMotion_ModifyRawStateBitClear_DoesNotMirror()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
|
||||
var p = new MovementParameters { ModifyRawState = false };
|
||||
|
||||
interp.StopMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
Assert.Equal(MotionCommand.WalkForward, interp.RawState.ForwardCommand); // untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopMotion_2Arg_AppOverload_StillCompiles()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
|
||||
var result = interp.StopMotion(MotionCommand.WalkForward);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// StopCompletely — 0x00527e40 @305208 (A9 verbatim quirk)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_NullPhysicsObj_Returns8()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
|
||||
var result = interp.StopCompletely();
|
||||
|
||||
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_ZeroesForwardSidestepTurnCommands_OnBothStates()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.RawState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.RawState.SidestepCommand = MotionCommand.SideStepRight;
|
||||
interp.RawState.TurnCommand = MotionCommand.TurnRight;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight;
|
||||
interp.InterpretedState.TurnCommand = MotionCommand.TurnRight;
|
||||
|
||||
interp.StopCompletely();
|
||||
|
||||
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
|
||||
Assert.Equal(1.0f, interp.RawState.ForwardSpeed);
|
||||
Assert.Equal(0u, interp.RawState.SidestepCommand);
|
||||
Assert.Equal(0u, interp.RawState.TurnCommand);
|
||||
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
|
||||
Assert.Equal(1.0f, interp.InterpretedState.ForwardSpeed);
|
||||
Assert.Equal(0u, interp.InterpretedState.SideStepCommand);
|
||||
Assert.Equal(0u, interp.InterpretedState.TurnCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_DoesNotTouchSidestepOrTurnSpeeds_J9()
|
||||
{
|
||||
// A9/J9: retail touches ONLY forward cmd/speed + sidestep/turn
|
||||
// COMMANDS — it does NOT write sidestep_speed or turn_speed. The
|
||||
// pre-R3 acdream divergence (1.0 speed resets) must be gone.
|
||||
var interp = MakeInterp();
|
||||
interp.RawState.SidestepSpeed = 2.5f;
|
||||
interp.RawState.TurnSpeed = 3.5f;
|
||||
interp.InterpretedState.SideStepSpeed = 4.5f;
|
||||
interp.InterpretedState.TurnSpeed = 5.5f;
|
||||
|
||||
interp.StopCompletely();
|
||||
|
||||
Assert.Equal(2.5f, interp.RawState.SidestepSpeed);
|
||||
Assert.Equal(3.5f, interp.RawState.TurnSpeed);
|
||||
Assert.Equal(4.5f, interp.InterpretedState.SideStepSpeed);
|
||||
Assert.Equal(5.5f, interp.InterpretedState.TurnSpeed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_ZerosPhysicsBodyVelocity()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
body.set_velocity(new Vector3(5f, 3f, 0f));
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
interp.StopCompletely();
|
||||
|
||||
Assert.Equal(Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_InterruptsCurrentMovement()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
bool fired = false;
|
||||
interp.InterruptCurrentMovement = () => fired = true;
|
||||
|
||||
interp.StopCompletely();
|
||||
|
||||
Assert.True(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_QueuedNodeErrorCode_ReflectsPreStopForwardCommand()
|
||||
{
|
||||
// A9 golden: motion_allows_jump is evaluated on the OLD (pre-stop)
|
||||
// interpreted forward_command, snapshotted BEFORE the overwrite, and
|
||||
// that snapshot becomes the queued node's jump_error_code. Crouch
|
||||
// (0x41000012) is in the motion_allows_jump BLOCKED range -> 0x48.
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Crouch;
|
||||
|
||||
interp.StopCompletely();
|
||||
|
||||
var node = Assert.Single(interp.PendingMotions);
|
||||
Assert.Equal(0u, node.ContextId);
|
||||
Assert.Equal(MotionCommand.Ready, node.Motion);
|
||||
Assert.Equal((uint)WeenieError.YouCantJumpFromThisPosition, node.JumpErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_QueuedNodeErrorCode_ZeroWhenPreStopCommandAllowsJump()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Ready; // passes motion_allows_jump
|
||||
|
||||
interp.StopCompletely();
|
||||
|
||||
var node = Assert.Single(interp.PendingMotions);
|
||||
Assert.Equal(0u, node.JumpErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_CellNull_FiresRemoveLinkAnimations()
|
||||
{
|
||||
// CurCell proxy: PhysicsObj.CellPosition.ObjCellId == 0 (default/unseeded).
|
||||
var interp = MakeInterp();
|
||||
bool fired = false;
|
||||
interp.RemoveLinkAnimations = () => fired = true;
|
||||
|
||||
interp.StopCompletely();
|
||||
|
||||
Assert.True(fired, "physics_obj->cell == 0 must fire RemoveLinkAnimations");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_CellNonNull_DoesNotFireRemoveLinkAnimations()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
body.SnapToCell(0x12340001u, Vector3.Zero, Vector3.Zero);
|
||||
var interp = MakeInterp(body);
|
||||
bool fired = false;
|
||||
interp.RemoveLinkAnimations = () => fired = true;
|
||||
|
||||
interp.StopCompletely();
|
||||
|
||||
Assert.False(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopCompletely_AlwaysReturnsNone_OnceCallExecutes()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Crouch; // would fail motion_allows_jump
|
||||
|
||||
var result = interp.StopCompletely();
|
||||
|
||||
Assert.Equal(WeenieError.None, result); // StopCompletely itself always succeeds
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// StandingLongJump state-only branch: Walk/Run/SideStepRight while
|
||||
// charging -> state write, NO dispatch, NO queue node.
|
||||
// =========================================================================
|
||||
|
||||
private sealed class RecordingSink : IInterpretedMotionSink
|
||||
{
|
||||
public readonly List<string> Calls = new();
|
||||
public bool ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
Calls.Add($"APPLY {motion:x8}@{speed:F2}");
|
||||
return true;
|
||||
}
|
||||
public bool StopMotion(uint motion)
|
||||
{
|
||||
Calls.Add($"STOP {motion:x8}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_StandingLongJumpCharging_WalkForward_StateOnly_NoDispatch_NoQueue()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.StandingLongJump = true;
|
||||
interp.DefaultSink = null; // no App-level sink wired directly on DoInterpretedMotion path
|
||||
|
||||
var before = interp.PendingMotions.Count();
|
||||
var result = interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand);
|
||||
Assert.Equal(before, interp.PendingMotions.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_StandingLongJumpCharging_RunForward_StateOnly_NoDispatch_NoQueue()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.StandingLongJump = true;
|
||||
|
||||
var before = interp.PendingMotions.Count();
|
||||
var result = interp.DoInterpretedMotion(MotionCommand.RunForward, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(MotionCommand.RunForward, interp.InterpretedState.ForwardCommand);
|
||||
Assert.Equal(before, interp.PendingMotions.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_StandingLongJumpCharging_SideStepRight_StateOnly_NoDispatch_NoQueue()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.StandingLongJump = true;
|
||||
|
||||
var before = interp.PendingMotions.Count();
|
||||
var result = interp.DoInterpretedMotion(MotionCommand.SideStepRight, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(MotionCommand.SideStepRight, interp.InterpretedState.SideStepCommand);
|
||||
Assert.Equal(before, interp.PendingMotions.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_StandingLongJumpCharging_OtherMotion_DispatchesNormally()
|
||||
{
|
||||
// Only Walk/Run/SideStepRight get the state-only shortcut while
|
||||
// charging — everything else dispatches through the normal path
|
||||
// (including queueing).
|
||||
var interp = MakeInterp();
|
||||
interp.StandingLongJump = true;
|
||||
|
||||
var result = interp.DoInterpretedMotion(MotionCommand.TurnRight, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Single(interp.PendingMotions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_NotCharging_WalkForward_DispatchesNormally_AndQueues()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.StandingLongJump = false;
|
||||
|
||||
var result = interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Single(interp.PendingMotions);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DisableJumpDuringLink forces the queued node's jump_error_code to 0x48
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_DisableJumpDuringLink_ForcesQueuedNodeTo0x48()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Ready; // would otherwise PASS motion_allows_jump
|
||||
var p = new MovementParameters { DisableJumpDuringLink = true };
|
||||
|
||||
interp.DoInterpretedMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
var node = Assert.Single(interp.PendingMotions);
|
||||
Assert.Equal((uint)WeenieError.YouCantJumpFromThisPosition, node.JumpErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_DisableJumpDuringLinkClear_UsesComputedErrorCode()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||
var p = new MovementParameters { DisableJumpDuringLink = false };
|
||||
|
||||
interp.DoInterpretedMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
var node = Assert.Single(interp.PendingMotions);
|
||||
Assert.Equal(0u, node.JumpErrorCode); // WalkForward + Ready both pass motion_allows_jump
|
||||
}
|
||||
|
||||
// ── Dead -> RemoveLinkAnimations ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_Dead_FiresRemoveLinkAnimations()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
bool fired = false;
|
||||
interp.RemoveLinkAnimations = () => fired = true;
|
||||
|
||||
interp.DoInterpretedMotion(MotionCommand.Dead, new MovementParameters());
|
||||
|
||||
Assert.True(fired);
|
||||
}
|
||||
|
||||
// ── ModifyInterpretedState param gates the state write ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_ModifyInterpretedStateClear_DoesNotWriteState()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
var p = new MovementParameters { ModifyInterpretedState = false };
|
||||
|
||||
interp.DoInterpretedMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); // untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_ModifyInterpretedStateSet_WritesState()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
var p = new MovementParameters { ModifyInterpretedState = true, Speed = 1.5f };
|
||||
|
||||
interp.DoInterpretedMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
// ── CurCell-null tail (proxy: CellPosition.ObjCellId == 0) ──────────────
|
||||
// NOTE: this is the DoInterpretedMotion success-path CurCell check per
|
||||
// the plan; the merged function fires RemoveLinkAnimations whenever the
|
||||
// physics_obj has no cell, mirroring StopCompletely's identical tail.
|
||||
|
||||
[Fact]
|
||||
public void DoInterpretedMotion_ContactBlocked_ActionClass_Returns0x24()
|
||||
{
|
||||
var body = new PhysicsBody { State = PhysicsStateFlags.Gravity }; // airborne, no contact
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
var result = interp.DoInterpretedMotion(0x10000060u, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.NotGrounded, result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// StopInterpretedMotion — merged verbatim pair
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void StopInterpretedMotion_NullPhysicsObj_Returns8()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
|
||||
var result = interp.StopInterpretedMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||
|
||||
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopInterpretedMotion_Success_EnqueuesReturnToNoneNode()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
var p = new MovementParameters { ContextId = 42 };
|
||||
|
||||
interp.StopInterpretedMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
var node = Assert.Single(interp.PendingMotions);
|
||||
Assert.Equal(42u, node.ContextId);
|
||||
Assert.Equal(MotionCommand.Ready, node.Motion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopInterpretedMotion_ModifyInterpretedStateClear_DoesNotWriteState()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
var p = new MovementParameters { ModifyInterpretedState = false };
|
||||
|
||||
interp.StopInterpretedMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); // untouched
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PerformMovement — zero-tick CheckForCompletedMotions flush (closes J14)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_RawCommand_FiresCheckForCompletedMotions()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
int flushCount = 0;
|
||||
interp.CheckForCompletedMotions = () => flushCount++;
|
||||
|
||||
interp.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.RawCommand,
|
||||
Motion = MotionCommand.WalkForward,
|
||||
Speed = 1.0f,
|
||||
});
|
||||
|
||||
Assert.Equal(1, flushCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MovementType.RawCommand)]
|
||||
[InlineData(MovementType.InterpretedCommand)]
|
||||
[InlineData(MovementType.StopRawCommand)]
|
||||
[InlineData(MovementType.StopInterpretedCommand)]
|
||||
[InlineData(MovementType.StopCompletely)]
|
||||
public void PerformMovement_EveryDispatchedOp_FiresFlushExactlyOnce(MovementType type)
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
int flushCount = 0;
|
||||
interp.CheckForCompletedMotions = () => flushCount++;
|
||||
|
||||
interp.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = type,
|
||||
Motion = MotionCommand.WalkForward,
|
||||
Speed = 1.0f,
|
||||
});
|
||||
|
||||
Assert.Equal(1, flushCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_InvalidType_DoesNotFireFlush()
|
||||
{
|
||||
// The type-dispatch failure (0x47, bad MovementStruct.type) happens
|
||||
// BEFORE any op is dispatched — no flush should fire for it.
|
||||
var interp = MakeInterp();
|
||||
int flushCount = 0;
|
||||
interp.CheckForCompletedMotions = () => flushCount++;
|
||||
|
||||
var result = interp.PerformMovement(new MovementStruct { Type = (MovementType)99 });
|
||||
|
||||
Assert.Equal(WeenieError.GeneralMovementFailure, result);
|
||||
Assert.Equal(0, flushCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_FlushSeamUnset_DoesNotThrow()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.CheckForCompletedMotions = null;
|
||||
|
||||
var result = interp.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.StopCompletely,
|
||||
});
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_StopCompletely_ResetsToReady_AndFlushes()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
int flushCount = 0;
|
||||
interp.CheckForCompletedMotions = () => flushCount++;
|
||||
|
||||
interp.PerformMovement(new MovementStruct { Type = MovementType.StopCompletely });
|
||||
|
||||
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
|
||||
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
|
||||
Assert.Equal(1, flushCount);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,10 +19,23 @@ public class MotionInterpreterFunnelTests
|
|||
private sealed class RecordingSink : IInterpretedMotionSink
|
||||
{
|
||||
public readonly List<string> Calls = new();
|
||||
public void ApplyMotion(uint motion, float speed)
|
||||
=> Calls.Add($"DIM {motion:x8}@{speed:F2}");
|
||||
public void StopMotion(uint motion)
|
||||
=> Calls.Add($"STOP {motion:x8}");
|
||||
public bool ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
Calls.Add($"DIM {motion:x8}@{speed:F2}");
|
||||
// R3-W5: a style/stance id (>= 0x80000000, i.e. negative as
|
||||
// int32) has no locomotion MotionData entry in the dat — retail's
|
||||
// real CMotionTable::DoObjectMotion genuinely fails for it
|
||||
// (MotionTableManagerError.MotionFailed). The fake sink mirrors
|
||||
// that so InterpretedMotionState.ForwardCommand isn't clobbered
|
||||
// by ApplyMotion's negative-motion branch before the very next
|
||||
// dispatch reads it — matches the live retail-observer trace.
|
||||
return motion < 0x80000000u;
|
||||
}
|
||||
public bool StopMotion(uint motion)
|
||||
{
|
||||
Calls.Add($"STOP {motion:x8}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static MotionInterpreter GroundedInterp()
|
||||
|
|
@ -164,8 +177,26 @@ public class MotionInterpreterFunnelTests
|
|||
// (0x00528240 early-accept), so it reaches the sink; the style and
|
||||
// forward dispatches are gate-blocked (apply-only path).
|
||||
Assert.Equal(new[] { "DIM 40000015@1.00", "STOP 6500000d" }, sink.Calls);
|
||||
// The interpreted STATE still flat-copies (velocity uses it on landing).
|
||||
Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand);
|
||||
// R3-W5 correction: DoInterpretedMotion's verbatim body (raw
|
||||
// 305575-305631) writes InterpretedState via
|
||||
// InterpretedMotionState::ApplyMotion on BOTH the success path
|
||||
// (raw 305609-305610, gated on the sink's own result) AND the
|
||||
// blocked/apply-only `label_528440` path (raw 305616-305618,
|
||||
// UNCONDITIONAL on the ModifyInterpretedState bit — no sink
|
||||
// involved at all). This W5 slice is the first port to actually
|
||||
// WIRE that state-write (the pre-W5 funnel never called
|
||||
// InterpretedMotionState.ApplyMotion from dispatch at all — state
|
||||
// was set only by MoveToInterpretedState's flat copy at the top of
|
||||
// the call), so the blocked style dispatch DOES flip
|
||||
// ForwardCommand to Ready (0x41000003) at step 1, which then makes
|
||||
// the Falling substitution dispatch (step 2) ITself write
|
||||
// ForwardCommand = Falling (0x40000015) via the SAME mechanism —
|
||||
// this replaces the assertion this comment used to make ("state
|
||||
// still flat-copies") with the verbatim end state. Only
|
||||
// sink.Calls (dispatch ORDER) was ever cdb-verified by this file's
|
||||
// module doc; this resulting-state assertion was an untested
|
||||
// assumption from the pre-W5 architecture.
|
||||
Assert.Equal(0x40000015u, mi.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -40,8 +40,18 @@ public class RetailObserverTraceConformanceTests
|
|||
private sealed class RecordingSink : IInterpretedMotionSink
|
||||
{
|
||||
public readonly List<uint> Applied = new();
|
||||
public void ApplyMotion(uint motion, float speed) => Applied.Add(motion);
|
||||
public void StopMotion(uint motion) { }
|
||||
public bool ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
Applied.Add(motion);
|
||||
// R3-W5: style/stance ids (>= 0x80000000) have no dat MotionData
|
||||
// entry — retail's real motion-table lookup fails for them
|
||||
// (see MotionInterpreterDoMotionFamilyTests / the interface
|
||||
// doc on IInterpretedMotionSink.ApplyMotion for the full
|
||||
// rationale). The fake sink mirrors that so the style dispatch
|
||||
// doesn't clobber ForwardCommand before the next read.
|
||||
return motion < 0x80000000u;
|
||||
}
|
||||
public bool StopMotion(uint motion) => true;
|
||||
}
|
||||
|
||||
private static string TracePath()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue