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>
784 lines
30 KiB
C#
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);
|
|
}
|
|
}
|