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>
955 lines
34 KiB
C#
955 lines
34 KiB
C#
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());
|
|
}
|
|
}
|