acdream/tests/AcDream.Core.Tests/Physics/MotionInterpreterGroundLifecycleTests.cs
Erik e214acdf23 feat(R3-W4): ground transitions + lifecycle verbatim; K-fix18 DELETED (closes J8, J10, J11-shape, J12, J13, J18, J19)
Core (dedicated agent, independently reviewed): HitGround 0x00528ac0 /
LeaveGround 0x00528b00 verbatim (creature+gravity gates, the
RemoveLinkAnimations seam — K-fix18's retail mechanism — velocity via
GetLeaveGroundVelocity with the autonomous flag, jump-state resets,
apply_current_movement re-sync); enter_default_state 0x00528c80 per A8
(fresh states, InitializeMotionTables seam, sentinel APPENDED without
draining pending_motions — pinned, Initted=1, LeaveGround tail);
Initted gates; the A3 IsThePlayer dual dispatch in
apply_current_movement / ReportExhaustion / SetWeenieObject /
SetPhysicsObject (a remote player routes INTERPRETED — the
ACE-divergence pin); set_hold_run 0x00528b70 + SetHoldKey 0x00528bb0
(XOR guard, None-only-from-Run); adjust_motion creature guard wired
(TS-34 retired); PhysicsBody.LastMoveWasAutonomous +
set_local_velocity(autonomous). Port discovery: retail's
apply_raw_movement 0x005287e0 / apply_interpreted_movement 0x00528600
ARE the already-shipped D6.2a/funnel functions — the dual dispatch
composes them instead of duplicating.

App cutover (orchestrator): the skipTransitionLink flag + both K-fix18
call sites DELETED (AP-74 retired). MotionInterpreter.DefaultSink routes
apply_current_movement's interpreted branch through the REAL funnel
dispatch when a sink is bound — so a remote's LeaveGround engages
Falling via the contact-gated funnel, replacing the forced SetCycle
(J19); the per-remote MotionTableDispatchSink is now PERSISTENT
(EnsureRemoteMotionBindings: DefaultSink + RemoveLinkAnimations +
InitializeMotionTables seams, idempotent from both the UM and
VectorUpdate paths; wire velocity re-applied after LeaveGround so it
stays authoritative). Player: seams bound to the player sequencer; the
controller's grounded→airborne EDGE now fires LeaveGround (jump()
clears OnWalkable and the same frame's transition detection fires it —
retail's order; walk-off-a-ledge gets the momentum fallback + link
strip it never had); the manual jump-block LeaveGround deleted;
LastMoveWasAutonomous set at the controller chokepoint (W6 refines).

Trace S8 re-expressed as the retail mechanism (Falling dispatch +
RemoveAllLinkAnimations = same final state the flag produced). 43 new
lifecycle tests. Registers: TS-34 + AP-74 retired; TS-38, AP-77, AP-78
added. Full suite: 3,665 passed. Live smoke: in-world clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:01:31 +02:00

784 lines
30 KiB
C#

