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

@ -988,7 +988,7 @@ public sealed class PlayerMovementController
// ── 3. Jump (charged) ─────────────────────────────────────────────────
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
// Release to execute: jump(extent) validates + sets JumpExtent,
// then LeaveGround() applies the scaled velocity via get_leave_ground_velocity.
// then LeaveGround() applies the scaled velocity via GetLeaveGroundVelocity.
float? outJumpExtent = null;
Vector3? outJumpVelocity = null;
@ -1010,9 +1010,9 @@ public sealed class PlayerMovementController
{
// Capture jump_v_z BEFORE LeaveGround() — that call resets
// JumpExtent back to 0 (faithful to retail's FUN_00529710),
// after which get_jump_v_z() returns 0 because the extent
// after which GetJumpVZ() returns 0 because the extent
// gate at the top of the function fires.
float jumpVz = _motion.get_jump_v_z();
float jumpVz = _motion.GetJumpVZ();
_motion.LeaveGround();
outJumpExtent = _jumpExtent;
// D6.2: get_state_velocity() is now correct for all directions

View file

@ -15,11 +15,15 @@ namespace AcDream.Core.Physics;
// FUN_00528960 get_state_velocity — compute world-space velocity for current motion
// FUN_00529210 apply_current_movement — apply interpreted motion as velocity
// FUN_00529390 jump — initiate jump: validate, record extent, leave ground
// FUN_005286b0 get_jump_v_z — get vertical jump velocity
// FUN_00528cd0 get_leave_ground_velocity — compose full 3D launch vector
// FUN_00528ec0 jump_is_allowed — can we jump?
// FUN_005286b0 GetJumpVZ — get vertical jump velocity (R3-W3 rename)
// FUN_00528cd0 GetLeaveGroundVelocity — compose full 3D launch vector (R3-W3 rename)
// FUN_00528ec0 jump_is_allowed — can we jump? (R3-W3: full verbatim chain)
// FUN_00528dd0 contact_allows_move — slope angle / contact state check
//
// R3-W3 jump-family addresses (docs/research/2026-07-02-r3-motioninterp/):
// 0x00527a50 JumpChargeIsAllowed
// 0x005281c0 ChargeJump — the ONLY place StandingLongJump arms (closes J6)
//
// Cross-checked against ACE MotionInterp.cs.
// ─────────────────────────────────────────────────────────────────────────────
@ -441,6 +445,36 @@ public interface IWeenieObject
/// retail-correct (register TS-34 tracks the adjust_motion side).
/// </summary>
bool IsCreature() => true;
/// <summary>
/// Retail <c>ACCWeenieObject::IsThePlayer</c> (vtable +0x14,
/// 0x0058C3D0): <c>this-&gt;id == SmartBox::smartbox-&gt;player_id</c>.
/// PINNED (W0-pins.md A3, adversarially verified) as the dual-dispatch
/// gate for <c>apply_current_movement</c>/<c>ReportExhaustion</c>/
/// <c>SetWeenieObject</c>/<c>SetPhysicsObject</c> — NOT <c>IsCreature</c>
/// (a remote player is a creature but not the player; ACE's server-side
/// <c>IsCreature</c> gate is a genuine divergence, not a reading to
/// copy). Default false; only the local player's weenie returns true.
/// No consumer in R3-W3 — the W4 dual-dispatch port is the first reader.
/// </summary>
bool IsThePlayer() => false;
/// <summary>
/// Retail <c>CWeenieObject::JumpStaminaCost</c> (vtable +0x44, referenced
/// from <c>jump_is_allowed</c> raw 305549-305556): given the charge
/// <paramref name="extent"/>, returns whether the weenie can afford the
/// stamina cost of this jump and writes the cost to
/// <paramref name="cost"/>. <c>jump_is_allowed</c> treats a <c>false</c>
/// return as <see cref="WeenieError.GeneralMovementFailure"/> (0x47).
/// Default true / cost 0 — real stamina gating stays TS-5-deferred (the
/// same register row that already covers <c>CanJump</c> always-true;
/// this extends it to <c>JumpStaminaCost</c> until stat plumbing lands).
/// </summary>
bool JumpStaminaCost(float extent, out int cost)
{
cost = 0;
return true;
}
}
// ── MotionInterpreter ─────────────────────────────────────────────────────────
@ -475,8 +509,16 @@ public sealed class MotionInterpreter : IMotionDoneSink
public const float RunAnimSpeed = 4.0f;
/// <summary>Sidestep animation base speed (_DAT_007c96e8 family).</summary>
public const float SidestepAnimSpeed = 1.25f;
/// <summary>Minimum jump extent before get_jump_v_z bothers computing (_DAT_007c9734).</summary>
public const float JumpExtentEpsilon = 0.001f;
/// <summary>
/// Minimum jump extent before get_jump_v_z bothers computing
/// (_DAT_007c9734). R3-W3 (closes J16-epsilons, A5/A6): retail's exact
/// literal is <c>0.000199999995f</c> (raw @304959:
/// <c>((long double)0.000199999995f)</c>), NOT the previous <c>0.001</c>
/// approximation. Used by both <see cref="GetJumpVZ"/> and
/// <see cref="GetLeaveGroundVelocity"/> (three times there, one per
/// axis — A6).
/// </summary>
public const float JumpVzEpsilon = 0.000199999995f;
/// <summary>Fallback vertical jump velocity when WeenieObj is absent (_DAT_0079c6d4).</summary>
public const float DefaultJumpVz = 10.0f;
/// <summary>Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f).</summary>
@ -585,6 +627,18 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// </summary>
public Action? UnstickFromObject { get; set; }
/// <summary>
/// R3-W3 no-op seam standing in for retail <c>CPhysicsObj::interrupt_current_movement</c>
/// (called unconditionally at the top of <see cref="jump"/>, raw @305800,
/// and by <see cref="StopCompletely"/> per A9). Register row: retail
/// cancels any in-flight MoveToManager transition before starting a new
/// movement action; R4 wires the real <c>cancel_moveto</c> once
/// MoveToManager exists. Until then this is an optional callback the App
/// layer may bind, matching the <see cref="UnstickFromObject"/> seam
/// convention.
/// </summary>
public Action? InterruptCurrentMovement { get; set; }
/// <summary>
/// Optional accessor for the owning entity's current animation cycle
/// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
@ -1170,28 +1224,158 @@ public sealed class MotionInterpreter : IMotionDoneSink
}
}
// ── FUN_00529390 — jump ───────────────────────────────────────────────────
// ── FUN_00527a50 — jump_charge_is_allowed ─────────────────────────────────
/// <summary>
/// <c>CMotionInterp::jump_charge_is_allowed</c> (0x00527a50, decomp §3b
/// @304935, W0-pins.md A1 polarity). Gate consulted while the jump
/// charge is accumulating (spacebar held) — NOT the same gate as
/// <see cref="jump_is_allowed"/>'s ground check.
///
/// <para>
/// Verbatim (raw 304940-304948):
/// <code>
/// weenie_obj = this-&gt;weenie_obj;
/// if (weenie_obj != 0 &amp;&amp; weenie_obj.CanJump(this-&gt;jump_extent) == 0)
/// return 0x49;
/// forward_command = this-&gt;interpreted_state.forward_command;
/// if (forward_command != 0x40000008
/// &amp;&amp; (forward_command &lt;= 0x41000011 || forward_command &gt; 0x41000014))
/// return 0;
/// return 0x48;
/// </code>
/// </para>
/// </summary>
public WeenieError JumpChargeIsAllowed(float extent)
{
if (WeenieObj is not null && !WeenieObj.CanJump(extent))
return WeenieError.CantJumpLoadedDown; // 0x49
uint forward = InterpretedState.ForwardCommand;
if (forward != MotionCommand.Fallen
&& (forward <= MotionCommand.CrouchLowerBound || forward > MotionCommand.Sleeping))
return WeenieError.None;
return WeenieError.YouCantJumpFromThisPosition; // 0x48
}
// ── FUN_005281c0 — charge_jump ────────────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::charge_jump</c> (0x005281c0, decomp §3e @305448,
/// W0-pins.md A1 polarity). R3-W3 (closes J6): the ONLY place retail
/// arms <see cref="StandingLongJump"/> — the old
/// <c>contact_allows_move</c> side effect (:1139-1148 pre-W3 numbering)
/// is DELETED (see <see cref="contact_allows_move"/>'s doc comment).
///
/// <para>
/// Verbatim (raw 305453-305466):
/// <code>
/// weenie_obj = this-&gt;weenie_obj;
/// if (weenie_obj != 0 &amp;&amp; weenie_obj.CanJump(this-&gt;jump_extent) == 0)
/// return 0x49;
/// forward_command = this-&gt;interpreted_state.forward_command;
/// if (forward_command == 0x40000008
/// || (forward_command &gt; 0x41000011 &amp;&amp; forward_command &lt;= 0x41000014))
/// return 0x48;
/// transient_state = physics_obj-&gt;transient_state;
/// if ((transient_state &amp; 1) != 0 &amp;&amp; (transient_state &amp; 2) != 0
/// &amp;&amp; forward_command == 0x41000003
/// &amp;&amp; interpreted_state.sidestep_command == 0
/// &amp;&amp; interpreted_state.turn_command == 0)
/// this-&gt;standing_longjump = 1;
/// return 0;
/// </code>
/// </para>
///
/// <para>
/// Note the inverted-but-equivalent posture test vs
/// <see cref="JumpChargeIsAllowed"/>: that function's PASS condition is
/// <c>forward &lt;= 0x41000011 || forward &gt; 0x41000014</c>; this
/// function's BLOCK condition is <c>forward == 0x40000008 ||
/// (forward &gt; 0x41000011 &amp;&amp; forward &lt;= 0x41000014)</c> — the
/// same Fallen-exact / Crouch..Sleeping-range predicate, just phrased as
/// its own negation plus the explicit Fallen exact-match up front.
/// Ported literally rather than reusing <see cref="JumpChargeIsAllowed"/>
/// to keep each function's control flow traceable to its own raw
/// address.
/// </para>
///
/// <para>
/// Caller (outside CMotionInterp, raw line 376144, 0x0056afac — the
/// player-input/SmartBox layer, out of R3 scope): drives the charge
/// accumulation loop while spacebar is held. acdream's charge
/// accumulation stays controller-side (AP-24 register row survives);
/// <c>PlayerMovementController</c> may call this once wired (W4/W6 — no
/// regression today since nothing calls <c>ChargeJump</c> yet, so
/// remotes/local both continue unaffected).
/// </para>
/// </summary>
public WeenieError ChargeJump()
{
if (WeenieObj is not null && !WeenieObj.CanJump(JumpExtent))
return WeenieError.CantJumpLoadedDown; // 0x49
uint forward = InterpretedState.ForwardCommand;
if (forward == MotionCommand.Fallen
|| (forward > MotionCommand.CrouchLowerBound && forward <= MotionCommand.Sleeping))
return WeenieError.YouCantJumpFromThisPosition; // 0x48
if (PhysicsObj is not null)
{
bool onGround = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (onGround
&& forward == MotionCommand.Ready
&& InterpretedState.SideStepCommand == 0
&& InterpretedState.TurnCommand == 0)
{
StandingLongJump = true;
}
}
return WeenieError.None;
}
// ── FUN_00528780 — jump ───────────────────────────────────────────────────
/// <summary>
/// Initiate a jump: validate, store jump extent, leave the ground.
///
/// Decompiled logic (FUN_00529390):
/// if (PhysicsObj == null) return 8
/// FUN_00510cc0() — cancel moveto
/// iVar1 = FUN_00528ec0(extent, stamina) ← jump_is_allowed
/// if (iVar1 == 0):
/// *(+0x74) = extent ← JumpExtent
/// FUN_00511de0(0) ← PhysicsObj.set_on_walkable(false)
/// return 0
/// *(+0x70) = 0 ← StandingLongJump = false
/// return iVar1
/// <para>
/// Verbatim (0x00528780, decomp §3f @305792):
/// <code>
/// physics_obj = this-&gt;physics_obj;
/// if (physics_obj == 0) return 8;
/// interrupt_current_movement(physics_obj);
/// result = jump_is_allowed(this, arg2, arg3);
/// if (result != 0) {
/// this-&gt;standing_longjump = 0;
/// return result;
/// }
/// this-&gt;jump_extent = arg2;
/// set_on_walkable(physics_obj, 0);
/// return result; // == 0
/// </code>
/// </para>
///
/// <para>
/// R3-W3 (closes J7-interp-side): <c>interrupt_current_movement</c> is
/// now called UNCONDITIONALLY before <see cref="jump_is_allowed"/>, via
/// the <see cref="InterruptCurrentMovement"/> no-op seam (register row —
/// R4 wires the real cancel_moveto). <see cref="StandingLongJump"/> is
/// cleared ONLY on the failure path — a successful jump leaves it
/// untouched (the caller/LeaveGround owns clearing it on success).
/// </para>
/// </summary>
public WeenieError jump(float extent, int adjustStamina = 0)
{
if (PhysicsObj is null)
return WeenieError.NoPhysicsObject;
var result = jump_is_allowed(extent, adjustStamina);
InterruptCurrentMovement?.Invoke();
var result = jump_is_allowed(extent, out _);
if (result == WeenieError.None)
{
JumpExtent = extent;
@ -1203,25 +1387,29 @@ public sealed class MotionInterpreter : IMotionDoneSink
return result;
}
// ── FUN_005286b0 — get_jump_v_z ──────────────────────────────────────────
// ── FUN_00527aa0 — get_jump_v_z ──────────────────────────────────────────
/// <summary>
/// Get the vertical (Z) component of jump velocity.
///
/// Decompiled logic (FUN_005286b0):
/// local_4 = *(+0x74) ← JumpExtent
/// if local_4 &lt; _DAT_007c9734 (epsilon): return _DAT_00796344 (0.0)
/// if local_4 > _DAT_007938b0 (1.0): local_4 = 1.0
/// if WeenieObj == null: return _DAT_0079c6d4 (10.0) — default jump v_z
/// cVar1 = InqJumpVelocity(local_4, &amp;local_4) — vtable +0x30
/// if (cVar1 != 0): return local_4
/// return _DAT_00796344 (0.0)
/// <para>
/// Verbatim (0x00527aa0, decomp §3c @304953, W0-pins.md A5 — the BN text
/// is x87-flag garbled; ACE's clean-room reading adjudicates):
/// <code>
/// extent = this-&gt;jump_extent;
/// if (extent &lt; 0.000199999995f) return 0.0f;
/// if (extent &gt; 1.0f) extent = 1.0f;
/// if (this-&gt;weenie_obj == 0) return 10.0f;
/// if (weenie_obj.InqJumpVelocity(extent, &amp;extent) != 0) return extent;
/// return 0.0f;
/// </code>
/// </para>
/// </summary>
public float get_jump_v_z()
public float GetJumpVZ()
{
float extent = JumpExtent;
if (extent < JumpExtentEpsilon)
if (extent < JumpVzEpsilon)
return 0.0f;
if (extent > MaxJumpExtent)
@ -1236,34 +1424,31 @@ public sealed class MotionInterpreter : IMotionDoneSink
return 0.0f;
}
// ── FUN_00528cd0 — get_leave_ground_velocity ──────────────────────────────
// ── FUN_005280c0 — get_leave_ground_velocity ──────────────────────────────
/// <summary>
/// Compose the full 3D body-local launch velocity when leaving the ground.
///
/// Decompiled logic (FUN_00528cd0):
/// FUN_00528960(velocity) ← get_state_velocity (XY components)
/// velocity.Z = get_jump_v_z()
/// If all three components are &lt; epsilon (nearly zero velocity):
/// Apply the orientation matrix rows of PhysicsObj to the current
/// world-space velocity (rotate world vel into body-local frame).
/// This preserves momentum direction when jumping while stationary.
/// return velocity
///
/// The "near-zero" fast path uses the body's current velocity transformed
/// back into local space, which in our port is
/// Vector3.Transform(Velocity, Quaternion.Inverse(Orientation)).
/// <para>
/// Verbatim (0x005280c0, decomp §3d @305404, W0-pins.md A6): body order
/// is <c>get_state_velocity(esi)</c> → <c>esi.z = get_jump_v_z()</c> →
/// fallback fires ONLY when <c>|x| AND |y| AND |z|</c> are ALL
/// <c>&lt; 0.000199999995f</c> (epsilon tested three times, one per
/// component), and then OVERWRITES ALL THREE components (including the
/// z the function just computed) with
/// <c>globaltolocal(physics_obj-&gt;m_velocityVector)</c> — decisively
/// pinned as GLOBAL→LOCAL by the row-linear match against
/// <c>Frame::globaltolocalvec</c> (A6). The existing
/// <c>Vector3.Transform(Velocity, Quaternion.Inverse(Orientation))</c>
/// transform already IS global→local — kept unchanged.
/// </para>
/// </summary>
public Vector3 get_leave_ground_velocity()
public Vector3 GetLeaveGroundVelocity()
{
var velocity = get_state_velocity();
velocity.Z = get_jump_v_z();
velocity.Z = GetJumpVZ();
// If the lateral + vertical components are all tiny, fall back to the
// current world velocity projected into body-local space so that an
// airborne nudge preserves direction (retail decompile: matrix multiply
// of the orientation column vectors against the world velocity).
float eps = JumpExtentEpsilon;
float eps = JumpVzEpsilon;
if (MathF.Abs(velocity.X) < eps && MathF.Abs(velocity.Y) < eps && MathF.Abs(velocity.Z) < eps
&& PhysicsObj is not null)
{
@ -1274,51 +1459,116 @@ public sealed class MotionInterpreter : IMotionDoneSink
return velocity;
}
// ── FUN_00528ec0 — jump_is_allowed ────────────────────────────────────────
// ── FUN_005282b0 — jump_is_allowed ────────────────────────────────────────
/// <summary>
/// Determine whether a jump is currently permitted.
/// Determine whether a jump is currently permitted. R3-W3 (closes J5):
/// FULL verbatim chain replacing the pre-W3 15-line approximation —
/// entry shape, <c>IsFullyConstrained</c>, the pending-head peek (A2),
/// the charge→motion→stamina chain, all now present.
///
/// Decompiled logic (FUN_00528ec0):
/// if PhysicsObj == null: return 0x24
/// if WeenieObj == null: proceed (no weenie check)
/// elif WeenieObj.IsCreature() returns false: proceed
/// iVar2 = PhysicsObj
/// if Gravity flag NOT set OR (Contact AND OnWalkable): ← grounded or no gravity
/// return 0x24 (NotGrounded)
/// if FUN_0050f730() (IsFullyConstrained) != 0: return 0x47
/// if pending queue action has non-zero jump error: return that error
/// iVar2 = FUN_00528660() (jump_charge_is_allowed)
/// if iVar2 == 0:
/// iVar2 = FUN_005285e0(InterpretedState.ForwardCommand) (motion_allows_jump)
/// if iVar2 == 0 AND WeenieObj != null:
/// cVar1 = WeenieObj.CanJump(extent, stamina) → vtable +0x40
/// if cVar1 == 0: return 0x47
/// return iVar2
/// <para>
/// Verbatim (0x005282b0, decomp §3h, W0-pins.md A2/A10):
/// <code>
/// if (physics_obj != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj.IsCreature();
/// if (weenie_obj != 0 &amp;&amp; eax_2 == 0) goto shared_gate; // non-creature weenie skips ground gate
/// if (physics_obj == 0 || (state bit 0x400) == 0) goto shared_gate; // gravity-state off skips ground gate
/// if (Contact &amp;&amp; OnWalkable) goto shared_gate; // grounded also reaches shared gate
/// }
/// return 0x24; // gravity-bound creature, not grounded
///
/// shared_gate:
/// if (IsFullyConstrained(physics_obj) != 0) return 0x47;
/// head = pending_motions.head_;
/// if (head != 0) eax_6 = head.jump_error_code; // +0xc
/// if (head == 0 || eax_6 == 0) {
/// eax_6 = jump_charge_is_allowed(this);
/// if (eax_6 == 0) {
/// eax_7 = motion_allows_jump(this, interpreted_state.forward_command);
/// if (eax_7 != 0) return eax_7;
/// if (weenie_obj_1 == 0) return eax_7; // == 0 (success, no weenie to consult)
/// eax_6 = 0x47;
/// if (weenie_obj_1.JumpStaminaCost(arg2, arg3) != 0) return eax_7; // == 0 (afforded)
/// // JumpStaminaCost returned false -> falls out, eax_6 stays 0x47
/// }
/// }
/// return eax_6;
/// </code>
/// </para>
///
/// <para>
/// A10 note: <c>physics_obj == null</c> returns <c>0x24</c> (NotGrounded),
/// NOT <c>8</c> — the "8 = no physics obj" convention that holds
/// everywhere else in CMotionInterp does not hold here (the
/// <c>if (physics_obj != 0) {...}</c> wrapper falls out to
/// <c>return 0x24</c> when physics_obj is null, same as the grounded-check
/// failure path).
/// </para>
/// </summary>
public WeenieError jump_is_allowed(float extent, int staminaCost)
/// <param name="extent">Jump charge fraction, forwarded to
/// <c>JumpStaminaCost</c>.</param>
/// <param name="staminaCost">Out-param mirroring retail's <c>arg3</c>
/// (<c>int32_t*</c>) — the stamina cost <c>JumpStaminaCost</c> computed,
/// 0 when the chain never reaches that call.</param>
public WeenieError jump_is_allowed(float extent, out int staminaCost)
{
// A10: jump_is_allowed returns 0x24 (NotGrounded), NOT 8, when
// physics_obj == null — the "8 = no physics obj" convention holds
// everywhere else in CMotionInterp but not here (raw structure
// 305512 + 305570: the `if (physics_obj != 0) {...}` wrapper falls
// out to `return 0x24;`).
if (PhysicsObj is null)
return WeenieError.NotGrounded;
staminaCost = 0;
// Must have gravity and be grounded (Contact + OnWalkable) to start a jump.
bool hasGravity = PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity);
bool isGrounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (PhysicsObj is not null)
{
bool nonCreatureWeenie = WeenieObj is not null && !WeenieObj.IsCreature();
bool gravityStateOff = !PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity);
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (!hasGravity || !isGrounded)
return WeenieError.NotGrounded;
if (nonCreatureWeenie || gravityStateOff || grounded)
return JumpIsAllowedSharedGate(extent, ref staminaCost);
}
// Delegate jump eligibility to WeenieObj if present.
if (WeenieObj is not null && !WeenieObj.CanJump(extent))
return WeenieError.CantJumpLoadedDown;
return WeenieError.NotGrounded; // 0x24 — gravity-bound creature, not grounded (also physics_obj == null)
}
return WeenieError.None;
/// <summary>
/// The <c>shared_gate</c> label inside <see cref="jump_is_allowed"/>
/// (raw 305524-305556) — split out only for C# readability; retail
/// reaches this point via three different <c>goto</c> sites, all folded
/// into one function here since C# has no goto-into-shared-tail idiom
/// as clean as retail's.
/// </summary>
private WeenieError JumpIsAllowedSharedGate(float extent, ref int staminaCost)
{
if (PhysicsObj is not null && PhysicsObj.IsFullyConstrained)
return WeenieError.GeneralMovementFailure; // 0x47
// A2: peek the pending_motions head WHENEVER non-empty — no Count>1
// gate. A nonzero head.JumpErrorCode short-circuits the whole
// charge/motion/stamina chain below.
var head = _pendingMotions.First;
uint peeked = head is not null ? head.Value.JumpErrorCode : 0;
if (head is null || peeked == 0)
{
WeenieError chargeResult = JumpChargeIsAllowed(extent);
if (chargeResult == WeenieError.None)
{
WeenieError motionResult = MotionAllowsJump(InterpretedState.ForwardCommand);
if (motionResult != WeenieError.None)
return motionResult;
if (WeenieObj is null)
return motionResult; // == None
if (!WeenieObj.JumpStaminaCost(extent, out staminaCost))
return WeenieError.GeneralMovementFailure; // 0x47 — can't afford
return motionResult; // == None (success)
}
return chargeResult;
}
return (WeenieError)peeked;
}
// ── FUN_00528dd0 — contact_allows_move ────────────────────────────────────
@ -1326,43 +1576,40 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// <summary>
/// Determine whether the current contact state allows this motion command.
///
/// Decompiled logic (FUN_00528dd0):
/// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false:
/// return 0x49
/// uVar1 = InterpretedState.ForwardCommand
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead):
/// return 0x48
/// if 0x41000011 &lt; uVar1 &lt; 0x41000015 (crouch/sit/sleep range):
/// return 0x48
/// uVar2 = PhysicsObj.TransientState
/// if (Contact AND OnWalkable) AND ForwardCommand == Ready
/// AND SideStepCommand == 0 AND TurnCommand == 0:
/// StandingLongJump = true
/// return 0
/// <para>
/// Decompiled logic (0x00528240, pseudo-C 305471): allowed (1) when —
/// motion is TurnRight/TurnLeft (0x6500000D/0E), OR motion is Falling
/// (0x40000015) or Dead-class (0x40000011), OR the weenie exists and is
/// NOT a creature, OR gravity is off, OR the body has Contact +
/// OnWalkable. Everything else (a gravity-bound creature without ground
/// contact) is blocked — this is the real mechanism behind "airborne
/// remotes keep their cycle" (K-fix17's empirical guard).
/// </para>
///
/// The return type in the decompile is undefined4 (int), but ACE models it
/// as bool (0 = allowed, non-zero = blocked). We model it as bool here for
/// cleaner call sites, treating any non-zero return as "blocked".
/// <para>
/// The return type in the decompile is undefined4 (int), but ACE models
/// it as bool (0 = allowed, non-zero = blocked). We model it as bool
/// here for cleaner call sites, treating any non-zero return as
/// "blocked".
/// </para>
///
/// <para>
/// <b>R3-W3 (closes J6): the StandingLongJump side effect that used to
/// live here is DELETED.</b> The S2a port had flagged it explicitly as
/// "PRE-EXISTING acdream side effect (not part of 0x00528240)" — a
/// misattribution: retail's <c>contact_allows_move</c> (0x00528240)
/// never reads or writes <c>standing_longjump</c> at all. The real
/// arming site is <see cref="ChargeJump"/> (0x005281c0, decomp §3e),
/// which now owns the identical grounded+idle predicate exclusively.
/// Consequence of the OLD bug this retires: every grounded idle contact
/// check (i.e. every frame the player stood still, since
/// <c>ApplyInterpretedMovement</c> calls this every dispatch) flipped
/// the flag regardless of whether a jump charge was ever started —
/// <see cref="ChargeJump"/> is the only path that can arm it now.
/// </para>
/// </summary>
public bool contact_allows_move(uint motion)
{
// L.2g S2 (2026-07-02): rewritten VERBATIM from the real
// CMotionInterp::contact_allows_move (0x00528240, pseudo-C 305471).
// The previous body conflated jump_charge_is_allowed (0x00527a50:
// CanJump + Fallen/crouch-range checks) with this gate — the
// 2026-06-04 deep-dive flagged the divergence; the retail-observer
// trace conformance run exposed it (airborne bodies must still
// ACCEPT Falling/Dead + turns).
//
// Retail: allowed (1) when —
// motion is TurnRight/TurnLeft (0x6500000D/0E), OR
// motion is Falling (0x40000015) or Dead-class (0x40000011), OR
// the weenie exists and is NOT a creature, OR
// gravity is off, OR
// the body has Contact + OnWalkable.
// Everything else (a gravity-bound creature without ground contact)
// is blocked — this is the real mechanism behind "airborne remotes
// keep their cycle" (K-fix17's empirical guard).
if (PhysicsObj is null)
return false;
@ -1384,21 +1631,8 @@ public sealed class MotionInterpreter : IMotionDoneSink
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (!grounded)
return false;
// PRE-EXISTING acdream side effect (not part of 0x00528240; kept for
// the local-player standing-long-jump path pending its own verbatim
// pass): grounded and idle flags the long-jump candidate.
uint fwd = InterpretedState.ForwardCommand;
if (fwd == MotionCommand.Ready
&& InterpretedState.SideStepCommand == 0
&& InterpretedState.TurnCommand == 0)
{
StandingLongJump = true;
}
return true;
return grounded;
}
// ── R3-W2 — pending_motions lifecycle ─────────────────────────────────
@ -1606,7 +1840,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
///
/// Decompiled logic (FUN_00529710):
/// if PhysicsObj == null: return
/// velocity = get_leave_ground_velocity()
/// velocity = GetLeaveGroundVelocity()
/// PhysicsObj.set_local_velocity(velocity)
/// StandingLongJump = false
/// JumpExtent = 0
@ -1616,7 +1850,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
if (PhysicsObj is null)
return;
var velocity = get_leave_ground_velocity();
var velocity = GetLeaveGroundVelocity();
PhysicsObj.set_local_velocity(velocity);
StandingLongJump = false;

View file

@ -231,6 +231,20 @@ public sealed class PhysicsBody
/// <summary>Last simulation time used to compute dt (+0xD8).</summary>
public double LastUpdateTime { get; set; }
/// <summary>
/// R3-W3 stub for retail <c>CPhysicsObj::IsFullyConstrained</c>
/// (0x0050f730), read by <c>CMotionInterp::jump_is_allowed</c> (raw
/// 305524-305525: <c>if (IsFullyConstrained(physics_obj) != 0) return
/// 0x47;</c>). Retail's body walks per-cell contact-plane constraints
/// (a mover pinned between opposing walkable surfaces / doorway
/// jamming); acdream has no equivalent constraint-tracking yet.
/// Register row: stubbed false (never fires) — a real port needs the
/// per-cell shadow-list contact accounting the physics digest tracks.
/// See docs/architecture/retail-divergence-register.md (added same
/// commit as this field).
/// </summary>
public bool IsFullyConstrained { get; set; }
// ── convenience helpers ────────────────────────────────────────────────
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity);

View file

@ -43,6 +43,24 @@ public sealed class PlayerWeenie : IWeenieObject
public bool CanJump(float extent) => true; // burden/stamina checks deferred
/// <summary>
/// R3-W3 (W0-pins.md A3): the local player's weenie is THE player.
/// Feeds W4's <c>apply_current_movement</c>/<c>ReportExhaustion</c>
/// dual-dispatch gate — no consumer yet in W3.
/// </summary>
public bool IsThePlayer() => true;
/// <summary>
/// TS-5 (extended): stamina cost gating deferred pending stat plumbing —
/// always affordable, cost 0. Matches <see cref="CanJump"/>'s existing
/// always-true stance.
/// </summary>
public bool JumpStaminaCost(float extent, out int cost)
{
cost = 0;
return true;
}
/// <summary>
/// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4
/// Capped at 4.5 when runSkill >= 800.