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:
Erik 2026-07-02 22:37:07 +02:00
parent 371679915e
commit af4764443f
7 changed files with 1383 additions and 352 deletions

View file

@ -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)");
}
// =========================================================================