using System.Linq;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics;
// ─────────────────────────────────────────────────────────────────────────────
// MotionInterpreterGroundLifecycleTests — R3-W4 (closes J8, J10, J11-shape,
// J12, J13; J18 one-liner rides along).
//
// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md
// §4 (HitGround 0x00528ac0 @305996, LeaveGround 0x00528b00 @306022,
// ReportExhaustion 0x005288d0 @305861, enter_default_state 0x00528c80
// @306124) + §5c/5d (set_hold_run 0x00528b70 @306053, SetHoldKey 0x00528bb0
// @306072) + raw acclient_2013_pseudo_c.txt:305838-305932 (apply_current_movement
// 0x00528870, SetWeenieObject 0x00528920, SetPhysicsObject 0x00528970) +
// W0-pins.md A3 (dual-dispatch gate) / A8 (enter_default_state no-clear).
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>Fake WeenieObject — IsThePlayer/IsCreature independently
/// configurable, for the A3 dispatch truth table.</summary>
file sealed class FakeWeenie : IWeenieObject
{
public bool IsThePlayerResult;
public bool IsCreatureResult = 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) => true;
public bool IsThePlayer() => IsThePlayerResult;
public bool IsCreature() => IsCreatureResult;
}
public sealed class MotionInterpreterGroundLifecycleTests
{
// ── 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 PhysicsBody MakeAirborne()
{
var body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
body.TransientState = TransientStateFlags.Active;
return body;
}
private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null)
{
body ??= MakeGrounded();
return new MotionInterpreter(body, weenie);
}
// =========================================================================
// HitGround — 0x00528ac0 @305996
// =========================================================================
[Fact]
public void HitGround_NoPhysicsObj_NoOp()
{
var interp = new MotionInterpreter();
bool called = false;
interp.RemoveLinkAnimations = () => called = true;
interp.HitGround();
Assert.False(called);
}
[Fact]
public void HitGround_NonCreatureWeenie_NoOp()
{
// raw 305720-305724: (weenie_obj == 0 || eax_2 != 0) -- a WEENIE
// present whose IsCreature() returns false skips the whole body.
var weenie = new FakeWeenie { IsCreatureResult = false };
var body = MakeGrounded();
var interp = MakeInterp(body, weenie);
bool called = false;
interp.RemoveLinkAnimations = () => called = true;
interp.HitGround();
Assert.False(called, "a non-creature weenie must skip HitGround's body entirely");
}
[Fact]
public void HitGround_NoWeenie_Proceeds()
{
// weenie_obj == 0 -> the OR short-circuits true, body proceeds.
var body = MakeGrounded();
var interp = MakeInterp(body);
bool called = false;
interp.RemoveLinkAnimations = () => called = true;
interp.HitGround();
Assert.True(called, "no weenie_obj must still proceed (weenie==0 half of the OR)");
}
[Fact]
public void HitGround_GravityFlagClear_NoOp()
{
// raw 305726-305730: state bit 0x400 (Gravity) gates the body.
var body = MakeGrounded();
body.State &= ~PhysicsStateFlags.Gravity;
var interp = MakeInterp(body);
bool called = false;
interp.RemoveLinkAnimations = () => called = true;
interp.HitGround();
Assert.False(called, "HitGround must no-op when the Gravity state flag is clear");
}
[Fact]
public void HitGround_CreatureWithGravity_CallsRemoveLinkAnimationsThenReapplies()
{
var weenie = new FakeWeenie { IsCreatureResult = true };
var body = MakeGrounded();
var interp = MakeInterp(body, weenie);
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
bool called = false;
interp.RemoveLinkAnimations = () => called = true;
interp.HitGround();
Assert.True(called, "HitGround must call the RemoveLinkAnimations seam");
// apply_current_movement(false, true) re-syncs velocity from the
// current interpreted state (grounded write, AP-75-adjacent).
Assert.True(body.Velocity.Length() > 0f, "HitGround must re-apply current movement");
}
// =========================================================================
// LeaveGround — 0x00528b00 @306022
// =========================================================================
[Fact]
public void LeaveGround_NoPhysicsObj_NoOp()
{
var interp = new MotionInterpreter();
bool called = false;
interp.RemoveLinkAnimations = () => called = true;
interp.LeaveGround();
Assert.False(called);
}
[Fact]
public void LeaveGround_NonCreatureWeenie_NoOp()
{
var weenie = new FakeWeenie { IsCreatureResult = false };
var body = MakeGrounded();
var interp = MakeInterp(body, weenie);
interp.StandingLongJump = true;
interp.JumpExtent = 0.7f;
bool called = false;
interp.RemoveLinkAnimations = () => called = true;
interp.LeaveGround();
Assert.False(called);
// Nothing in the body ran -- StandingLongJump/JumpExtent untouched.
Assert.True(interp.StandingLongJump);
Assert.Equal(0.7f, interp.JumpExtent);
}
[Fact]
public void LeaveGround_GravityFlagClear_NoOp()
{
var body = MakeGrounded();
body.State &= ~PhysicsStateFlags.Gravity;
var interp = MakeInterp(body);
interp.StandingLongJump = true;
bool called = false;
interp.RemoveLinkAnimations = () => called = true;
interp.LeaveGround();
Assert.False(called);
Assert.True(interp.StandingLongJump);
}
[Fact]
public void LeaveGround_CreatureWithGravity_SetsVelocityAndResetsJumpState()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.StandingLongJump = true;
interp.JumpExtent = 0.5f;
interp.LeaveGround();
Assert.False(interp.StandingLongJump, "standing_longjump = 0 on leave-ground");
Assert.Equal(0f, interp.JumpExtent, precision: 5);
}
[Fact]
public void LeaveGround_CallsRemoveLinkAnimationsThenReapplies()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
bool called = false;
interp.RemoveLinkAnimations = () => called = true;
interp.LeaveGround();
Assert.True(called, "LeaveGround must call the RemoveLinkAnimations seam");
}
[Fact]
public void LeaveGround_VelocityCarriesGetLeaveGroundVelocity_WithNonzeroJumpExtent()
{
// A6/A5: get_leave_ground_velocity composes get_state_velocity() +
// z=GetJumpVZ(). With JumpExtent set and a weenie present, the body
// local velocity written must match GetLeaveGroundVelocity()'s Z.
//
// Real call-site precondition: by the time LeaveGround fires, the
// body has ALREADY left the ground (jump() calls set_on_walkable(false)
// before LeaveGround runs; a ledge walk-off similarly clears OnWalkable
// via the physics resolver BEFORE this callback). With OnWalkable
// false, LeaveGround's own tail apply_current_movement(0,0) call
// takes the "airborne -- preserve integrated velocity" branch in the
// AP-77 adaptation (ApplyCurrentMovementInterpreted), so the Z this
// call just set is not immediately clobbered by a resync.
var weenie = new AcDream.Core.Physics.PlayerWeenie();
var body = MakeGrounded();
body.set_on_walkable(false);
var interp = MakeInterp(body, weenie);
interp.JumpExtent = 0.5f;
var expected = interp.GetLeaveGroundVelocity();
interp.LeaveGround();
// set_local_velocity transforms local->world via Orientation
// (Identity in these fixtures), so world == local here.
Assert.Equal(expected.Z, body.Velocity.Z, precision: 3);
}
[Fact]
public void LeaveGround_SetLocalVelocity_UsesAutonomousFlag()
{
// raw 305763-305765: CPhysicsObj::set_local_velocity(&var_c, 1) --
// the autonomous=1 arg. PhysicsBody.LastMoveWasAutonomous should
// reflect this after LeaveGround runs (movement_is_autonomous, A3's
// apply_current_movement dispatch reads this same flag).
var body = MakeGrounded();
body.LastMoveWasAutonomous = false;
var interp = MakeInterp(body);
interp.LeaveGround();
Assert.True(body.LastMoveWasAutonomous, "LeaveGround's set_local_velocity call carries autonomous=1");
}
// =========================================================================
// enter_default_state — 0x00528c80 @306124 (A8: append sentinel, NO drain)
// =========================================================================
[Fact]
public void EnterDefaultState_AppendsReadySentinel_WithoutDrainingExistingNodes()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.AddToQueue(1, MotionCommand.RunForward, 0);
interp.AddToQueue(2, MotionCommand.WalkForward, 0x48);
interp.EnterDefaultState();
var nodes = interp.PendingMotions.ToArray();
Assert.Equal(3, nodes.Length);
Assert.Equal(new MotionNode(1, MotionCommand.RunForward, 0), nodes[0]);
Assert.Equal(new MotionNode(2, MotionCommand.WalkForward, 0x48), nodes[1]);
Assert.Equal(new MotionNode(0, MotionCommand.Ready, 0), nodes[2]);
}
[Fact]
public void EnterDefaultState_SetsInitted()
{
var interp = new MotionInterpreter { PhysicsObj = MakeGrounded() };
// Force Initted false to prove EnterDefaultState is what flips it
// (the ctor already defaults it true -- see the Initted-gating
// section below for why).
interp.Initted = false;
interp.EnterDefaultState();
Assert.True(interp.Initted);
}
[Fact]
public void EnterDefaultState_ResetsRawAndInterpretedStateToCtorDefaults()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.ForwardCommand = MotionCommand.RunForward;
interp.RawState.ForwardSpeed = 2.94f;
interp.RawState.CurrentHoldKey = HoldKey.Run;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.TurnCommand = MotionCommand.TurnRight;
interp.EnterDefaultState();
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
Assert.Equal(1.0f, interp.RawState.ForwardSpeed);
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
Assert.Equal(0u, interp.InterpretedState.TurnCommand);
}
[Fact]
public void EnterDefaultState_CallsInitializeMotionTablesSeam()
{
var interp = MakeInterp();
bool called = false;
interp.InitializeMotionTables = () => called = true;
interp.EnterDefaultState();
Assert.True(called);
}
[Fact]
public void EnterDefaultState_TailCallsLeaveGround()
{
// enter_default_state's LAST step is an unconditional LeaveGround()
// call (raw @306153). With a grounded creature+gravity body and a
// nonzero JumpExtent pre-seeded, LeaveGround's reset (standing_longjump=0,
// jump_extent=0) must be observable after EnterDefaultState.
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.StandingLongJump = true;
interp.JumpExtent = 0.9f;
interp.EnterDefaultState();
Assert.False(interp.StandingLongJump, "EnterDefaultState's LeaveGround tail must fire");
Assert.Equal(0f, interp.JumpExtent, precision: 5);
}
// =========================================================================
// Initted gating — apply_current_movement / ReportExhaustion (A3 entry gate)
// =========================================================================
[Fact]
public void ApplyCurrentMovement_NotInitted_NoOp()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.Initted = false;
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
}
[Fact]
public void ApplyCurrentMovement_Initted_Proceeds()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
Assert.True(interp.Initted, "the two-arg constructor defaults Initted=true (see final report)");
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
Assert.True(body.Velocity.Length() > 0f);
}
[Fact]
public void ReportExhaustion_NotInitted_NoOp()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.Initted = false;
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.ReportExhaustion();
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
}
[Fact]
public void ReportExhaustion_NoPhysicsObj_NoOp()
{
var interp = new MotionInterpreter();
// No exception is the assertion -- retail's entry gate is
// physics_obj != 0 && initted != 0, both ANDed.
interp.ReportExhaustion();
}
// =========================================================================
// Dual-dispatch truth table — A3: IsThePlayer && movement_is_autonomous
// gates apply_raw_movement vs apply_interpreted_movement, for
// apply_current_movement AND ReportExhaustion.
//
// Reading raw_movement's effect: apply_raw_movement copies RawState ->
// InterpretedState (see apply_raw_movement's existing doc comment) then
// re-normalizes via adjust_motion. apply_interpreted_movement instead
// reads InterpretedState directly and pushes velocity via
// get_state_velocity/set_local_velocity when grounded. We distinguish
// the two dispatch targets by seeding RawState and InterpretedState with
// DIFFERENT forward commands/speeds and observing which one drove the
// resulting body velocity.
// =========================================================================
[Theory]
[InlineData(true, true, /* expectRaw */ true)] // IsThePlayer && autonomous -> raw
[InlineData(true, false, /* expectRaw */ false)] // IsThePlayer but not autonomous -> interpreted
[InlineData(false, true, /* expectRaw */ false)] // autonomous but NOT the player -> interpreted (remote player IS a creature but not "the player")
[InlineData(false, false, /* expectRaw */ false)] // neither -> interpreted
public void ApplyCurrentMovement_DualDispatch_MatchesA3TruthTable(
bool isThePlayer, bool autonomous, bool expectRaw)
{
var weenie = new FakeWeenie { IsThePlayerResult = isThePlayer, IsCreatureResult = true };
var body = MakeGrounded();
body.LastMoveWasAutonomous = autonomous;
var interp = MakeInterp(body, weenie);
// RawState drives WalkForward (speed 1 => WalkAnimSpeed); InterpretedState
// drives RunForward (speed 1 => RunAnimSpeed). WalkAnimSpeed != RunAnimSpeed
// so the resulting body.Velocity.Y distinguishes which path ran.
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
interp.RawState.ForwardSpeed = 1.0f;
interp.RawState.ForwardHoldKey = HoldKey.None;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
float expectedY = expectRaw
? MotionInterpreter.WalkAnimSpeed // apply_raw_movement re-derives from RawState
: MotionInterpreter.RunAnimSpeed; // apply_interpreted_movement reads InterpretedState as-is
Assert.Equal(expectedY, body.Velocity.Y, precision: 2);
}
[Fact]
public void ApplyCurrentMovement_NoWeenie_Autonomous_DispatchesRaw()
{
// weenie_obj == 0 short-circuits the OR to true (raw 305849:
// weenie_obj == 0 || eax_2 != 0) -- no weenie means "treat as the
// player" for dispatch purposes.
var body = MakeGrounded();
body.LastMoveWasAutonomous = true;
var interp = MakeInterp(body); // no weenie
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
interp.RawState.ForwardSpeed = 1.0f;
interp.RawState.ForwardHoldKey = HoldKey.None;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
Assert.Equal(MotionInterpreter.WalkAnimSpeed, body.Velocity.Y, precision: 2);
}
[Fact]
public void ReportExhaustion_DualDispatch_PassesZeroZeroArgs()
{
// A3: ReportExhaustion's dispatch args are hardcoded (0, 0) --
// distinct from apply_current_movement which passes its own
// (cancelMoveTo, allowJump) through. We can't observe the args
// directly (both paths are void), so this proves ReportExhaustion
// dispatches at all when initted+player+autonomous (raw path) by
// observing the same velocity-divergence trick.
var weenie = new FakeWeenie { IsThePlayerResult = true };
var body = MakeGrounded();
body.LastMoveWasAutonomous = true;
var interp = MakeInterp(body, weenie);
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
interp.RawState.ForwardSpeed = 1.0f;
interp.RawState.ForwardHoldKey = HoldKey.None;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.ReportExhaustion();
Assert.Equal(MotionInterpreter.WalkAnimSpeed, body.Velocity.Y, precision: 2);
}
[Fact]
public void ReportExhaustion_RemotePlayer_DispatchesInterpreted()
{
// The ACE-divergence pin: a remote player weenie (IsThePlayer=false,
// IsCreature=true) must route INTERPRETED, not raw -- even though
// it IS a creature. ACE's IsCreature-gated read would wrongly send
// this down apply_raw_movement.
var weenie = new FakeWeenie { IsThePlayerResult = false, IsCreatureResult = true };
var body = MakeGrounded();
body.LastMoveWasAutonomous = true; // even with autonomous=true...
var interp = MakeInterp(body, weenie);
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
interp.RawState.ForwardSpeed = 1.0f;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.ReportExhaustion();
// ...IsThePlayer==false forces the interpreted path.
Assert.Equal(MotionInterpreter.RunAnimSpeed, body.Velocity.Y, precision: 2);
}
// =========================================================================
// SetWeenieObject / SetPhysicsObject — 0x00528920 / 0x00528970 re-apply
// =========================================================================
[Fact]
public void SetWeenieObject_WhilePhysicsBoundAndInitted_ReappliesMovement()
{
var body = MakeGrounded();
var interp = new MotionInterpreter { PhysicsObj = body, Initted = true };
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.SetWeenieObject(new FakeWeenie { IsThePlayerResult = false });
Assert.True(body.Velocity.Length() > 0f, "SetWeenieObject must re-apply movement when bound+initted");
}
[Fact]
public void SetWeenieObject_NoPhysicsObj_DoesNotReapply()
{
var interp = new MotionInterpreter();
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
// Must not throw despite no PhysicsObj.
interp.SetWeenieObject(new FakeWeenie());
Assert.Null(interp.PhysicsObj);
}
[Fact]
public void SetWeenieObject_NotInitted_DoesNotReapply()
{
var body = MakeGrounded();
var interp = new MotionInterpreter { PhysicsObj = body, Initted = false };
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.SetWeenieObject(new FakeWeenie());
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
}
[Fact]
public void SetPhysicsObject_BindsAndReappliesWhenInitted()
{
var interp = new MotionInterpreter { Initted = true };
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
var body = MakeGrounded();
interp.SetPhysicsObject(body);
Assert.Same(body, interp.PhysicsObj);
Assert.True(body.Velocity.Length() > 0f);
}
[Fact]
public void SetPhysicsObject_NullArg_DoesNotReapply()
{
var interp = new MotionInterpreter { Initted = true };
// arg2 != 0 gates the reapply -- passing null must not throw and
// must not attempt InterpretedState velocity work (no body to write to).
interp.SetPhysicsObject(null);
Assert.Null(interp.PhysicsObj);
}
// =========================================================================
// set_hold_run — 0x00528b70 @306053 (XOR toggle guard)
// =========================================================================
[Fact]
public void SetHoldRun_TogglesFromNoneToRun_WhenHoldingRunKey()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.None;
interp.set_hold_run(holdingRun: true, interrupt: false);
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
}
[Fact]
public void SetHoldRun_TogglesFromRunToNone_WhenReleasingRunKey()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.Run;
interp.set_hold_run(holdingRun: false, interrupt: false);
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
}
[Fact]
public void SetHoldRun_NoChange_WhenAlreadyInRequestedState_IsANoOp()
{
// XOR guard: eax(=arg2==0) != edx(=current!=Run) is FALSE when
// arg2 requests exactly the state we're already in -- skip.
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.Run;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
body.Velocity = System.Numerics.Vector3.Zero;
interp.set_hold_run(holdingRun: true, interrupt: false);
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
// No re-apply fired (still zero) -- the guard skipped the whole body.
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
}
[Fact]
public void SetHoldRun_OnChange_CallsApplyCurrentMovementWithInterruptArg()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.None;
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.set_hold_run(holdingRun: true, interrupt: true);
// apply_current_movement(arg3, 0) fired -- observable via the
// grounded velocity re-application (AP-75-adjacent write).
Assert.True(body.Velocity.Length() > 0f);
}
// =========================================================================
// SetHoldKey — 0x00528bb0 @306072 (None-only-meaningful-from-Run)
// =========================================================================
[Fact]
public void SetHoldKey_None_FromRun_TakesEffect()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.Run;
interp.SetHoldKey(HoldKey.None, cancelMoveTo: false);
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
}
[Fact]
public void SetHoldKey_None_FromInvalid_IsIgnored()
{
// raw @306072: setting None only takes effect from Run. Any other
// starting state (Invalid, or already None) is silently ignored.
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.Invalid;
interp.SetHoldKey(HoldKey.None, cancelMoveTo: false);
Assert.Equal(HoldKey.Invalid, interp.RawState.CurrentHoldKey);
}
[Fact]
public void SetHoldKey_AlreadyNone_IsNoOp()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.None;
interp.SetHoldKey(HoldKey.None, cancelMoveTo: false);
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
}
[Fact]
public void SetHoldKey_Run_FromNone_TakesEffect()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.None;
interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false);
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
}
[Fact]
public void SetHoldKey_Run_AlreadyRun_IsNoOp()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.Run;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
body.Velocity = System.Numerics.Vector3.Zero;
interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false);
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
}
[Fact]
public void SetHoldKey_EffectiveChange_ReappliesMovement()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.RawState.CurrentHoldKey = HoldKey.None;
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
interp.InterpretedState.ForwardSpeed = 1.0f;
interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false);
Assert.True(body.Velocity.Length() > 0f, "an effective SetHoldKey change must reapply movement");
}
// =========================================================================
// adjust_motion creature guard — J18 one-liner (retires register TS-34)
// =========================================================================
[Fact]
public void AdjustMotion_NonCreatureWeenie_SkipsNormalization()
{
var weenie = new FakeWeenie { IsCreatureResult = false };
var interp = MakeInterp(weenie: weenie);
uint motion = MotionCommand.WalkBackward;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
// Retail returns immediately for a non-creature weenie -- WalkBackward
// must NOT be remapped to WalkForward/negated-speed.
Assert.Equal(MotionCommand.WalkBackward, motion);
Assert.Equal(1.0f, speed);
}
[Fact]
public void AdjustMotion_CreatureWeenie_NormalizesAsBefore()
{
var weenie = new FakeWeenie { IsCreatureResult = true };
var interp = MakeInterp(weenie: weenie);
uint motion = MotionCommand.WalkBackward;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
Assert.Equal(MotionCommand.WalkForward, motion);
Assert.Equal(-MotionInterpreter.BackwardsFactor, speed, precision: 5);
}
[Fact]
public void AdjustMotion_NoWeenie_StillNormalizes()
{
// weenie == null must behave like a creature (the "always creature
// unless proven otherwise" retail idiom -- weenie_obj != 0 gates
// the query at all).
var interp = MakeInterp();
uint motion = MotionCommand.WalkBackward;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
Assert.Equal(MotionCommand.WalkForward, motion);
}
}