feat(R3-W3): verbatim jump family — charge gates, jump_is_allowed chain, epsilon fixes; StandingLongJump misattribution retired (closes J5, J6, J7-interp-side, J16-epsilons)
JumpChargeIsAllowed 0x00527a50 (CanJump→0x49 before posture; Fallen + Crouch..Sleeping→0x48), ChargeJump 0x005281c0 — now the ONLY place standing_longjump arms (grounded Contact+OnWalkable + forward Ready + no sidestep/turn, raw @305453-305466); the S2a-flagged side effect inside contact_allows_move is DELETED (J6) with regression pins that contact checks never touch the flag (the funnel's StandingLongJump branch is local-player scope; the controller wires ChargeJump in W4/W6 — remotes never charge client-side, no interim regression). jump_is_allowed 0x005282b0 full chain replaces the 15-line approximation: IsFullyConstrained→0x47 (new PhysicsBody stub, TS-35) BEFORE the pending-head peek (A2 ordering, pinned); head peek fires whenever the queue is non-empty (no ACE Count>1 gate; nonzero jump_error_code short-circuits the whole chain — test proves JumpStaminaCost is never consulted); retail entry shape verbatim (non-creature-weenie and gravity-off skip the ground gate; null physics obj → 0x24 NOT 8 per A10); charge → MotionAllowsJump(fwd) double-check → JumpStaminaCost gate (new IWeenieObject member, always-affordable PlayerWeenie stub — TS-5 extended). GetJumpVZ/GetLeaveGroundVelocity: epsilon corrected to retail's 0.000199999995f (was 0.001 — regression-pinned at extent 0.0005); A5 shape (clamp 1.0, weenie-null 10.0f); A6 momentum fallback fires only when ALL THREE components are sub-epsilon and overwrites all three with global→local(m_velocityVector). Jump 0x00528780: InterruptCurrentMovement no-op seam (TS-36, →R4 cancel_moveto), fires unconditionally; standing_longjump cleared ONLY on failure (success keeps it — pinned). IWeenieObject.IsThePlayer() (PlayerWeenie true) lands for W4's A3 dispatch. 51 new tests + 1 re-pin; superseded pre-W3 jump tests removed. Full suite: 3,623 passed. Registers: TS-5 extended, TS-35/36/37 added. Implemented by a dedicated agent against the W0-pinned spec; arming gates + entry shape verified against the quoted raw text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
371679915e
commit
af4764443f
7 changed files with 1383 additions and 352 deletions
|
|
@ -0,0 +1,955 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MotionInterpreterJumpFamilyTests — R3-W3 (closes J5, J6, J7-interp-side,
|
||||
// J16-epsilons). Pins per docs/research/2026-07-02-r3-motioninterp/W0-pins.md
|
||||
// (A1, A2, A5, A6, A10) and r3-motioninterp-decomp.md §3a-3h.
|
||||
//
|
||||
// Source addresses tested:
|
||||
// FUN_00527a50 (0x00527a50) jump_charge_is_allowed @304935
|
||||
// FUN_005281c0 (0x005281c0) charge_jump @305448
|
||||
// FUN_00527aa0 (0x00527aa0) get_jump_v_z @304953
|
||||
// FUN_005280c0 (0x005280c0) get_leave_ground_velocity @305404
|
||||
// FUN_005282b0 (0x005282b0) jump_is_allowed (full chain)
|
||||
// FUN_00528780 (0x00528780) jump @305792
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Fake WeenieObject for jump-family test isolation.</summary>
|
||||
file sealed class FakeWeenie : IWeenieObject
|
||||
{
|
||||
public float RunRate = 1.0f;
|
||||
public float JumpVz = 10.0f;
|
||||
public bool CanJumpResult = true;
|
||||
public bool InqRunRateResult = true;
|
||||
public bool InqJumpVelocityResult = true;
|
||||
|
||||
/// <summary>Controls the JumpStaminaCost virtual (vtable +0x44 per the
|
||||
/// raw 305549-305556 shape): true = affordable (pass), false = 0x47.</summary>
|
||||
public bool JumpStaminaCostResult = true;
|
||||
public int JumpStaminaCostCalls;
|
||||
|
||||
public bool InqJumpVelocity(float extent, out float vz)
|
||||
{
|
||||
vz = JumpVz * extent;
|
||||
return InqJumpVelocityResult;
|
||||
}
|
||||
|
||||
public bool InqRunRate(out float rate)
|
||||
{
|
||||
rate = RunRate;
|
||||
return InqRunRateResult;
|
||||
}
|
||||
|
||||
public bool CanJump(float extent) => CanJumpResult;
|
||||
|
||||
public bool JumpStaminaCost(float extent, out int cost)
|
||||
{
|
||||
JumpStaminaCostCalls++;
|
||||
cost = 0;
|
||||
return JumpStaminaCostResult;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MotionInterpreterJumpFamilyTests
|
||||
{
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// jump_charge_is_allowed — 0x00527a50 @304935
|
||||
//
|
||||
// Raw (r3-motioninterp-decomp.md §3b):
|
||||
// weenie_obj = this->weenie_obj;
|
||||
// if (weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0)
|
||||
// return 0x49;
|
||||
// forward_command = this->interpreted_state.forward_command;
|
||||
// if (forward_command != 0x40000008
|
||||
// && (forward_command <= 0x41000011 || forward_command > 0x41000014))
|
||||
// return 0;
|
||||
// return 0x48;
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void JumpChargeIsAllowed_NoWeenie_ReadyForward_ReturnsNone()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||
|
||||
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpChargeIsAllowed_WeenieCanJumpFalse_ReturnsCantJumpLoadedDown()
|
||||
{
|
||||
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||
|
||||
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpChargeIsAllowed_Fallen_ReturnsYouCantJumpFromThisPosition()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Fallen; // 0x40000008
|
||||
|
||||
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MotionCommand.Crouch)] // 0x41000012
|
||||
[InlineData(MotionCommand.Sitting)] // 0x41000013
|
||||
[InlineData(MotionCommand.Sleeping)] // 0x41000014
|
||||
public void JumpChargeIsAllowed_CrouchSitSleepRange_ReturnsYouCantJumpFromThisPosition(uint forward)
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = forward;
|
||||
|
||||
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpChargeIsAllowed_CrouchLowerBoundExact_Passes()
|
||||
{
|
||||
// forward_command <= 0x41000011 passes (the gate is a strict
|
||||
// inequality on the LOWER bound: forward_command > 0x41000011 is
|
||||
// required to even consider the block). 0x41000011 == CrouchLowerBound
|
||||
// itself must PASS (not blocked) — verbatim boundary.
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.CrouchLowerBound; // 0x41000011
|
||||
|
||||
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpChargeIsAllowed_SleepUpperBoundExact_Blocked()
|
||||
{
|
||||
// forward_command > 0x41000014 passes; == 0x41000014 (Sleeping) is
|
||||
// still inside the blocked range (already covered above), but this
|
||||
// pins the boundary explicitly: 0x41000015 (one past Sleeping) must
|
||||
// PASS.
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.CrouchUpperExclusive; // 0x41000015
|
||||
|
||||
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpChargeIsAllowed_Falling_Passes()
|
||||
{
|
||||
// Falling (0x40000015) is NOT Fallen (0x40000008) — must pass this
|
||||
// gate (though jump_is_allowed's separate ground check blocks
|
||||
// mid-air jumps by a different mechanism — A1 adjudication).
|
||||
var interp = MakeInterp();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Falling;
|
||||
|
||||
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpChargeIsAllowed_CanJumpCheckedBeforePostureCheck()
|
||||
{
|
||||
// Raw order: CanJump gate FIRST, forward_command gate SECOND. A
|
||||
// weenie that refuses CanJump returns 0x49 even in a posture that
|
||||
// would otherwise pass.
|
||||
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Falling; // would pass posture gate
|
||||
|
||||
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// charge_jump — 0x005281c0 @305448 (closes J6)
|
||||
//
|
||||
// Raw (r3-motioninterp-decomp.md §3e):
|
||||
// weenie_obj = this->weenie_obj;
|
||||
// if (weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0)
|
||||
// return 0x49;
|
||||
// forward_command = this->interpreted_state.forward_command;
|
||||
// if (forward_command == 0x40000008
|
||||
// || (forward_command > 0x41000011 && forward_command <= 0x41000014))
|
||||
// return 0x48;
|
||||
// transient_state = physics_obj->transient_state;
|
||||
// if ((transient_state & 1) != 0 && (transient_state & 2) != 0
|
||||
// && forward_command == 0x41000003
|
||||
// && interpreted_state.sidestep_command == 0
|
||||
// && interpreted_state.turn_command == 0)
|
||||
// standing_longjump = 1;
|
||||
// return 0;
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ChargeJump_GroundedIdle_ArmsStandingLongJump()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = false;
|
||||
// Default interpreted state: ForwardCommand=Ready, SideStep=0, Turn=0.
|
||||
|
||||
var result = interp.ChargeJump();
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.True(interp.StandingLongJump, "grounded + idle must arm StandingLongJump");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChargeJump_GroundedButMoving_DoesNotArmStandingLongJump()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = false;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
|
||||
var result = interp.ChargeJump();
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.False(interp.StandingLongJump, "must not arm while moving forward");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChargeJump_GroundedIdleButSidestepping_DoesNotArmStandingLongJump()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = false;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||
interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight;
|
||||
|
||||
var result = interp.ChargeJump();
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.False(interp.StandingLongJump, "must not arm while sidestepping");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChargeJump_GroundedIdleButTurning_DoesNotArmStandingLongJump()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = false;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||
interp.InterpretedState.TurnCommand = MotionCommand.TurnRight;
|
||||
|
||||
var result = interp.ChargeJump();
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.False(interp.StandingLongJump, "must not arm while turning");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChargeJump_Airborne_DoesNotArmStandingLongJump()
|
||||
{
|
||||
var body = MakeAirborne();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = false;
|
||||
|
||||
var result = interp.ChargeJump();
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.False(interp.StandingLongJump, "must not arm while airborne (no Contact+OnWalkable)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChargeJump_WeenieBlocksCanJump_ReturnsCantJumpLoadedDown_NoArm()
|
||||
{
|
||||
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
interp.StandingLongJump = false;
|
||||
|
||||
var result = interp.ChargeJump();
|
||||
|
||||
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||
Assert.False(interp.StandingLongJump);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChargeJump_Fallen_ReturnsYouCantJumpFromThisPosition_NoArm()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Fallen;
|
||||
interp.StandingLongJump = false;
|
||||
|
||||
var result = interp.ChargeJump();
|
||||
|
||||
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||
Assert.False(interp.StandingLongJump);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChargeJump_CrouchRange_ReturnsYouCantJumpFromThisPosition_NoArm()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Sitting;
|
||||
interp.StandingLongJump = false;
|
||||
|
||||
var result = interp.ChargeJump();
|
||||
|
||||
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||
Assert.False(interp.StandingLongJump);
|
||||
}
|
||||
|
||||
// ── J6 regression pin: contact_allows_move no longer arms StandingLongJump ──
|
||||
|
||||
[Fact]
|
||||
public void ContactAllowsMove_GroundedAndIdle_DoesNotArmStandingLongJump()
|
||||
{
|
||||
// J6: the S2a-flagged misattribution is DELETED. Only ChargeJump
|
||||
// arms StandingLongJump now — contact_allows_move must have no side
|
||||
// effect on this flag at all, in either direction.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = false;
|
||||
|
||||
interp.contact_allows_move(MotionCommand.WalkForward);
|
||||
|
||||
Assert.False(interp.StandingLongJump,
|
||||
"contact_allows_move must never arm StandingLongJump (J6 — moved to ChargeJump exclusively)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactAllowsMove_GroundedAndIdle_DoesNotClearPreArmedStandingLongJump()
|
||||
{
|
||||
// contact_allows_move must not touch the flag at all — verify it
|
||||
// doesn't clear a flag armed by a prior ChargeJump call either.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = true;
|
||||
|
||||
interp.contact_allows_move(MotionCommand.WalkForward);
|
||||
|
||||
Assert.True(interp.StandingLongJump,
|
||||
"contact_allows_move must not clear an externally-armed StandingLongJump flag");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// get_jump_v_z — 0x00527aa0 @304953 (A5; closes J16-epsilons)
|
||||
// Epsilon: 0.000199999995f (NOT the old 0.001 JumpExtentEpsilon).
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVZ_EpsilonIsRetailLiteral()
|
||||
{
|
||||
Assert.Equal(0.000199999995f, MotionInterpreter.JumpVzEpsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVZ_JustBelowEpsilon_ReturnsZero()
|
||||
{
|
||||
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.JumpExtent = 0.0001f; // < 0.000199999995f
|
||||
|
||||
Assert.Equal(0f, interp.GetJumpVZ(), precision: 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVZ_JustAboveEpsilon_DelegatesToWeenie()
|
||||
{
|
||||
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.JumpExtent = 0.0003f; // > 0.000199999995f
|
||||
|
||||
float vz = interp.GetJumpVZ();
|
||||
|
||||
// Not the zero-fallback: delegates to InqJumpVelocity(extent, ...).
|
||||
Assert.Equal(weenie.JumpVz * 0.0003f, vz, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVZ_ExactlyOldWrongEpsilon_0_001_IsAboveRetailEpsilon_NotZero()
|
||||
{
|
||||
// Regression pin: the OLD (wrong) epsilon was 0.001. At extent =
|
||||
// 0.0005 the old code would have returned 0 (0.0005 < 0.001), but
|
||||
// retail's epsilon (0.0002) means 0.0005 must NOT hit the zero path.
|
||||
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.JumpExtent = 0.0005f;
|
||||
|
||||
float vz = interp.GetJumpVZ();
|
||||
|
||||
Assert.NotEqual(0f, vz);
|
||||
Assert.Equal(weenie.JumpVz * 0.0005f, vz, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVZ_NoWeenie_ReturnsDefault()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.JumpExtent = 0.5f;
|
||||
|
||||
Assert.Equal(MotionInterpreter.DefaultJumpVz, interp.GetJumpVZ(), precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVZ_ExtentClampsAtMax1()
|
||||
{
|
||||
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.JumpExtent = 5.0f; // over-clamped
|
||||
|
||||
float vz = interp.GetJumpVZ();
|
||||
|
||||
Assert.Equal(10.0f, vz, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVZ_WeenieRefusesInqJumpVelocity_ReturnsZero()
|
||||
{
|
||||
var weenie = new FakeWeenie { InqJumpVelocityResult = false };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.JumpExtent = 0.5f;
|
||||
|
||||
Assert.Equal(0f, interp.GetJumpVZ(), precision: 5);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// get_leave_ground_velocity — 0x005280c0 @305404 (A6; closes J16-epsilons)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GetLeaveGroundVelocity_WalkingForward_HasPositiveYAndZ()
|
||||
{
|
||||
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||
var body = MakeGrounded();
|
||||
var interp = new MotionInterpreter(body, weenie)
|
||||
{
|
||||
JumpExtent = 1.0f,
|
||||
};
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
var vel = interp.GetLeaveGroundVelocity();
|
||||
|
||||
Assert.True(vel.Y > 0f, "Y velocity should be positive when walking forward");
|
||||
Assert.True(vel.Z > 0f, "Z (jump) velocity should be positive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLeaveGroundVelocity_TrulyZero_FallsBackToWorldVelocityGlobalToLocal()
|
||||
{
|
||||
// A6: fallback fires ONLY when |x| AND |y| AND |z| are ALL <
|
||||
// epsilon. When it fires, ALL THREE components (including the
|
||||
// just-computed jump v_z) are overwritten with
|
||||
// globaltolocal(m_velocityVector) — the existing
|
||||
// Quaternion.Inverse(Orientation) transform IS global->local; keep.
|
||||
var body = MakeGrounded();
|
||||
body.Velocity = new Vector3(2.0f, 3.0f, 0f); // nonzero WORLD velocity (momentum)
|
||||
body.Orientation = Quaternion.Identity; // identity: local == global for this pin
|
||||
var interp = new MotionInterpreter(body)
|
||||
{
|
||||
JumpExtent = 0f, // below epsilon -> get_jump_v_z returns 0
|
||||
};
|
||||
// Default interpreted state = Ready (no walk/run/sidestep command) -> XY also 0.
|
||||
|
||||
var vel = interp.GetLeaveGroundVelocity();
|
||||
|
||||
// Fallback must have fired: velocity now reflects body.Velocity
|
||||
// (global->local via inverse-identity == identity here), NOT the
|
||||
// all-zero get_state_velocity()/get_jump_v_z() composition.
|
||||
Assert.Equal(2.0f, vel.X, precision: 4);
|
||||
Assert.Equal(3.0f, vel.Y, precision: 4);
|
||||
Assert.Equal(0.0f, vel.Z, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLeaveGroundVelocity_TrulyZero_NoWorldVelocity_StaysZero()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
body.Velocity = Vector3.Zero;
|
||||
var interp = new MotionInterpreter(body)
|
||||
{
|
||||
JumpExtent = 0f,
|
||||
};
|
||||
|
||||
var vel = interp.GetLeaveGroundVelocity();
|
||||
|
||||
Assert.Equal(0f, vel.X, precision: 5);
|
||||
Assert.Equal(0f, vel.Y, precision: 5);
|
||||
Assert.Equal(0f, vel.Z, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLeaveGroundVelocity_NonzeroXOnly_DoesNotTriggerFallback()
|
||||
{
|
||||
// A6: the fallback requires ALL THREE axes below epsilon. A
|
||||
// sidestep-only launch (nonzero X, zero Y, zero jump Z) must NOT
|
||||
// trigger the momentum fallback — it should keep the computed X.
|
||||
var body = MakeGrounded();
|
||||
body.Velocity = new Vector3(99f, 99f, 99f); // if fallback wrongly fired, this would show
|
||||
var interp = new MotionInterpreter(body)
|
||||
{
|
||||
JumpExtent = 0f, // jump v_z stays 0
|
||||
};
|
||||
interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight;
|
||||
interp.InterpretedState.SideStepSpeed = 1.0f;
|
||||
|
||||
var vel = interp.GetLeaveGroundVelocity();
|
||||
|
||||
// X must be the sidestep-derived value, not 99 (fallback did not fire).
|
||||
Assert.NotEqual(99f, vel.X);
|
||||
Assert.True(vel.X > 0f, "sidestep should have produced nonzero X from get_state_velocity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLeaveGroundVelocity_EpsilonBoundary_JustBelow_TriggersFallback()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
body.Velocity = new Vector3(5f, 0f, 0f);
|
||||
body.Orientation = Quaternion.Identity;
|
||||
var interp = new MotionInterpreter(body)
|
||||
{
|
||||
JumpExtent = 0.0001f, // < 0.000199999995f -> get_jump_v_z() == 0
|
||||
};
|
||||
// Default interpreted state -> XY == 0 too. All three axes are
|
||||
// exactly at the "essentially zero" edge -> fallback fires.
|
||||
|
||||
var vel = interp.GetLeaveGroundVelocity();
|
||||
|
||||
Assert.Equal(5f, vel.X, precision: 4);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// jump_is_allowed — 0x005282b0 (A2, A10; closes J5)
|
||||
//
|
||||
// Raw entry shape (r3-motioninterp-decomp.md §3h):
|
||||
// if (physics_obj != 0) {
|
||||
// if (weenie != 0) eax_2 = weenie->IsCreature();
|
||||
// if (weenie != 0 && eax_2 == 0) // non-creature weenie
|
||||
// goto shared_gate;
|
||||
// if (physics_obj == 0 || (state bit 0x400) == 0) // gravity-state off
|
||||
// goto shared_gate;
|
||||
// if (Contact && OnWalkable) // grounded
|
||||
// goto shared_gate;
|
||||
// }
|
||||
// return 0x24;
|
||||
//
|
||||
// shared_gate:
|
||||
// if (IsFullyConstrained) return 0x47;
|
||||
// head = pending_motions.head_;
|
||||
// if (head != 0) eax_6 = head.jump_error_code;
|
||||
// if (head == 0 || eax_6 == 0) {
|
||||
// eax_6 = jump_charge_is_allowed();
|
||||
// if (eax_6 == 0) {
|
||||
// eax_7 = motion_allows_jump(interpreted_state.forward_command);
|
||||
// if (eax_7 != 0) return eax_7;
|
||||
// if (weenie_obj_1 == 0) return eax_7; // == 0 here
|
||||
// eax_6 = 0x47;
|
||||
// if (weenie_obj_1->JumpStaminaCost(extent, &cost) != 0)
|
||||
// return eax_7; // == 0 (success)
|
||||
// // JumpStaminaCost returned 0 (false) -> falls through, eax_6 stays 0x47
|
||||
// }
|
||||
// }
|
||||
// return eax_6;
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_NullPhysicsObj_ReturnsNotGrounded()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.NotGrounded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_CreatureWeenie_Airborne_GravityState_ReturnsNotGrounded()
|
||||
{
|
||||
var body = MakeAirborne();
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.NotGrounded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_CreatureWeenie_Grounded_ReturnsNone()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out int cost);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(0, cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_NonCreatureWeenie_Airborne_SkipsGroundGate_ReturnsNone()
|
||||
{
|
||||
// weenie present && !IsCreature() -> skip the ground check entirely,
|
||||
// go straight to the shared gate. Airborne here must NOT block.
|
||||
var weenie = new FakeWeenie();
|
||||
var body = MakeAirborne();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
// Non-creature: use an explicit fake that overrides IsCreature.
|
||||
var nonCreature = new NonCreatureFakeWeenie();
|
||||
interp.WeenieObj = nonCreature;
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_NoWeenie_GravityStateOff_SkipsGroundGate_ReturnsNone()
|
||||
{
|
||||
// "no weenie" -> weenie_obj == 0 branch is fine either way (the
|
||||
// `weenie != 0 && eax_2 == 0` non-creature-skip only applies when a
|
||||
// weenie exists); but the SEPARATE gravity-state-off skip
|
||||
// (state bit 0x400 clear) also reaches the shared gate regardless
|
||||
// of weenie presence. Pin: no gravity flag -> shared gate, not
|
||||
// blocked outright.
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
State = PhysicsStateFlags.None, // gravity flag clear
|
||||
TransientState = TransientStateFlags.None,
|
||||
};
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_IsFullyConstrained_ReturnsGeneralMovementFailure()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
body.IsFullyConstrained = true;
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_IsFullyConstrained_BeatsPendingHeadPeek()
|
||||
{
|
||||
// A2 ordering: IsFullyConstrained is checked BEFORE the pending-head
|
||||
// peek. Even if the head carries a DIFFERENT nonzero error, the
|
||||
// IsFullyConstrained code (0x47) must win.
|
||||
var body = MakeGrounded();
|
||||
body.IsFullyConstrained = true;
|
||||
var interp = MakeInterp(body);
|
||||
interp.AddToQueue(0, MotionCommand.WalkForward, (uint)WeenieError.CantJumpLoadedDown);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.GeneralMovementFailure, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_PendingHeadNonzeroError_ShortCircuitsChain()
|
||||
{
|
||||
// A2: the peek fires WHENEVER the queue is non-empty (no Count>1
|
||||
// gate). A nonzero head.JumpErrorCode is returned VERBATIM, and the
|
||||
// charge/motion/stamina chain is skipped entirely (proven via the
|
||||
// weenie's CanJump/JumpStaminaCost never being consulted for THIS
|
||||
// code path — CanJump would return CantJumpLoadedDown some other
|
||||
// way, so instead assert JumpStaminaCost, which only the fallthrough
|
||||
// chain calls, is never invoked).
|
||||
var weenie = new FakeWeenie();
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
interp.AddToQueue(0, MotionCommand.WalkForward, (uint)WeenieError.YouCantJumpFromThisPosition);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||
Assert.Equal(0, weenie.JumpStaminaCostCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_PendingHeadZeroError_FallsThroughToChain()
|
||||
{
|
||||
// A2: head exists but JumpErrorCode == 0 -> chain still runs.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.AddToQueue(0, MotionCommand.WalkForward, jumpErrorCode: 0);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_EmptyQueue_FallsThroughToChain()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
// No AddToQueue call -> head is null -> chain runs normally.
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_ChargeBlocked_Fallen_ReturnsYouCantJumpFromThisPosition()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Fallen;
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_ChargeBlocked_WeenieCanJumpFalse_ReturnsCantJumpLoadedDown()
|
||||
{
|
||||
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_MotionAllowsJumpBlocks_ReturnsYouCantJumpFromThisPosition()
|
||||
{
|
||||
// jump_charge_is_allowed passes (Ready forward command), but the
|
||||
// SEPARATE motion_allows_jump(forward_command) check blocks — use a
|
||||
// forward command in the [0x4000001e, 0x40000039] blocklist band
|
||||
// that jump_charge_is_allowed does NOT gate on (only Fallen/Crouch
|
||||
// range does), so this pins the double-check shape.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.InterpretedState.ForwardCommand = 0x40000020u; // inside [0x4000001e, 0x40000039]
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_NoWeenie_PassesChain_ReturnsNone()
|
||||
{
|
||||
// motion_allows_jump passes and weenie_obj == null -> return eax_7
|
||||
// (== 0) directly, skipping the JumpStaminaCost consult entirely.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body); // no weenie
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out int cost);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(0, cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_WeenieJumpStaminaCostRefuses_ReturnsGeneralMovementFailure()
|
||||
{
|
||||
var weenie = new FakeWeenie { JumpStaminaCostResult = false };
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47
|
||||
Assert.Equal(1, weenie.JumpStaminaCostCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_WeenieJumpStaminaCostAffords_ReturnsNone()
|
||||
{
|
||||
var weenie = new FakeWeenie { JumpStaminaCostResult = true };
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, out _);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(1, weenie.JumpStaminaCostCalls);
|
||||
}
|
||||
|
||||
/// <summary>Fake weenie whose IsCreature() returns false, for the
|
||||
/// non-creature ground-gate-skip pin.</summary>
|
||||
private sealed class NonCreatureFakeWeenie : IWeenieObject
|
||||
{
|
||||
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 IsCreature() => false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// jump — 0x00528780 @305792 (closes J7-interp-side)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Jump_Grounded_SetsJumpExtentAndLeavesWalkable()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
var result = interp.jump(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(0.5f, interp.JumpExtent, precision: 5);
|
||||
Assert.False(body.OnWalkable, "Body should no longer be on walkable after jump");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jump_Airborne_ReturnsNotGrounded()
|
||||
{
|
||||
var body = MakeAirborne();
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
var result = interp.jump(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.NotGrounded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jump_WeenieBlocksJump_ClearsStandingLongJump()
|
||||
{
|
||||
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
interp.StandingLongJump = true;
|
||||
|
||||
var result = interp.jump(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||
Assert.False(interp.StandingLongJump, "a failed jump attempt cancels any pending StandingLongJump");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jump_NullPhysicsObj_ReturnsNoPhysicsObject()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
|
||||
var result = interp.jump(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jump_Success_DoesNotClearStandingLongJump()
|
||||
{
|
||||
// Only the FAILURE path clears StandingLongJump (raw 305800:
|
||||
// `this->standing_longjump = 0;` sits inside the `if (result != 0)`
|
||||
// branch only). A successful jump leaves whatever value the flag
|
||||
// already had.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = true;
|
||||
|
||||
var result = interp.jump(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.True(interp.StandingLongJump, "success path must not touch StandingLongJump");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jump_CallsInterruptCurrentMovementSeam()
|
||||
{
|
||||
// J7 / "Adjacent findings": jump() ALWAYS calls
|
||||
// interrupt_current_movement first (raw @305800), regardless of
|
||||
// outcome. Register no-op seam until R4's cancel_moveto exists.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
bool called = false;
|
||||
interp.InterruptCurrentMovement = () => called = true;
|
||||
|
||||
interp.jump(0.5f);
|
||||
|
||||
Assert.True(called, "jump() must invoke the InterruptCurrentMovement seam");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jump_CallsInterruptCurrentMovementSeam_EvenOnFailure()
|
||||
{
|
||||
var body = MakeAirborne();
|
||||
var interp = MakeInterp(body);
|
||||
bool called = false;
|
||||
interp.InterruptCurrentMovement = () => called = true;
|
||||
|
||||
interp.jump(0.5f);
|
||||
|
||||
Assert.True(called, "jump() calls interrupt_current_movement before jump_is_allowed, unconditionally");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IWeenieObject.IsThePlayer — default false; PlayerWeenie true (for W4's
|
||||
// A3 dispatch gate; no consumer yet in W3).
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void IWeenieObject_IsThePlayer_DefaultsFalse()
|
||||
{
|
||||
// Default interface members are only reachable through the
|
||||
// interface type, not the concrete implementer — call via
|
||||
// IWeenieObject to exercise the DIM (not a compile error site).
|
||||
IWeenieObject weenie = new FakeWeenie();
|
||||
|
||||
Assert.False(weenie.IsThePlayer());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayerWeenie_IsThePlayer_ReturnsTrue()
|
||||
{
|
||||
var weenie = new PlayerWeenie();
|
||||
|
||||
Assert.True(weenie.IsThePlayer());
|
||||
}
|
||||
}
|
||||
|
|
@ -423,220 +423,20 @@ public sealed class MotionInterpreterTests
|
|||
}
|
||||
|
||||
// =========================================================================
|
||||
// jump (FUN_00529390)
|
||||
// jump (FUN_00529390) / get_jump_v_z (FUN_005286b0) /
|
||||
// get_leave_ground_velocity (FUN_00528cd0) / jump_is_allowed (FUN_00528ec0)
|
||||
//
|
||||
// R3-W3 (closes J5, J6, J7-interp-side, J16-epsilons): the full jump
|
||||
// family (jump/GetJumpVZ/GetLeaveGroundVelocity/jump_is_allowed) plus
|
||||
// jump_charge_is_allowed/ChargeJump now live in
|
||||
// MotionInterpreterJumpFamilyTests.cs, ported against the verbatim
|
||||
// decomp (r3-motioninterp-decomp.md §3a-3h) and W0-pins.md (A2/A5/A6/A10)
|
||||
// instead of this file's pre-W3 approximation-era pins (wrong 0.001
|
||||
// epsilon, jump_is_allowed(extent, int) shape, no IsFullyConstrained /
|
||||
// pending-head-peek / stamina chain). See that file for the full gate
|
||||
// tables and epsilon-boundary pins.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Jump_Grounded_SetsJumpExtentAndLeavesWalkable()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
var result = interp.jump(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
Assert.Equal(0.5f, interp.JumpExtent, precision: 5);
|
||||
Assert.False(body.OnWalkable, "Body should no longer be on walkable after jump");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jump_Airborne_ReturnsNotGrounded()
|
||||
{
|
||||
// R3-W1 renumber (A10): airborne is 0x24 (NotGrounded), not 0x48
|
||||
// (0x48 is now reserved for the motion_allows_jump blocklist).
|
||||
var body = MakeAirborne();
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
var result = interp.jump(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.NotGrounded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jump_WeenieBlocksJump_ClearStandingLongJump()
|
||||
{
|
||||
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
interp.StandingLongJump = true;
|
||||
|
||||
var result = interp.jump(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||
Assert.False(interp.StandingLongJump);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jump_NullPhysicsObj_ReturnsNoPhysicsObject()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
|
||||
var result = interp.jump(0.5f);
|
||||
|
||||
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// get_jump_v_z (FUN_005286b0)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVz_ZeroExtent_ReturnsZero()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.JumpExtent = 0f;
|
||||
|
||||
Assert.Equal(0f, interp.get_jump_v_z(), precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVz_NoWeenie_ReturnsDefault()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
interp.JumpExtent = 0.5f;
|
||||
|
||||
Assert.Equal(MotionInterpreter.DefaultJumpVz, interp.get_jump_v_z(), precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVz_WithWeenie_DelegatesToInqJumpVelocity()
|
||||
{
|
||||
var weenie = new FakeWeenie { JumpVz = 8.0f };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.JumpExtent = 1.0f;
|
||||
|
||||
float vz = interp.get_jump_v_z();
|
||||
|
||||
// FakeWeenie returns JumpVz * extent = 8.0 * 1.0 = 8.0
|
||||
Assert.Equal(8.0f, vz, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVz_ExtentClampsAt1()
|
||||
{
|
||||
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.JumpExtent = 5.0f; // over-clamped
|
||||
|
||||
float vz = interp.get_jump_v_z();
|
||||
|
||||
// Should clamp extent to 1.0: FakeWeenie returns 10.0 * 1.0 = 10.0
|
||||
Assert.Equal(10.0f, vz, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetJumpVz_WeenieReturnsFailure_ReturnsZero()
|
||||
{
|
||||
var weenie = new FakeWeenie { InqJumpVelocityResult = false };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
interp.JumpExtent = 0.5f;
|
||||
|
||||
Assert.Equal(0f, interp.get_jump_v_z(), precision: 5);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// get_leave_ground_velocity (FUN_00528cd0)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GetLeaveGroundVelocity_WalkingForward_HasPositiveYAndZ()
|
||||
{
|
||||
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||
var body = MakeGrounded();
|
||||
var interp = new MotionInterpreter(body, weenie)
|
||||
{
|
||||
JumpExtent = 1.0f,
|
||||
};
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
var vel = interp.get_leave_ground_velocity();
|
||||
|
||||
Assert.True(vel.Y > 0f, "Y velocity should be positive when walking forward");
|
||||
Assert.True(vel.Z > 0f, "Z (jump) velocity should be positive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLeaveGroundVelocity_Standing_ZeroExtent_ReturnsZeroXY()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = new MotionInterpreter(body)
|
||||
{
|
||||
JumpExtent = 0f, // below epsilon
|
||||
};
|
||||
// Default interpreted state = Ready (no walk/run command)
|
||||
|
||||
var vel = interp.get_leave_ground_velocity();
|
||||
|
||||
Assert.Equal(0f, vel.X, precision: 5);
|
||||
Assert.Equal(0f, vel.Y, precision: 5);
|
||||
Assert.Equal(0f, vel.Z, precision: 5);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// jump_is_allowed (FUN_00528ec0)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_Grounded_ReturnsNone()
|
||||
{
|
||||
var interp = MakeInterp(MakeGrounded());
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, 0);
|
||||
|
||||
Assert.Equal(WeenieError.None, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_Airborne_ReturnsNotGrounded()
|
||||
{
|
||||
var interp = MakeInterp(MakeAirborne());
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, 0);
|
||||
|
||||
Assert.Equal(WeenieError.NotGrounded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_WeenieBlocks_ReturnsCantJumpLoadedDown()
|
||||
{
|
||||
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||
var interp = MakeInterp(MakeGrounded(), weenie);
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, 0);
|
||||
|
||||
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_NoGravityFlag_ReturnsNotGrounded()
|
||||
{
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
State = PhysicsStateFlags.None, // no gravity
|
||||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||||
};
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
// No gravity → must be airborne-style
|
||||
var result = interp.jump_is_allowed(0.5f, 0);
|
||||
|
||||
Assert.Equal(WeenieError.NotGrounded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpIsAllowed_NullPhysicsObj_ReturnsNotGrounded()
|
||||
{
|
||||
// A10 note: jump_is_allowed returns 0x24 (NotGrounded), NOT 8, when
|
||||
// physics_obj == null — the "8 = no physics obj" convention that
|
||||
// holds everywhere else in CMotionInterp does not hold here.
|
||||
var interp = new MotionInterpreter();
|
||||
|
||||
var result = interp.jump_is_allowed(0.5f, 0);
|
||||
|
||||
Assert.Equal(WeenieError.NotGrounded, result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// contact_allows_move (FUN_00528dd0)
|
||||
// =========================================================================
|
||||
|
|
@ -741,8 +541,15 @@ public sealed class MotionInterpreterTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactAllowsMove_GroundedAndIdle_SetsStandingLongJump()
|
||||
public void ContactAllowsMove_GroundedAndIdle_DoesNotSetStandingLongJump()
|
||||
{
|
||||
// R3-W3 (closes J6): the S2a-flagged misattribution is DELETED.
|
||||
// Retail arms StandingLongJump ONLY in charge_jump (0x005281c0) —
|
||||
// contact_allows_move (0x00528240) never reads or writes it. See
|
||||
// MotionInterpreterJumpFamilyTests.ContactAllowsMove_GroundedAndIdle_DoesNotArmStandingLongJump
|
||||
// for the full regression pin (both arm and no-clear directions)
|
||||
// and ChargeJump_GroundedIdle_ArmsStandingLongJump for where the
|
||||
// arming now actually happens.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
// All interpreted commands at default (Ready, no sidestep, no turn)
|
||||
|
|
@ -750,7 +557,7 @@ public sealed class MotionInterpreterTests
|
|||
|
||||
interp.contact_allows_move(MotionCommand.WalkForward);
|
||||
|
||||
Assert.True(interp.StandingLongJump, "Should set StandingLongJump when grounded and idle");
|
||||
Assert.False(interp.StandingLongJump, "contact_allows_move must never touch StandingLongJump (J6)");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue