diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a80a02cd..ec87756a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -183,7 +183,7 @@ accepted-divergence entries (#96, #49, #50). | AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 | | AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) | -## 4. Temporary stopgap (TS) — 33 rows +## 4. Temporary stopgap (TS) — 36 rows (TS-37 is a retired-row historical note, not an active count) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -191,7 +191,7 @@ accepted-divergence entries (#96, #49, #50). | TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 | | TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall | | TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | -| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2) | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry | +| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs | @@ -220,6 +220,9 @@ accepted-divergence entries (#96, #49, #50). | TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) | | TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping) is a dedicated follow-up slice | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0 | | TS-34 | `adjust_motion`'s creature guard is a no-op — retail returns early for a non-creature weenie (`!IsCreature()`), but acdream's `IWeenieObject` has no `IsCreature()`, so the guard always proceeds | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`adjust_motion`) | Correct today: the only caller of the local `adjust_motion`/`apply_raw_movement` path is the player (a creature); no non-creature drives it | If a non-creature weenie is ever routed through `adjust_motion` (e.g. a missile with a weenie), its motion would be wrongly normalized instead of skipped | `CMotionInterp::adjust_motion` 0x00528010 creature guard; add `IsCreature` to IWeenieObject | +| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain; the WRITE side (per-cell contact-plane constraint tracking — a mover pinned between opposing walkable surfaces / doorway jamming) doesn't exist yet | A body that retail would consider fully constrained (and thus refuse to jump, 0x47) never gets blocked here — jumps succeed in geometry retail would reject. Low practical risk today: the constrained-mover scenario (doorway wedge, opposing-wall pin) is rare and mostly a physics-digest topic already tracked separately | `CPhysicsObj::IsFullyConstrained` 0x0050f730; `jump_is_allowed` 0x005282b0 | +| TS-36 | `MotionInterpreter.InterruptCurrentMovement` is a no-op `Action?` seam — retail's `CPhysicsObj::interrupt_current_movement` (called unconditionally at the top of `jump()` and inside `StopCompletely`) has no real implementation to call yet | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`InterruptCurrentMovement`, R3-W3; called from `jump()`) | R4 (MoveToManager) is where `cancel_moveto`/in-flight-transition-interrupt actually lands; until then there is no in-flight transition to cancel (no MoveToManager exists), so the no-op is behaviorally inert, not a masked bug | Once MoveToManager exists (R4) and a jump fires mid-auto-walk/mid-moveto without this seam being wired to the real cancel, the queued transition would keep running alongside the new jump — silent double-motion | `CPhysicsObj::interrupt_current_movement`; `jump` 0x00528780 @305800 | +| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 | --- diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index a0cfa7fd..fabb4b4c 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -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 diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index cdecabf4..96a21236 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -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). /// bool IsCreature() => true; + + /// + /// Retail ACCWeenieObject::IsThePlayer (vtable +0x14, + /// 0x0058C3D0): this->id == SmartBox::smartbox->player_id. + /// PINNED (W0-pins.md A3, adversarially verified) as the dual-dispatch + /// gate for apply_current_movement/ReportExhaustion/ + /// SetWeenieObject/SetPhysicsObject — NOT IsCreature + /// (a remote player is a creature but not the player; ACE's server-side + /// IsCreature 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. + /// + bool IsThePlayer() => false; + + /// + /// Retail CWeenieObject::JumpStaminaCost (vtable +0x44, referenced + /// from jump_is_allowed raw 305549-305556): given the charge + /// , returns whether the weenie can afford the + /// stamina cost of this jump and writes the cost to + /// . jump_is_allowed treats a false + /// return as (0x47). + /// Default true / cost 0 — real stamina gating stays TS-5-deferred (the + /// same register row that already covers CanJump always-true; + /// this extends it to JumpStaminaCost until stat plumbing lands). + /// + 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; /// Sidestep animation base speed (_DAT_007c96e8 family). public const float SidestepAnimSpeed = 1.25f; - /// Minimum jump extent before get_jump_v_z bothers computing (_DAT_007c9734). - public const float JumpExtentEpsilon = 0.001f; + /// + /// Minimum jump extent before get_jump_v_z bothers computing + /// (_DAT_007c9734). R3-W3 (closes J16-epsilons, A5/A6): retail's exact + /// literal is 0.000199999995f (raw @304959: + /// ((long double)0.000199999995f)), NOT the previous 0.001 + /// approximation. Used by both and + /// (three times there, one per + /// axis — A6). + /// + public const float JumpVzEpsilon = 0.000199999995f; /// Fallback vertical jump velocity when WeenieObj is absent (_DAT_0079c6d4). public const float DefaultJumpVz = 10.0f; /// Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f). @@ -585,6 +627,18 @@ public sealed class MotionInterpreter : IMotionDoneSink /// public Action? UnstickFromObject { get; set; } + /// + /// R3-W3 no-op seam standing in for retail CPhysicsObj::interrupt_current_movement + /// (called unconditionally at the top of , raw @305800, + /// and by per A9). Register row: retail + /// cancels any in-flight MoveToManager transition before starting a new + /// movement action; R4 wires the real cancel_moveto once + /// MoveToManager exists. Until then this is an optional callback the App + /// layer may bind, matching the seam + /// convention. + /// + public Action? InterruptCurrentMovement { get; set; } + /// /// 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 ───────────────────────────────── + + /// + /// CMotionInterp::jump_charge_is_allowed (0x00527a50, decomp §3b + /// @304935, W0-pins.md A1 polarity). Gate consulted while the jump + /// charge is accumulating (spacebar held) — NOT the same gate as + /// 's ground check. + /// + /// + /// Verbatim (raw 304940-304948): + /// + /// weenie_obj = this->weenie_obj; + /// if (weenie_obj != 0 && weenie_obj.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; + /// + /// + /// + 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 ──────────────────────────────────────────── + + /// + /// CMotionInterp::charge_jump (0x005281c0, decomp §3e @305448, + /// W0-pins.md A1 polarity). R3-W3 (closes J6): the ONLY place retail + /// arms — the old + /// contact_allows_move side effect (:1139-1148 pre-W3 numbering) + /// is DELETED (see 's doc comment). + /// + /// + /// Verbatim (raw 305453-305466): + /// + /// weenie_obj = this->weenie_obj; + /// if (weenie_obj != 0 && weenie_obj.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) + /// this->standing_longjump = 1; + /// return 0; + /// + /// + /// + /// + /// Note the inverted-but-equivalent posture test vs + /// : that function's PASS condition is + /// forward <= 0x41000011 || forward > 0x41000014; this + /// function's BLOCK condition is forward == 0x40000008 || + /// (forward > 0x41000011 && forward <= 0x41000014) — 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 + /// to keep each function's control flow traceable to its own raw + /// address. + /// + /// + /// + /// 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); + /// PlayerMovementController may call this once wired (W4/W6 — no + /// regression today since nothing calls ChargeJump yet, so + /// remotes/local both continue unaffected). + /// + /// + 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 ─────────────────────────────────────────────────── /// /// 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 + /// + /// Verbatim (0x00528780, decomp §3f @305792): + /// + /// physics_obj = this->physics_obj; + /// if (physics_obj == 0) return 8; + /// interrupt_current_movement(physics_obj); + /// result = jump_is_allowed(this, arg2, arg3); + /// if (result != 0) { + /// this->standing_longjump = 0; + /// return result; + /// } + /// this->jump_extent = arg2; + /// set_on_walkable(physics_obj, 0); + /// return result; // == 0 + /// + /// + /// + /// + /// R3-W3 (closes J7-interp-side): interrupt_current_movement is + /// now called UNCONDITIONALLY before , via + /// the no-op seam (register row — + /// R4 wires the real cancel_moveto). is + /// cleared ONLY on the failure path — a successful jump leaves it + /// untouched (the caller/LeaveGround owns clearing it on success). + /// /// 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 ────────────────────────────────────────── /// /// Get the vertical (Z) component of jump velocity. /// - /// Decompiled logic (FUN_005286b0): - /// local_4 = *(+0x74) ← JumpExtent - /// if local_4 < _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, &local_4) — vtable +0x30 - /// if (cVar1 != 0): return local_4 - /// return _DAT_00796344 (0.0) + /// + /// Verbatim (0x00527aa0, decomp §3c @304953, W0-pins.md A5 — the BN text + /// is x87-flag garbled; ACE's clean-room reading adjudicates): + /// + /// extent = this->jump_extent; + /// if (extent < 0.000199999995f) return 0.0f; + /// if (extent > 1.0f) extent = 1.0f; + /// if (this->weenie_obj == 0) return 10.0f; + /// if (weenie_obj.InqJumpVelocity(extent, &extent) != 0) return extent; + /// return 0.0f; + /// + /// /// - 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 ────────────────────────────── /// /// 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 < 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)). + /// + /// Verbatim (0x005280c0, decomp §3d @305404, W0-pins.md A6): body order + /// is get_state_velocity(esi)esi.z = get_jump_v_z() → + /// fallback fires ONLY when |x| AND |y| AND |z| are ALL + /// < 0.000199999995f (epsilon tested three times, one per + /// component), and then OVERWRITES ALL THREE components (including the + /// z the function just computed) with + /// globaltolocal(physics_obj->m_velocityVector) — decisively + /// pinned as GLOBAL→LOCAL by the row-linear match against + /// Frame::globaltolocalvec (A6). The existing + /// Vector3.Transform(Velocity, Quaternion.Inverse(Orientation)) + /// transform already IS global→local — kept unchanged. + /// /// - 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 ──────────────────────────────────────── /// - /// 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, IsFullyConstrained, 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 + /// + /// Verbatim (0x005282b0, decomp §3h, W0-pins.md A2/A10): + /// + /// if (physics_obj != 0) { + /// if (weenie_obj != 0) eax_2 = weenie_obj.IsCreature(); + /// if (weenie_obj != 0 && 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 && 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; + /// + /// + /// + /// + /// A10 note: physics_obj == null returns 0x24 (NotGrounded), + /// NOT 8 — the "8 = no physics obj" convention that holds + /// everywhere else in CMotionInterp does not hold here (the + /// if (physics_obj != 0) {...} wrapper falls out to + /// return 0x24 when physics_obj is null, same as the grounded-check + /// failure path). + /// /// - public WeenieError jump_is_allowed(float extent, int staminaCost) + /// Jump charge fraction, forwarded to + /// JumpStaminaCost. + /// Out-param mirroring retail's arg3 + /// (int32_t*) — the stamina cost JumpStaminaCost computed, + /// 0 when the chain never reaches that call. + 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; + /// + /// The shared_gate label inside + /// (raw 305524-305556) — split out only for C# readability; retail + /// reaches this point via three different goto sites, all folded + /// into one function here since C# has no goto-into-shared-tail idiom + /// as clean as retail's. + /// + 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 /// /// 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 < uVar1 < 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 + /// + /// 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). + /// /// - /// 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". + /// + /// 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". + /// + /// + /// + /// R3-W3 (closes J6): the StandingLongJump side effect that used to + /// live here is DELETED. The S2a port had flagged it explicitly as + /// "PRE-EXISTING acdream side effect (not part of 0x00528240)" — a + /// misattribution: retail's contact_allows_move (0x00528240) + /// never reads or writes standing_longjump at all. The real + /// arming site is (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 + /// ApplyInterpretedMovement calls this every dispatch) flipped + /// the flag regardless of whether a jump charge was ever started — + /// is the only path that can arm it now. + /// /// 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; diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index 32d4b928..1275bda4 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -231,6 +231,20 @@ public sealed class PhysicsBody /// Last simulation time used to compute dt (+0xD8). public double LastUpdateTime { get; set; } + /// + /// R3-W3 stub for retail CPhysicsObj::IsFullyConstrained + /// (0x0050f730), read by CMotionInterp::jump_is_allowed (raw + /// 305524-305525: if (IsFullyConstrained(physics_obj) != 0) return + /// 0x47;). 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). + /// + public bool IsFullyConstrained { get; set; } + // ── convenience helpers ──────────────────────────────────────────────── public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity); diff --git a/src/AcDream.Core/Physics/PlayerWeenie.cs b/src/AcDream.Core/Physics/PlayerWeenie.cs index 014c96a8..3d1ab62d 100644 --- a/src/AcDream.Core/Physics/PlayerWeenie.cs +++ b/src/AcDream.Core/Physics/PlayerWeenie.cs @@ -43,6 +43,24 @@ public sealed class PlayerWeenie : IWeenieObject public bool CanJump(float extent) => true; // burden/stamina checks deferred + /// + /// R3-W3 (W0-pins.md A3): the local player's weenie is THE player. + /// Feeds W4's apply_current_movement/ReportExhaustion + /// dual-dispatch gate — no consumer yet in W3. + /// + public bool IsThePlayer() => true; + + /// + /// TS-5 (extended): stamina cost gating deferred pending stat plumbing — + /// always affordable, cost 0. Matches 's existing + /// always-true stance. + /// + public bool JumpStaminaCost(float extent, out int cost) + { + cost = 0; + return true; + } + /// /// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4 /// Capped at 4.5 when runSkill >= 800. diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs new file mode 100644 index 00000000..f32d67e7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs @@ -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 +// ───────────────────────────────────────────────────────────────────────────── + +/// Fake WeenieObject for jump-family test isolation. +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; + + /// Controls the JumpStaminaCost virtual (vtable +0x44 per the + /// raw 305549-305556 shape): true = affordable (pass), false = 0x47. + 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); + } + + /// Fake weenie whose IsCreature() returns false, for the + /// non-creature ground-gate-skip pin. + 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()); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs index 2ed8c38b..cfecd6a1 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs @@ -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)"); } // =========================================================================