feat(R3-W3): verbatim jump family — charge gates, jump_is_allowed chain, epsilon fixes; StandingLongJump misattribution retired (closes J5, J6, J7-interp-side, J16-epsilons)
JumpChargeIsAllowed 0x00527a50 (CanJump→0x49 before posture; Fallen + Crouch..Sleeping→0x48), ChargeJump 0x005281c0 — now the ONLY place standing_longjump arms (grounded Contact+OnWalkable + forward Ready + no sidestep/turn, raw @305453-305466); the S2a-flagged side effect inside contact_allows_move is DELETED (J6) with regression pins that contact checks never touch the flag (the funnel's StandingLongJump branch is local-player scope; the controller wires ChargeJump in W4/W6 — remotes never charge client-side, no interim regression). jump_is_allowed 0x005282b0 full chain replaces the 15-line approximation: IsFullyConstrained→0x47 (new PhysicsBody stub, TS-35) BEFORE the pending-head peek (A2 ordering, pinned); head peek fires whenever the queue is non-empty (no ACE Count>1 gate; nonzero jump_error_code short-circuits the whole chain — test proves JumpStaminaCost is never consulted); retail entry shape verbatim (non-creature-weenie and gravity-off skip the ground gate; null physics obj → 0x24 NOT 8 per A10); charge → MotionAllowsJump(fwd) double-check → JumpStaminaCost gate (new IWeenieObject member, always-affordable PlayerWeenie stub — TS-5 extended). GetJumpVZ/GetLeaveGroundVelocity: epsilon corrected to retail's 0.000199999995f (was 0.001 — regression-pinned at extent 0.0005); A5 shape (clamp 1.0, weenie-null 10.0f); A6 momentum fallback fires only when ALL THREE components are sub-epsilon and overwrites all three with global→local(m_velocityVector). Jump 0x00528780: InterruptCurrentMovement no-op seam (TS-36, →R4 cancel_moveto), fires unconditionally; standing_longjump cleared ONLY on failure (success keeps it — pinned). IWeenieObject.IsThePlayer() (PlayerWeenie true) lands for W4's A3 dispatch. 51 new tests + 1 re-pin; superseded pre-W3 jump tests removed. Full suite: 3,623 passed. Registers: TS-5 extended, TS-35/36/37 added. Implemented by a dedicated agent against the W0-pinned spec; arming gates + entry shape verified against the quoted raw text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
371679915e
commit
af4764443f
7 changed files with 1383 additions and 352 deletions
|
|
@ -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-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) |
|
| 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 |
|
| # | 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-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-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-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-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-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 |
|
| 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-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-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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -988,7 +988,7 @@ public sealed class PlayerMovementController
|
||||||
// ── 3. Jump (charged) ─────────────────────────────────────────────────
|
// ── 3. Jump (charged) ─────────────────────────────────────────────────
|
||||||
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
|
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
|
||||||
// Release to execute: jump(extent) validates + sets JumpExtent,
|
// 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;
|
float? outJumpExtent = null;
|
||||||
Vector3? outJumpVelocity = null;
|
Vector3? outJumpVelocity = null;
|
||||||
|
|
||||||
|
|
@ -1010,9 +1010,9 @@ public sealed class PlayerMovementController
|
||||||
{
|
{
|
||||||
// Capture jump_v_z BEFORE LeaveGround() — that call resets
|
// Capture jump_v_z BEFORE LeaveGround() — that call resets
|
||||||
// JumpExtent back to 0 (faithful to retail's FUN_00529710),
|
// 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.
|
// gate at the top of the function fires.
|
||||||
float jumpVz = _motion.get_jump_v_z();
|
float jumpVz = _motion.GetJumpVZ();
|
||||||
_motion.LeaveGround();
|
_motion.LeaveGround();
|
||||||
outJumpExtent = _jumpExtent;
|
outJumpExtent = _jumpExtent;
|
||||||
// D6.2: get_state_velocity() is now correct for all directions
|
// D6.2: get_state_velocity() is now correct for all directions
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,15 @@ namespace AcDream.Core.Physics;
|
||||||
// FUN_00528960 get_state_velocity — compute world-space velocity for current motion
|
// FUN_00528960 get_state_velocity — compute world-space velocity for current motion
|
||||||
// FUN_00529210 apply_current_movement — apply interpreted motion as velocity
|
// FUN_00529210 apply_current_movement — apply interpreted motion as velocity
|
||||||
// FUN_00529390 jump — initiate jump: validate, record extent, leave ground
|
// FUN_00529390 jump — initiate jump: validate, record extent, leave ground
|
||||||
// FUN_005286b0 get_jump_v_z — get vertical jump velocity
|
// FUN_005286b0 GetJumpVZ — get vertical jump velocity (R3-W3 rename)
|
||||||
// FUN_00528cd0 get_leave_ground_velocity — compose full 3D launch vector
|
// FUN_00528cd0 GetLeaveGroundVelocity — compose full 3D launch vector (R3-W3 rename)
|
||||||
// FUN_00528ec0 jump_is_allowed — can we jump?
|
// FUN_00528ec0 jump_is_allowed — can we jump? (R3-W3: full verbatim chain)
|
||||||
// FUN_00528dd0 contact_allows_move — slope angle / contact state check
|
// 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.
|
// Cross-checked against ACE MotionInterp.cs.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -441,6 +445,36 @@ public interface IWeenieObject
|
||||||
/// retail-correct (register TS-34 tracks the adjust_motion side).
|
/// retail-correct (register TS-34 tracks the adjust_motion side).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool IsCreature() => true;
|
bool IsCreature() => true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>ACCWeenieObject::IsThePlayer</c> (vtable +0x14,
|
||||||
|
/// 0x0058C3D0): <c>this->id == SmartBox::smartbox->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 ─────────────────────────────────────────────────────────
|
// ── MotionInterpreter ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -475,8 +509,16 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
public const float RunAnimSpeed = 4.0f;
|
public const float RunAnimSpeed = 4.0f;
|
||||||
/// <summary>Sidestep animation base speed (_DAT_007c96e8 family).</summary>
|
/// <summary>Sidestep animation base speed (_DAT_007c96e8 family).</summary>
|
||||||
public const float SidestepAnimSpeed = 1.25f;
|
public const float SidestepAnimSpeed = 1.25f;
|
||||||
/// <summary>Minimum jump extent before get_jump_v_z bothers computing (_DAT_007c9734).</summary>
|
/// <summary>
|
||||||
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 <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>
|
/// <summary>Fallback vertical jump velocity when WeenieObj is absent (_DAT_0079c6d4).</summary>
|
||||||
public const float DefaultJumpVz = 10.0f;
|
public const float DefaultJumpVz = 10.0f;
|
||||||
/// <summary>Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f).</summary>
|
/// <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>
|
/// </summary>
|
||||||
public Action? UnstickFromObject { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Optional accessor for the owning entity's current animation cycle
|
/// Optional accessor for the owning entity's current animation cycle
|
||||||
/// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
|
/// 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->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;
|
||||||
|
/// </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->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;
|
||||||
|
/// </code>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Note the inverted-but-equivalent posture test vs
|
||||||
|
/// <see cref="JumpChargeIsAllowed"/>: that function's PASS condition is
|
||||||
|
/// <c>forward <= 0x41000011 || forward > 0x41000014</c>; this
|
||||||
|
/// function's BLOCK condition is <c>forward == 0x40000008 ||
|
||||||
|
/// (forward > 0x41000011 && forward <= 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>
|
/// <summary>
|
||||||
/// Initiate a jump: validate, store jump extent, leave the ground.
|
/// Initiate a jump: validate, store jump extent, leave the ground.
|
||||||
///
|
///
|
||||||
/// Decompiled logic (FUN_00529390):
|
/// <para>
|
||||||
/// if (PhysicsObj == null) return 8
|
/// Verbatim (0x00528780, decomp §3f @305792):
|
||||||
/// FUN_00510cc0() — cancel moveto
|
/// <code>
|
||||||
/// iVar1 = FUN_00528ec0(extent, stamina) ← jump_is_allowed
|
/// physics_obj = this->physics_obj;
|
||||||
/// if (iVar1 == 0):
|
/// if (physics_obj == 0) return 8;
|
||||||
/// *(+0x74) = extent ← JumpExtent
|
/// interrupt_current_movement(physics_obj);
|
||||||
/// FUN_00511de0(0) ← PhysicsObj.set_on_walkable(false)
|
/// result = jump_is_allowed(this, arg2, arg3);
|
||||||
/// return 0
|
/// if (result != 0) {
|
||||||
/// *(+0x70) = 0 ← StandingLongJump = false
|
/// this->standing_longjump = 0;
|
||||||
/// return iVar1
|
/// return result;
|
||||||
|
/// }
|
||||||
|
/// this->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>
|
/// </summary>
|
||||||
public WeenieError jump(float extent, int adjustStamina = 0)
|
public WeenieError jump(float extent, int adjustStamina = 0)
|
||||||
{
|
{
|
||||||
if (PhysicsObj is null)
|
if (PhysicsObj is null)
|
||||||
return WeenieError.NoPhysicsObject;
|
return WeenieError.NoPhysicsObject;
|
||||||
|
|
||||||
var result = jump_is_allowed(extent, adjustStamina);
|
InterruptCurrentMovement?.Invoke();
|
||||||
|
|
||||||
|
var result = jump_is_allowed(extent, out _);
|
||||||
if (result == WeenieError.None)
|
if (result == WeenieError.None)
|
||||||
{
|
{
|
||||||
JumpExtent = extent;
|
JumpExtent = extent;
|
||||||
|
|
@ -1203,25 +1387,29 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FUN_005286b0 — get_jump_v_z ──────────────────────────────────────────
|
// ── FUN_00527aa0 — get_jump_v_z ──────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the vertical (Z) component of jump velocity.
|
/// Get the vertical (Z) component of jump velocity.
|
||||||
///
|
///
|
||||||
/// Decompiled logic (FUN_005286b0):
|
/// <para>
|
||||||
/// local_4 = *(+0x74) ← JumpExtent
|
/// Verbatim (0x00527aa0, decomp §3c @304953, W0-pins.md A5 — the BN text
|
||||||
/// if local_4 < _DAT_007c9734 (epsilon): return _DAT_00796344 (0.0)
|
/// is x87-flag garbled; ACE's clean-room reading adjudicates):
|
||||||
/// if local_4 > _DAT_007938b0 (1.0): local_4 = 1.0
|
/// <code>
|
||||||
/// if WeenieObj == null: return _DAT_0079c6d4 (10.0) — default jump v_z
|
/// extent = this->jump_extent;
|
||||||
/// cVar1 = InqJumpVelocity(local_4, &local_4) — vtable +0x30
|
/// if (extent < 0.000199999995f) return 0.0f;
|
||||||
/// if (cVar1 != 0): return local_4
|
/// if (extent > 1.0f) extent = 1.0f;
|
||||||
/// return _DAT_00796344 (0.0)
|
/// if (this->weenie_obj == 0) return 10.0f;
|
||||||
|
/// if (weenie_obj.InqJumpVelocity(extent, &extent) != 0) return extent;
|
||||||
|
/// return 0.0f;
|
||||||
|
/// </code>
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float get_jump_v_z()
|
public float GetJumpVZ()
|
||||||
{
|
{
|
||||||
float extent = JumpExtent;
|
float extent = JumpExtent;
|
||||||
|
|
||||||
if (extent < JumpExtentEpsilon)
|
if (extent < JumpVzEpsilon)
|
||||||
return 0.0f;
|
return 0.0f;
|
||||||
|
|
||||||
if (extent > MaxJumpExtent)
|
if (extent > MaxJumpExtent)
|
||||||
|
|
@ -1236,34 +1424,31 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
return 0.0f;
|
return 0.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FUN_00528cd0 — get_leave_ground_velocity ──────────────────────────────
|
// ── FUN_005280c0 — get_leave_ground_velocity ──────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compose the full 3D body-local launch velocity when leaving the ground.
|
/// Compose the full 3D body-local launch velocity when leaving the ground.
|
||||||
///
|
///
|
||||||
/// Decompiled logic (FUN_00528cd0):
|
/// <para>
|
||||||
/// FUN_00528960(velocity) ← get_state_velocity (XY components)
|
/// Verbatim (0x005280c0, decomp §3d @305404, W0-pins.md A6): body order
|
||||||
/// velocity.Z = get_jump_v_z()
|
/// is <c>get_state_velocity(esi)</c> → <c>esi.z = get_jump_v_z()</c> →
|
||||||
/// If all three components are < epsilon (nearly zero velocity):
|
/// fallback fires ONLY when <c>|x| AND |y| AND |z|</c> are ALL
|
||||||
/// Apply the orientation matrix rows of PhysicsObj to the current
|
/// <c>< 0.000199999995f</c> (epsilon tested three times, one per
|
||||||
/// world-space velocity (rotate world vel into body-local frame).
|
/// component), and then OVERWRITES ALL THREE components (including the
|
||||||
/// This preserves momentum direction when jumping while stationary.
|
/// z the function just computed) with
|
||||||
/// return velocity
|
/// <c>globaltolocal(physics_obj->m_velocityVector)</c> — decisively
|
||||||
///
|
/// pinned as GLOBAL→LOCAL by the row-linear match against
|
||||||
/// The "near-zero" fast path uses the body's current velocity transformed
|
/// <c>Frame::globaltolocalvec</c> (A6). The existing
|
||||||
/// back into local space, which in our port is
|
/// <c>Vector3.Transform(Velocity, Quaternion.Inverse(Orientation))</c>
|
||||||
/// Vector3.Transform(Velocity, Quaternion.Inverse(Orientation)).
|
/// transform already IS global→local — kept unchanged.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Vector3 get_leave_ground_velocity()
|
public Vector3 GetLeaveGroundVelocity()
|
||||||
{
|
{
|
||||||
var velocity = get_state_velocity();
|
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
|
float eps = JumpVzEpsilon;
|
||||||
// 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;
|
|
||||||
if (MathF.Abs(velocity.X) < eps && MathF.Abs(velocity.Y) < eps && MathF.Abs(velocity.Z) < eps
|
if (MathF.Abs(velocity.X) < eps && MathF.Abs(velocity.Y) < eps && MathF.Abs(velocity.Z) < eps
|
||||||
&& PhysicsObj is not null)
|
&& PhysicsObj is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -1274,51 +1459,116 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
return velocity;
|
return velocity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FUN_00528ec0 — jump_is_allowed ────────────────────────────────────────
|
// ── FUN_005282b0 — jump_is_allowed ────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <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):
|
/// <para>
|
||||||
/// if PhysicsObj == null: return 0x24
|
/// Verbatim (0x005282b0, decomp §3h, W0-pins.md A2/A10):
|
||||||
/// if WeenieObj == null: proceed (no weenie check)
|
/// <code>
|
||||||
/// elif WeenieObj.IsCreature() returns false: proceed
|
/// if (physics_obj != 0) {
|
||||||
/// iVar2 = PhysicsObj
|
/// if (weenie_obj != 0) eax_2 = weenie_obj.IsCreature();
|
||||||
/// if Gravity flag NOT set OR (Contact AND OnWalkable): ← grounded or no gravity
|
/// if (weenie_obj != 0 && eax_2 == 0) goto shared_gate; // non-creature weenie skips ground gate
|
||||||
/// return 0x24 (NotGrounded)
|
/// if (physics_obj == 0 || (state bit 0x400) == 0) goto shared_gate; // gravity-state off skips ground gate
|
||||||
/// if FUN_0050f730() (IsFullyConstrained) != 0: return 0x47
|
/// if (Contact && OnWalkable) goto shared_gate; // grounded also reaches shared gate
|
||||||
/// if pending queue action has non-zero jump error: return that error
|
/// }
|
||||||
/// iVar2 = FUN_00528660() (jump_charge_is_allowed)
|
/// return 0x24; // gravity-bound creature, not grounded
|
||||||
/// if iVar2 == 0:
|
///
|
||||||
/// iVar2 = FUN_005285e0(InterpretedState.ForwardCommand) (motion_allows_jump)
|
/// shared_gate:
|
||||||
/// if iVar2 == 0 AND WeenieObj != null:
|
/// if (IsFullyConstrained(physics_obj) != 0) return 0x47;
|
||||||
/// cVar1 = WeenieObj.CanJump(extent, stamina) → vtable +0x40
|
/// head = pending_motions.head_;
|
||||||
/// if cVar1 == 0: return 0x47
|
/// if (head != 0) eax_6 = head.jump_error_code; // +0xc
|
||||||
/// return iVar2
|
/// 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>
|
/// </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
|
staminaCost = 0;
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Must have gravity and be grounded (Contact + OnWalkable) to start a jump.
|
if (PhysicsObj is not null)
|
||||||
bool hasGravity = PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity);
|
{
|
||||||
bool isGrounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
|
bool nonCreatureWeenie = WeenieObj is not null && !WeenieObj.IsCreature();
|
||||||
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
|
bool gravityStateOff = !PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity);
|
||||||
|
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
|
||||||
|
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
|
||||||
|
|
||||||
if (!hasGravity || !isGrounded)
|
if (nonCreatureWeenie || gravityStateOff || grounded)
|
||||||
return WeenieError.NotGrounded;
|
return JumpIsAllowedSharedGate(extent, ref staminaCost);
|
||||||
|
}
|
||||||
|
|
||||||
// Delegate jump eligibility to WeenieObj if present.
|
return WeenieError.NotGrounded; // 0x24 — gravity-bound creature, not grounded (also physics_obj == null)
|
||||||
if (WeenieObj is not null && !WeenieObj.CanJump(extent))
|
}
|
||||||
return WeenieError.CantJumpLoadedDown;
|
|
||||||
|
|
||||||
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 ────────────────────────────────────
|
// ── FUN_00528dd0 — contact_allows_move ────────────────────────────────────
|
||||||
|
|
@ -1326,43 +1576,40 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determine whether the current contact state allows this motion command.
|
/// Determine whether the current contact state allows this motion command.
|
||||||
///
|
///
|
||||||
/// Decompiled logic (FUN_00528dd0):
|
/// <para>
|
||||||
/// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false:
|
/// Decompiled logic (0x00528240, pseudo-C 305471): allowed (1) when —
|
||||||
/// return 0x49
|
/// motion is TurnRight/TurnLeft (0x6500000D/0E), OR motion is Falling
|
||||||
/// uVar1 = InterpretedState.ForwardCommand
|
/// (0x40000015) or Dead-class (0x40000011), OR the weenie exists and is
|
||||||
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead):
|
/// NOT a creature, OR gravity is off, OR the body has Contact +
|
||||||
/// return 0x48
|
/// OnWalkable. Everything else (a gravity-bound creature without ground
|
||||||
/// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range):
|
/// contact) is blocked — this is the real mechanism behind "airborne
|
||||||
/// return 0x48
|
/// remotes keep their cycle" (K-fix17's empirical guard).
|
||||||
/// uVar2 = PhysicsObj.TransientState
|
/// </para>
|
||||||
/// if (Contact AND OnWalkable) AND ForwardCommand == Ready
|
|
||||||
/// AND SideStepCommand == 0 AND TurnCommand == 0:
|
|
||||||
/// StandingLongJump = true
|
|
||||||
/// return 0
|
|
||||||
///
|
///
|
||||||
/// The return type in the decompile is undefined4 (int), but ACE models it
|
/// <para>
|
||||||
/// as bool (0 = allowed, non-zero = blocked). We model it as bool here for
|
/// The return type in the decompile is undefined4 (int), but ACE models
|
||||||
/// cleaner call sites, treating any non-zero return as "blocked".
|
/// 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>
|
/// </summary>
|
||||||
public bool contact_allows_move(uint motion)
|
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)
|
if (PhysicsObj is null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|
@ -1384,21 +1631,8 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
|
|
||||||
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
|
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
|
||||||
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
|
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
|
||||||
if (!grounded)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// PRE-EXISTING acdream side effect (not part of 0x00528240; kept for
|
return grounded;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── R3-W2 — pending_motions lifecycle ─────────────────────────────────
|
// ── R3-W2 — pending_motions lifecycle ─────────────────────────────────
|
||||||
|
|
@ -1606,7 +1840,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
///
|
///
|
||||||
/// Decompiled logic (FUN_00529710):
|
/// Decompiled logic (FUN_00529710):
|
||||||
/// if PhysicsObj == null: return
|
/// if PhysicsObj == null: return
|
||||||
/// velocity = get_leave_ground_velocity()
|
/// velocity = GetLeaveGroundVelocity()
|
||||||
/// PhysicsObj.set_local_velocity(velocity)
|
/// PhysicsObj.set_local_velocity(velocity)
|
||||||
/// StandingLongJump = false
|
/// StandingLongJump = false
|
||||||
/// JumpExtent = 0
|
/// JumpExtent = 0
|
||||||
|
|
@ -1616,7 +1850,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
if (PhysicsObj is null)
|
if (PhysicsObj is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var velocity = get_leave_ground_velocity();
|
var velocity = GetLeaveGroundVelocity();
|
||||||
PhysicsObj.set_local_velocity(velocity);
|
PhysicsObj.set_local_velocity(velocity);
|
||||||
|
|
||||||
StandingLongJump = false;
|
StandingLongJump = false;
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,20 @@ public sealed class PhysicsBody
|
||||||
/// <summary>Last simulation time used to compute dt (+0xD8).</summary>
|
/// <summary>Last simulation time used to compute dt (+0xD8).</summary>
|
||||||
public double LastUpdateTime { get; set; }
|
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 ────────────────────────────────────────────────
|
// ── convenience helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity);
|
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,24 @@ public sealed class PlayerWeenie : IWeenieObject
|
||||||
|
|
||||||
public bool CanJump(float extent) => true; // burden/stamina checks deferred
|
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>
|
/// <summary>
|
||||||
/// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4
|
/// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4
|
||||||
/// Capped at 4.5 when runSkill >= 800.
|
/// Capped at 4.5 when runSkill >= 800.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,955 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// MotionInterpreterJumpFamilyTests — R3-W3 (closes J5, J6, J7-interp-side,
|
||||||
|
// J16-epsilons). Pins per docs/research/2026-07-02-r3-motioninterp/W0-pins.md
|
||||||
|
// (A1, A2, A5, A6, A10) and r3-motioninterp-decomp.md §3a-3h.
|
||||||
|
//
|
||||||
|
// Source addresses tested:
|
||||||
|
// FUN_00527a50 (0x00527a50) jump_charge_is_allowed @304935
|
||||||
|
// FUN_005281c0 (0x005281c0) charge_jump @305448
|
||||||
|
// FUN_00527aa0 (0x00527aa0) get_jump_v_z @304953
|
||||||
|
// FUN_005280c0 (0x005280c0) get_leave_ground_velocity @305404
|
||||||
|
// FUN_005282b0 (0x005282b0) jump_is_allowed (full chain)
|
||||||
|
// FUN_00528780 (0x00528780) jump @305792
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Fake WeenieObject for jump-family test isolation.</summary>
|
||||||
|
file sealed class FakeWeenie : IWeenieObject
|
||||||
|
{
|
||||||
|
public float RunRate = 1.0f;
|
||||||
|
public float JumpVz = 10.0f;
|
||||||
|
public bool CanJumpResult = true;
|
||||||
|
public bool InqRunRateResult = true;
|
||||||
|
public bool InqJumpVelocityResult = true;
|
||||||
|
|
||||||
|
/// <summary>Controls the JumpStaminaCost virtual (vtable +0x44 per the
|
||||||
|
/// raw 305549-305556 shape): true = affordable (pass), false = 0x47.</summary>
|
||||||
|
public bool JumpStaminaCostResult = true;
|
||||||
|
public int JumpStaminaCostCalls;
|
||||||
|
|
||||||
|
public bool InqJumpVelocity(float extent, out float vz)
|
||||||
|
{
|
||||||
|
vz = JumpVz * extent;
|
||||||
|
return InqJumpVelocityResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool InqRunRate(out float rate)
|
||||||
|
{
|
||||||
|
rate = RunRate;
|
||||||
|
return InqRunRateResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanJump(float extent) => CanJumpResult;
|
||||||
|
|
||||||
|
public bool JumpStaminaCost(float extent, out int cost)
|
||||||
|
{
|
||||||
|
JumpStaminaCostCalls++;
|
||||||
|
cost = 0;
|
||||||
|
return JumpStaminaCostResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MotionInterpreterJumpFamilyTests
|
||||||
|
{
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static PhysicsBody MakeGrounded()
|
||||||
|
{
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||||
|
};
|
||||||
|
body.TransientState = TransientStateFlags.Contact
|
||||||
|
| TransientStateFlags.OnWalkable
|
||||||
|
| TransientStateFlags.Active;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhysicsBody MakeAirborne()
|
||||||
|
{
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||||
|
};
|
||||||
|
body.TransientState = TransientStateFlags.Active;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null)
|
||||||
|
{
|
||||||
|
body ??= MakeGrounded();
|
||||||
|
return new MotionInterpreter(body, weenie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// jump_charge_is_allowed — 0x00527a50 @304935
|
||||||
|
//
|
||||||
|
// Raw (r3-motioninterp-decomp.md §3b):
|
||||||
|
// weenie_obj = this->weenie_obj;
|
||||||
|
// if (weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0)
|
||||||
|
// return 0x49;
|
||||||
|
// forward_command = this->interpreted_state.forward_command;
|
||||||
|
// if (forward_command != 0x40000008
|
||||||
|
// && (forward_command <= 0x41000011 || forward_command > 0x41000014))
|
||||||
|
// return 0;
|
||||||
|
// return 0x48;
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpChargeIsAllowed_NoWeenie_ReadyForward_ReturnsNone()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||||
|
|
||||||
|
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpChargeIsAllowed_WeenieCanJumpFalse_ReturnsCantJumpLoadedDown()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||||
|
var interp = MakeInterp(weenie: weenie);
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||||
|
|
||||||
|
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpChargeIsAllowed_Fallen_ReturnsYouCantJumpFromThisPosition()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Fallen; // 0x40000008
|
||||||
|
|
||||||
|
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(MotionCommand.Crouch)] // 0x41000012
|
||||||
|
[InlineData(MotionCommand.Sitting)] // 0x41000013
|
||||||
|
[InlineData(MotionCommand.Sleeping)] // 0x41000014
|
||||||
|
public void JumpChargeIsAllowed_CrouchSitSleepRange_ReturnsYouCantJumpFromThisPosition(uint forward)
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = forward;
|
||||||
|
|
||||||
|
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpChargeIsAllowed_CrouchLowerBoundExact_Passes()
|
||||||
|
{
|
||||||
|
// forward_command <= 0x41000011 passes (the gate is a strict
|
||||||
|
// inequality on the LOWER bound: forward_command > 0x41000011 is
|
||||||
|
// required to even consider the block). 0x41000011 == CrouchLowerBound
|
||||||
|
// itself must PASS (not blocked) — verbatim boundary.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.CrouchLowerBound; // 0x41000011
|
||||||
|
|
||||||
|
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpChargeIsAllowed_SleepUpperBoundExact_Blocked()
|
||||||
|
{
|
||||||
|
// forward_command > 0x41000014 passes; == 0x41000014 (Sleeping) is
|
||||||
|
// still inside the blocked range (already covered above), but this
|
||||||
|
// pins the boundary explicitly: 0x41000015 (one past Sleeping) must
|
||||||
|
// PASS.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.CrouchUpperExclusive; // 0x41000015
|
||||||
|
|
||||||
|
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpChargeIsAllowed_Falling_Passes()
|
||||||
|
{
|
||||||
|
// Falling (0x40000015) is NOT Fallen (0x40000008) — must pass this
|
||||||
|
// gate (though jump_is_allowed's separate ground check blocks
|
||||||
|
// mid-air jumps by a different mechanism — A1 adjudication).
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Falling;
|
||||||
|
|
||||||
|
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpChargeIsAllowed_CanJumpCheckedBeforePostureCheck()
|
||||||
|
{
|
||||||
|
// Raw order: CanJump gate FIRST, forward_command gate SECOND. A
|
||||||
|
// weenie that refuses CanJump returns 0x49 even in a posture that
|
||||||
|
// would otherwise pass.
|
||||||
|
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||||
|
var interp = MakeInterp(weenie: weenie);
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Falling; // would pass posture gate
|
||||||
|
|
||||||
|
var result = interp.JumpChargeIsAllowed(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// charge_jump — 0x005281c0 @305448 (closes J6)
|
||||||
|
//
|
||||||
|
// Raw (r3-motioninterp-decomp.md §3e):
|
||||||
|
// weenie_obj = this->weenie_obj;
|
||||||
|
// if (weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0)
|
||||||
|
// return 0x49;
|
||||||
|
// forward_command = this->interpreted_state.forward_command;
|
||||||
|
// if (forward_command == 0x40000008
|
||||||
|
// || (forward_command > 0x41000011 && forward_command <= 0x41000014))
|
||||||
|
// return 0x48;
|
||||||
|
// transient_state = physics_obj->transient_state;
|
||||||
|
// if ((transient_state & 1) != 0 && (transient_state & 2) != 0
|
||||||
|
// && forward_command == 0x41000003
|
||||||
|
// && interpreted_state.sidestep_command == 0
|
||||||
|
// && interpreted_state.turn_command == 0)
|
||||||
|
// standing_longjump = 1;
|
||||||
|
// return 0;
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChargeJump_GroundedIdle_ArmsStandingLongJump()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
// Default interpreted state: ForwardCommand=Ready, SideStep=0, Turn=0.
|
||||||
|
|
||||||
|
var result = interp.ChargeJump();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.True(interp.StandingLongJump, "grounded + idle must arm StandingLongJump");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChargeJump_GroundedButMoving_DoesNotArmStandingLongJump()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||||
|
|
||||||
|
var result = interp.ChargeJump();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.False(interp.StandingLongJump, "must not arm while moving forward");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChargeJump_GroundedIdleButSidestepping_DoesNotArmStandingLongJump()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||||
|
interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight;
|
||||||
|
|
||||||
|
var result = interp.ChargeJump();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.False(interp.StandingLongJump, "must not arm while sidestepping");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChargeJump_GroundedIdleButTurning_DoesNotArmStandingLongJump()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||||
|
interp.InterpretedState.TurnCommand = MotionCommand.TurnRight;
|
||||||
|
|
||||||
|
var result = interp.ChargeJump();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.False(interp.StandingLongJump, "must not arm while turning");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChargeJump_Airborne_DoesNotArmStandingLongJump()
|
||||||
|
{
|
||||||
|
var body = MakeAirborne();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
|
||||||
|
var result = interp.ChargeJump();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.False(interp.StandingLongJump, "must not arm while airborne (no Contact+OnWalkable)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChargeJump_WeenieBlocksCanJump_ReturnsCantJumpLoadedDown_NoArm()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body, weenie);
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
|
||||||
|
var result = interp.ChargeJump();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||||
|
Assert.False(interp.StandingLongJump);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChargeJump_Fallen_ReturnsYouCantJumpFromThisPosition_NoArm()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Fallen;
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
|
||||||
|
var result = interp.ChargeJump();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||||
|
Assert.False(interp.StandingLongJump);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChargeJump_CrouchRange_ReturnsYouCantJumpFromThisPosition_NoArm()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Sitting;
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
|
||||||
|
var result = interp.ChargeJump();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||||
|
Assert.False(interp.StandingLongJump);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── J6 regression pin: contact_allows_move no longer arms StandingLongJump ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ContactAllowsMove_GroundedAndIdle_DoesNotArmStandingLongJump()
|
||||||
|
{
|
||||||
|
// J6: the S2a-flagged misattribution is DELETED. Only ChargeJump
|
||||||
|
// arms StandingLongJump now — contact_allows_move must have no side
|
||||||
|
// effect on this flag at all, in either direction.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
|
||||||
|
interp.contact_allows_move(MotionCommand.WalkForward);
|
||||||
|
|
||||||
|
Assert.False(interp.StandingLongJump,
|
||||||
|
"contact_allows_move must never arm StandingLongJump (J6 — moved to ChargeJump exclusively)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ContactAllowsMove_GroundedAndIdle_DoesNotClearPreArmedStandingLongJump()
|
||||||
|
{
|
||||||
|
// contact_allows_move must not touch the flag at all — verify it
|
||||||
|
// doesn't clear a flag armed by a prior ChargeJump call either.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.StandingLongJump = true;
|
||||||
|
|
||||||
|
interp.contact_allows_move(MotionCommand.WalkForward);
|
||||||
|
|
||||||
|
Assert.True(interp.StandingLongJump,
|
||||||
|
"contact_allows_move must not clear an externally-armed StandingLongJump flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// get_jump_v_z — 0x00527aa0 @304953 (A5; closes J16-epsilons)
|
||||||
|
// Epsilon: 0.000199999995f (NOT the old 0.001 JumpExtentEpsilon).
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetJumpVZ_EpsilonIsRetailLiteral()
|
||||||
|
{
|
||||||
|
Assert.Equal(0.000199999995f, MotionInterpreter.JumpVzEpsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetJumpVZ_JustBelowEpsilon_ReturnsZero()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||||
|
var interp = MakeInterp(weenie: weenie);
|
||||||
|
interp.JumpExtent = 0.0001f; // < 0.000199999995f
|
||||||
|
|
||||||
|
Assert.Equal(0f, interp.GetJumpVZ(), precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetJumpVZ_JustAboveEpsilon_DelegatesToWeenie()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||||
|
var interp = MakeInterp(weenie: weenie);
|
||||||
|
interp.JumpExtent = 0.0003f; // > 0.000199999995f
|
||||||
|
|
||||||
|
float vz = interp.GetJumpVZ();
|
||||||
|
|
||||||
|
// Not the zero-fallback: delegates to InqJumpVelocity(extent, ...).
|
||||||
|
Assert.Equal(weenie.JumpVz * 0.0003f, vz, precision: 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetJumpVZ_ExactlyOldWrongEpsilon_0_001_IsAboveRetailEpsilon_NotZero()
|
||||||
|
{
|
||||||
|
// Regression pin: the OLD (wrong) epsilon was 0.001. At extent =
|
||||||
|
// 0.0005 the old code would have returned 0 (0.0005 < 0.001), but
|
||||||
|
// retail's epsilon (0.0002) means 0.0005 must NOT hit the zero path.
|
||||||
|
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||||
|
var interp = MakeInterp(weenie: weenie);
|
||||||
|
interp.JumpExtent = 0.0005f;
|
||||||
|
|
||||||
|
float vz = interp.GetJumpVZ();
|
||||||
|
|
||||||
|
Assert.NotEqual(0f, vz);
|
||||||
|
Assert.Equal(weenie.JumpVz * 0.0005f, vz, precision: 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetJumpVZ_NoWeenie_ReturnsDefault()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.JumpExtent = 0.5f;
|
||||||
|
|
||||||
|
Assert.Equal(MotionInterpreter.DefaultJumpVz, interp.GetJumpVZ(), precision: 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetJumpVZ_ExtentClampsAtMax1()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||||
|
var interp = MakeInterp(weenie: weenie);
|
||||||
|
interp.JumpExtent = 5.0f; // over-clamped
|
||||||
|
|
||||||
|
float vz = interp.GetJumpVZ();
|
||||||
|
|
||||||
|
Assert.Equal(10.0f, vz, precision: 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetJumpVZ_WeenieRefusesInqJumpVelocity_ReturnsZero()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { InqJumpVelocityResult = false };
|
||||||
|
var interp = MakeInterp(weenie: weenie);
|
||||||
|
interp.JumpExtent = 0.5f;
|
||||||
|
|
||||||
|
Assert.Equal(0f, interp.GetJumpVZ(), precision: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// get_leave_ground_velocity — 0x005280c0 @305404 (A6; closes J16-epsilons)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLeaveGroundVelocity_WalkingForward_HasPositiveYAndZ()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = new MotionInterpreter(body, weenie)
|
||||||
|
{
|
||||||
|
JumpExtent = 1.0f,
|
||||||
|
};
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||||
|
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||||
|
|
||||||
|
var vel = interp.GetLeaveGroundVelocity();
|
||||||
|
|
||||||
|
Assert.True(vel.Y > 0f, "Y velocity should be positive when walking forward");
|
||||||
|
Assert.True(vel.Z > 0f, "Z (jump) velocity should be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLeaveGroundVelocity_TrulyZero_FallsBackToWorldVelocityGlobalToLocal()
|
||||||
|
{
|
||||||
|
// A6: fallback fires ONLY when |x| AND |y| AND |z| are ALL <
|
||||||
|
// epsilon. When it fires, ALL THREE components (including the
|
||||||
|
// just-computed jump v_z) are overwritten with
|
||||||
|
// globaltolocal(m_velocityVector) — the existing
|
||||||
|
// Quaternion.Inverse(Orientation) transform IS global->local; keep.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
body.Velocity = new Vector3(2.0f, 3.0f, 0f); // nonzero WORLD velocity (momentum)
|
||||||
|
body.Orientation = Quaternion.Identity; // identity: local == global for this pin
|
||||||
|
var interp = new MotionInterpreter(body)
|
||||||
|
{
|
||||||
|
JumpExtent = 0f, // below epsilon -> get_jump_v_z returns 0
|
||||||
|
};
|
||||||
|
// Default interpreted state = Ready (no walk/run/sidestep command) -> XY also 0.
|
||||||
|
|
||||||
|
var vel = interp.GetLeaveGroundVelocity();
|
||||||
|
|
||||||
|
// Fallback must have fired: velocity now reflects body.Velocity
|
||||||
|
// (global->local via inverse-identity == identity here), NOT the
|
||||||
|
// all-zero get_state_velocity()/get_jump_v_z() composition.
|
||||||
|
Assert.Equal(2.0f, vel.X, precision: 4);
|
||||||
|
Assert.Equal(3.0f, vel.Y, precision: 4);
|
||||||
|
Assert.Equal(0.0f, vel.Z, precision: 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLeaveGroundVelocity_TrulyZero_NoWorldVelocity_StaysZero()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
body.Velocity = Vector3.Zero;
|
||||||
|
var interp = new MotionInterpreter(body)
|
||||||
|
{
|
||||||
|
JumpExtent = 0f,
|
||||||
|
};
|
||||||
|
|
||||||
|
var vel = interp.GetLeaveGroundVelocity();
|
||||||
|
|
||||||
|
Assert.Equal(0f, vel.X, precision: 5);
|
||||||
|
Assert.Equal(0f, vel.Y, precision: 5);
|
||||||
|
Assert.Equal(0f, vel.Z, precision: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLeaveGroundVelocity_NonzeroXOnly_DoesNotTriggerFallback()
|
||||||
|
{
|
||||||
|
// A6: the fallback requires ALL THREE axes below epsilon. A
|
||||||
|
// sidestep-only launch (nonzero X, zero Y, zero jump Z) must NOT
|
||||||
|
// trigger the momentum fallback — it should keep the computed X.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
body.Velocity = new Vector3(99f, 99f, 99f); // if fallback wrongly fired, this would show
|
||||||
|
var interp = new MotionInterpreter(body)
|
||||||
|
{
|
||||||
|
JumpExtent = 0f, // jump v_z stays 0
|
||||||
|
};
|
||||||
|
interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight;
|
||||||
|
interp.InterpretedState.SideStepSpeed = 1.0f;
|
||||||
|
|
||||||
|
var vel = interp.GetLeaveGroundVelocity();
|
||||||
|
|
||||||
|
// X must be the sidestep-derived value, not 99 (fallback did not fire).
|
||||||
|
Assert.NotEqual(99f, vel.X);
|
||||||
|
Assert.True(vel.X > 0f, "sidestep should have produced nonzero X from get_state_velocity");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLeaveGroundVelocity_EpsilonBoundary_JustBelow_TriggersFallback()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
body.Velocity = new Vector3(5f, 0f, 0f);
|
||||||
|
body.Orientation = Quaternion.Identity;
|
||||||
|
var interp = new MotionInterpreter(body)
|
||||||
|
{
|
||||||
|
JumpExtent = 0.0001f, // < 0.000199999995f -> get_jump_v_z() == 0
|
||||||
|
};
|
||||||
|
// Default interpreted state -> XY == 0 too. All three axes are
|
||||||
|
// exactly at the "essentially zero" edge -> fallback fires.
|
||||||
|
|
||||||
|
var vel = interp.GetLeaveGroundVelocity();
|
||||||
|
|
||||||
|
Assert.Equal(5f, vel.X, precision: 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// jump_is_allowed — 0x005282b0 (A2, A10; closes J5)
|
||||||
|
//
|
||||||
|
// Raw entry shape (r3-motioninterp-decomp.md §3h):
|
||||||
|
// if (physics_obj != 0) {
|
||||||
|
// if (weenie != 0) eax_2 = weenie->IsCreature();
|
||||||
|
// if (weenie != 0 && eax_2 == 0) // non-creature weenie
|
||||||
|
// goto shared_gate;
|
||||||
|
// if (physics_obj == 0 || (state bit 0x400) == 0) // gravity-state off
|
||||||
|
// goto shared_gate;
|
||||||
|
// if (Contact && OnWalkable) // grounded
|
||||||
|
// goto shared_gate;
|
||||||
|
// }
|
||||||
|
// return 0x24;
|
||||||
|
//
|
||||||
|
// shared_gate:
|
||||||
|
// if (IsFullyConstrained) return 0x47;
|
||||||
|
// head = pending_motions.head_;
|
||||||
|
// if (head != 0) eax_6 = head.jump_error_code;
|
||||||
|
// if (head == 0 || eax_6 == 0) {
|
||||||
|
// eax_6 = jump_charge_is_allowed();
|
||||||
|
// if (eax_6 == 0) {
|
||||||
|
// eax_7 = motion_allows_jump(interpreted_state.forward_command);
|
||||||
|
// if (eax_7 != 0) return eax_7;
|
||||||
|
// if (weenie_obj_1 == 0) return eax_7; // == 0 here
|
||||||
|
// eax_6 = 0x47;
|
||||||
|
// if (weenie_obj_1->JumpStaminaCost(extent, &cost) != 0)
|
||||||
|
// return eax_7; // == 0 (success)
|
||||||
|
// // JumpStaminaCost returned 0 (false) -> falls through, eax_6 stays 0x47
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return eax_6;
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_NullPhysicsObj_ReturnsNotGrounded()
|
||||||
|
{
|
||||||
|
var interp = new MotionInterpreter();
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.NotGrounded, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_CreatureWeenie_Airborne_GravityState_ReturnsNotGrounded()
|
||||||
|
{
|
||||||
|
var body = MakeAirborne();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.NotGrounded, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_CreatureWeenie_Grounded_ReturnsNone()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out int cost);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Equal(0, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_NonCreatureWeenie_Airborne_SkipsGroundGate_ReturnsNone()
|
||||||
|
{
|
||||||
|
// weenie present && !IsCreature() -> skip the ground check entirely,
|
||||||
|
// go straight to the shared gate. Airborne here must NOT block.
|
||||||
|
var weenie = new FakeWeenie();
|
||||||
|
var body = MakeAirborne();
|
||||||
|
var interp = MakeInterp(body, weenie);
|
||||||
|
// Non-creature: use an explicit fake that overrides IsCreature.
|
||||||
|
var nonCreature = new NonCreatureFakeWeenie();
|
||||||
|
interp.WeenieObj = nonCreature;
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_NoWeenie_GravityStateOff_SkipsGroundGate_ReturnsNone()
|
||||||
|
{
|
||||||
|
// "no weenie" -> weenie_obj == 0 branch is fine either way (the
|
||||||
|
// `weenie != 0 && eax_2 == 0` non-creature-skip only applies when a
|
||||||
|
// weenie exists); but the SEPARATE gravity-state-off skip
|
||||||
|
// (state bit 0x400 clear) also reaches the shared gate regardless
|
||||||
|
// of weenie presence. Pin: no gravity flag -> shared gate, not
|
||||||
|
// blocked outright.
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
State = PhysicsStateFlags.None, // gravity flag clear
|
||||||
|
TransientState = TransientStateFlags.None,
|
||||||
|
};
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_IsFullyConstrained_ReturnsGeneralMovementFailure()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
body.IsFullyConstrained = true;
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_IsFullyConstrained_BeatsPendingHeadPeek()
|
||||||
|
{
|
||||||
|
// A2 ordering: IsFullyConstrained is checked BEFORE the pending-head
|
||||||
|
// peek. Even if the head carries a DIFFERENT nonzero error, the
|
||||||
|
// IsFullyConstrained code (0x47) must win.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
body.IsFullyConstrained = true;
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.AddToQueue(0, MotionCommand.WalkForward, (uint)WeenieError.CantJumpLoadedDown);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.GeneralMovementFailure, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_PendingHeadNonzeroError_ShortCircuitsChain()
|
||||||
|
{
|
||||||
|
// A2: the peek fires WHENEVER the queue is non-empty (no Count>1
|
||||||
|
// gate). A nonzero head.JumpErrorCode is returned VERBATIM, and the
|
||||||
|
// charge/motion/stamina chain is skipped entirely (proven via the
|
||||||
|
// weenie's CanJump/JumpStaminaCost never being consulted for THIS
|
||||||
|
// code path — CanJump would return CantJumpLoadedDown some other
|
||||||
|
// way, so instead assert JumpStaminaCost, which only the fallthrough
|
||||||
|
// chain calls, is never invoked).
|
||||||
|
var weenie = new FakeWeenie();
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body, weenie);
|
||||||
|
interp.AddToQueue(0, MotionCommand.WalkForward, (uint)WeenieError.YouCantJumpFromThisPosition);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||||
|
Assert.Equal(0, weenie.JumpStaminaCostCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_PendingHeadZeroError_FallsThroughToChain()
|
||||||
|
{
|
||||||
|
// A2: head exists but JumpErrorCode == 0 -> chain still runs.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.AddToQueue(0, MotionCommand.WalkForward, jumpErrorCode: 0);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_EmptyQueue_FallsThroughToChain()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
// No AddToQueue call -> head is null -> chain runs normally.
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_ChargeBlocked_Fallen_ReturnsYouCantJumpFromThisPosition()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Fallen;
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_ChargeBlocked_WeenieCanJumpFalse_ReturnsCantJumpLoadedDown()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body, weenie);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_MotionAllowsJumpBlocks_ReturnsYouCantJumpFromThisPosition()
|
||||||
|
{
|
||||||
|
// jump_charge_is_allowed passes (Ready forward command), but the
|
||||||
|
// SEPARATE motion_allows_jump(forward_command) check blocks — use a
|
||||||
|
// forward command in the [0x4000001e, 0x40000039] blocklist band
|
||||||
|
// that jump_charge_is_allowed does NOT gate on (only Fallen/Crouch
|
||||||
|
// range does), so this pins the double-check shape.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.InterpretedState.ForwardCommand = 0x40000020u; // inside [0x4000001e, 0x40000039]
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_NoWeenie_PassesChain_ReturnsNone()
|
||||||
|
{
|
||||||
|
// motion_allows_jump passes and weenie_obj == null -> return eax_7
|
||||||
|
// (== 0) directly, skipping the JumpStaminaCost consult entirely.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body); // no weenie
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out int cost);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Equal(0, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_WeenieJumpStaminaCostRefuses_ReturnsGeneralMovementFailure()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { JumpStaminaCostResult = false };
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body, weenie);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47
|
||||||
|
Assert.Equal(1, weenie.JumpStaminaCostCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JumpIsAllowed_WeenieJumpStaminaCostAffords_ReturnsNone()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { JumpStaminaCostResult = true };
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body, weenie);
|
||||||
|
|
||||||
|
var result = interp.jump_is_allowed(0.5f, out _);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Equal(1, weenie.JumpStaminaCostCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fake weenie whose IsCreature() returns false, for the
|
||||||
|
/// non-creature ground-gate-skip pin.</summary>
|
||||||
|
private sealed class NonCreatureFakeWeenie : IWeenieObject
|
||||||
|
{
|
||||||
|
public bool InqJumpVelocity(float extent, out float vz) { vz = 10f; return true; }
|
||||||
|
public bool InqRunRate(out float rate) { rate = 1f; return true; }
|
||||||
|
public bool CanJump(float extent) => true;
|
||||||
|
public bool IsCreature() => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// jump — 0x00528780 @305792 (closes J7-interp-side)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Jump_Grounded_SetsJumpExtentAndLeavesWalkable()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
|
||||||
|
var result = interp.jump(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Equal(0.5f, interp.JumpExtent, precision: 5);
|
||||||
|
Assert.False(body.OnWalkable, "Body should no longer be on walkable after jump");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Jump_Airborne_ReturnsNotGrounded()
|
||||||
|
{
|
||||||
|
var body = MakeAirborne();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
|
||||||
|
var result = interp.jump(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.NotGrounded, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Jump_WeenieBlocksJump_ClearsStandingLongJump()
|
||||||
|
{
|
||||||
|
var weenie = new FakeWeenie { CanJumpResult = false };
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body, weenie);
|
||||||
|
interp.StandingLongJump = true;
|
||||||
|
|
||||||
|
var result = interp.jump(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
||||||
|
Assert.False(interp.StandingLongJump, "a failed jump attempt cancels any pending StandingLongJump");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Jump_NullPhysicsObj_ReturnsNoPhysicsObject()
|
||||||
|
{
|
||||||
|
var interp = new MotionInterpreter();
|
||||||
|
|
||||||
|
var result = interp.jump(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Jump_Success_DoesNotClearStandingLongJump()
|
||||||
|
{
|
||||||
|
// Only the FAILURE path clears StandingLongJump (raw 305800:
|
||||||
|
// `this->standing_longjump = 0;` sits inside the `if (result != 0)`
|
||||||
|
// branch only). A successful jump leaves whatever value the flag
|
||||||
|
// already had.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
interp.StandingLongJump = true;
|
||||||
|
|
||||||
|
var result = interp.jump(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.True(interp.StandingLongJump, "success path must not touch StandingLongJump");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Jump_CallsInterruptCurrentMovementSeam()
|
||||||
|
{
|
||||||
|
// J7 / "Adjacent findings": jump() ALWAYS calls
|
||||||
|
// interrupt_current_movement first (raw @305800), regardless of
|
||||||
|
// outcome. Register no-op seam until R4's cancel_moveto exists.
|
||||||
|
var body = MakeGrounded();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
bool called = false;
|
||||||
|
interp.InterruptCurrentMovement = () => called = true;
|
||||||
|
|
||||||
|
interp.jump(0.5f);
|
||||||
|
|
||||||
|
Assert.True(called, "jump() must invoke the InterruptCurrentMovement seam");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Jump_CallsInterruptCurrentMovementSeam_EvenOnFailure()
|
||||||
|
{
|
||||||
|
var body = MakeAirborne();
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
bool called = false;
|
||||||
|
interp.InterruptCurrentMovement = () => called = true;
|
||||||
|
|
||||||
|
interp.jump(0.5f);
|
||||||
|
|
||||||
|
Assert.True(called, "jump() calls interrupt_current_movement before jump_is_allowed, unconditionally");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// IWeenieObject.IsThePlayer — default false; PlayerWeenie true (for W4's
|
||||||
|
// A3 dispatch gate; no consumer yet in W3).
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IWeenieObject_IsThePlayer_DefaultsFalse()
|
||||||
|
{
|
||||||
|
// Default interface members are only reachable through the
|
||||||
|
// interface type, not the concrete implementer — call via
|
||||||
|
// IWeenieObject to exercise the DIM (not a compile error site).
|
||||||
|
IWeenieObject weenie = new FakeWeenie();
|
||||||
|
|
||||||
|
Assert.False(weenie.IsThePlayer());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayerWeenie_IsThePlayer_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var weenie = new PlayerWeenie();
|
||||||
|
|
||||||
|
Assert.True(weenie.IsThePlayer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -423,220 +423,20 @@ public sealed class MotionInterpreterTests
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// jump (FUN_00529390)
|
// jump (FUN_00529390) / get_jump_v_z (FUN_005286b0) /
|
||||||
|
// get_leave_ground_velocity (FUN_00528cd0) / jump_is_allowed (FUN_00528ec0)
|
||||||
|
//
|
||||||
|
// R3-W3 (closes J5, J6, J7-interp-side, J16-epsilons): the full jump
|
||||||
|
// family (jump/GetJumpVZ/GetLeaveGroundVelocity/jump_is_allowed) plus
|
||||||
|
// jump_charge_is_allowed/ChargeJump now live in
|
||||||
|
// MotionInterpreterJumpFamilyTests.cs, ported against the verbatim
|
||||||
|
// decomp (r3-motioninterp-decomp.md §3a-3h) and W0-pins.md (A2/A5/A6/A10)
|
||||||
|
// instead of this file's pre-W3 approximation-era pins (wrong 0.001
|
||||||
|
// epsilon, jump_is_allowed(extent, int) shape, no IsFullyConstrained /
|
||||||
|
// pending-head-peek / stamina chain). See that file for the full gate
|
||||||
|
// tables and epsilon-boundary pins.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Jump_Grounded_SetsJumpExtentAndLeavesWalkable()
|
|
||||||
{
|
|
||||||
var body = MakeGrounded();
|
|
||||||
var interp = MakeInterp(body);
|
|
||||||
|
|
||||||
var result = interp.jump(0.5f);
|
|
||||||
|
|
||||||
Assert.Equal(WeenieError.None, result);
|
|
||||||
Assert.Equal(0.5f, interp.JumpExtent, precision: 5);
|
|
||||||
Assert.False(body.OnWalkable, "Body should no longer be on walkable after jump");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Jump_Airborne_ReturnsNotGrounded()
|
|
||||||
{
|
|
||||||
// R3-W1 renumber (A10): airborne is 0x24 (NotGrounded), not 0x48
|
|
||||||
// (0x48 is now reserved for the motion_allows_jump blocklist).
|
|
||||||
var body = MakeAirborne();
|
|
||||||
var interp = MakeInterp(body);
|
|
||||||
|
|
||||||
var result = interp.jump(0.5f);
|
|
||||||
|
|
||||||
Assert.Equal(WeenieError.NotGrounded, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Jump_WeenieBlocksJump_ClearStandingLongJump()
|
|
||||||
{
|
|
||||||
var weenie = new FakeWeenie { CanJumpResult = false };
|
|
||||||
var body = MakeGrounded();
|
|
||||||
var interp = MakeInterp(body, weenie);
|
|
||||||
interp.StandingLongJump = true;
|
|
||||||
|
|
||||||
var result = interp.jump(0.5f);
|
|
||||||
|
|
||||||
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
|
||||||
Assert.False(interp.StandingLongJump);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Jump_NullPhysicsObj_ReturnsNoPhysicsObject()
|
|
||||||
{
|
|
||||||
var interp = new MotionInterpreter();
|
|
||||||
|
|
||||||
var result = interp.jump(0.5f);
|
|
||||||
|
|
||||||
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// get_jump_v_z (FUN_005286b0)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetJumpVz_ZeroExtent_ReturnsZero()
|
|
||||||
{
|
|
||||||
var interp = MakeInterp();
|
|
||||||
interp.JumpExtent = 0f;
|
|
||||||
|
|
||||||
Assert.Equal(0f, interp.get_jump_v_z(), precision: 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetJumpVz_NoWeenie_ReturnsDefault()
|
|
||||||
{
|
|
||||||
var interp = MakeInterp();
|
|
||||||
interp.JumpExtent = 0.5f;
|
|
||||||
|
|
||||||
Assert.Equal(MotionInterpreter.DefaultJumpVz, interp.get_jump_v_z(), precision: 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetJumpVz_WithWeenie_DelegatesToInqJumpVelocity()
|
|
||||||
{
|
|
||||||
var weenie = new FakeWeenie { JumpVz = 8.0f };
|
|
||||||
var interp = MakeInterp(weenie: weenie);
|
|
||||||
interp.JumpExtent = 1.0f;
|
|
||||||
|
|
||||||
float vz = interp.get_jump_v_z();
|
|
||||||
|
|
||||||
// FakeWeenie returns JumpVz * extent = 8.0 * 1.0 = 8.0
|
|
||||||
Assert.Equal(8.0f, vz, precision: 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetJumpVz_ExtentClampsAt1()
|
|
||||||
{
|
|
||||||
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
|
||||||
var interp = MakeInterp(weenie: weenie);
|
|
||||||
interp.JumpExtent = 5.0f; // over-clamped
|
|
||||||
|
|
||||||
float vz = interp.get_jump_v_z();
|
|
||||||
|
|
||||||
// Should clamp extent to 1.0: FakeWeenie returns 10.0 * 1.0 = 10.0
|
|
||||||
Assert.Equal(10.0f, vz, precision: 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetJumpVz_WeenieReturnsFailure_ReturnsZero()
|
|
||||||
{
|
|
||||||
var weenie = new FakeWeenie { InqJumpVelocityResult = false };
|
|
||||||
var interp = MakeInterp(weenie: weenie);
|
|
||||||
interp.JumpExtent = 0.5f;
|
|
||||||
|
|
||||||
Assert.Equal(0f, interp.get_jump_v_z(), precision: 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// get_leave_ground_velocity (FUN_00528cd0)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetLeaveGroundVelocity_WalkingForward_HasPositiveYAndZ()
|
|
||||||
{
|
|
||||||
var weenie = new FakeWeenie { JumpVz = 10.0f };
|
|
||||||
var body = MakeGrounded();
|
|
||||||
var interp = new MotionInterpreter(body, weenie)
|
|
||||||
{
|
|
||||||
JumpExtent = 1.0f,
|
|
||||||
};
|
|
||||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
|
||||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
|
||||||
|
|
||||||
var vel = interp.get_leave_ground_velocity();
|
|
||||||
|
|
||||||
Assert.True(vel.Y > 0f, "Y velocity should be positive when walking forward");
|
|
||||||
Assert.True(vel.Z > 0f, "Z (jump) velocity should be positive");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetLeaveGroundVelocity_Standing_ZeroExtent_ReturnsZeroXY()
|
|
||||||
{
|
|
||||||
var body = MakeGrounded();
|
|
||||||
var interp = new MotionInterpreter(body)
|
|
||||||
{
|
|
||||||
JumpExtent = 0f, // below epsilon
|
|
||||||
};
|
|
||||||
// Default interpreted state = Ready (no walk/run command)
|
|
||||||
|
|
||||||
var vel = interp.get_leave_ground_velocity();
|
|
||||||
|
|
||||||
Assert.Equal(0f, vel.X, precision: 5);
|
|
||||||
Assert.Equal(0f, vel.Y, precision: 5);
|
|
||||||
Assert.Equal(0f, vel.Z, precision: 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// jump_is_allowed (FUN_00528ec0)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void JumpIsAllowed_Grounded_ReturnsNone()
|
|
||||||
{
|
|
||||||
var interp = MakeInterp(MakeGrounded());
|
|
||||||
|
|
||||||
var result = interp.jump_is_allowed(0.5f, 0);
|
|
||||||
|
|
||||||
Assert.Equal(WeenieError.None, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void JumpIsAllowed_Airborne_ReturnsNotGrounded()
|
|
||||||
{
|
|
||||||
var interp = MakeInterp(MakeAirborne());
|
|
||||||
|
|
||||||
var result = interp.jump_is_allowed(0.5f, 0);
|
|
||||||
|
|
||||||
Assert.Equal(WeenieError.NotGrounded, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void JumpIsAllowed_WeenieBlocks_ReturnsCantJumpLoadedDown()
|
|
||||||
{
|
|
||||||
var weenie = new FakeWeenie { CanJumpResult = false };
|
|
||||||
var interp = MakeInterp(MakeGrounded(), weenie);
|
|
||||||
|
|
||||||
var result = interp.jump_is_allowed(0.5f, 0);
|
|
||||||
|
|
||||||
Assert.Equal(WeenieError.CantJumpLoadedDown, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void JumpIsAllowed_NoGravityFlag_ReturnsNotGrounded()
|
|
||||||
{
|
|
||||||
var body = new PhysicsBody
|
|
||||||
{
|
|
||||||
State = PhysicsStateFlags.None, // no gravity
|
|
||||||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
|
||||||
};
|
|
||||||
var interp = MakeInterp(body);
|
|
||||||
|
|
||||||
// No gravity → must be airborne-style
|
|
||||||
var result = interp.jump_is_allowed(0.5f, 0);
|
|
||||||
|
|
||||||
Assert.Equal(WeenieError.NotGrounded, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void JumpIsAllowed_NullPhysicsObj_ReturnsNotGrounded()
|
|
||||||
{
|
|
||||||
// A10 note: jump_is_allowed returns 0x24 (NotGrounded), NOT 8, when
|
|
||||||
// physics_obj == null — the "8 = no physics obj" convention that
|
|
||||||
// holds everywhere else in CMotionInterp does not hold here.
|
|
||||||
var interp = new MotionInterpreter();
|
|
||||||
|
|
||||||
var result = interp.jump_is_allowed(0.5f, 0);
|
|
||||||
|
|
||||||
Assert.Equal(WeenieError.NotGrounded, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// contact_allows_move (FUN_00528dd0)
|
// contact_allows_move (FUN_00528dd0)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -741,8 +541,15 @@ public sealed class MotionInterpreterTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 body = MakeGrounded();
|
||||||
var interp = MakeInterp(body);
|
var interp = MakeInterp(body);
|
||||||
// All interpreted commands at default (Ready, no sidestep, no turn)
|
// All interpreted commands at default (Ready, no sidestep, no turn)
|
||||||
|
|
@ -750,7 +557,7 @@ public sealed class MotionInterpreterTests
|
||||||
|
|
||||||
interp.contact_allows_move(MotionCommand.WalkForward);
|
interp.contact_allows_move(MotionCommand.WalkForward);
|
||||||
|
|
||||||
Assert.True(interp.StandingLongJump, "Should set StandingLongJump when grounded and idle");
|
Assert.False(interp.StandingLongJump, "contact_allows_move must never touch StandingLongJump (J6)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue