From 1390f9477d3beaae9181e3ba1b59f7052f5f9485 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 08:31:22 +0200 Subject: [PATCH] fix: airborne jump refusal fires at RELEASE, not press (user retail gate; supersedes CH round-1 item A) The user's retail description matches the decomp exactly: charge_jump @0x005281c0 has NO grounded check - it refuses only 0x49 (CanJump encumbrance) and 0x48 (fallen/crouch-family forward commands). Pressing jump while airborne begins the powerbar and charges normally. The 0x24 "You can't jump while in the air" comes exclusively from the RELEASE path (ClientCombatSystem::DoJump @0x0056B110 -> CMotionInterp::jump -> jump_is_allowed, whose airborne 0x24 our port already carries test-pinned). A charge held through landing executes a normal jump on the grounded release. PlayerMovementController's input orchestration now mirrors CommenceJump/DoJump: - Press edge: ChargeJump() decides; a refused charge (0x48/0x49) reports and never begins the bar (retail's jump_pending stays 0). The invented airborne press-edge 0x24 report (CH user-gate round 1 item A - added when the press/release split was not yet known) is deleted; CommenceJump's in-air fallback text is unreachable with a faithful charge_jump. - Hold: accumulates grounded OR airborne; leaving the ground mid-charge no longer force-fires the jump. - Release: fires jump(); an airborne release refuses 0x24 there. Tests: the round-1 press-edge test is replaced by two release-semantics tests (airborne release reports once; held-through-landing grounded release jumps silently). Runtime 1,619, App 4,987/3, Core jump family 159. Co-Authored-By: Claude Fable 5 --- .../Gameplay/PlayerMovementController.cs | 63 ++++++++------- .../Gameplay/PlayerMovementControllerTests.cs | 80 +++++++++++++------ 2 files changed, 87 insertions(+), 56 deletions(-) diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index b4daa174..3714e39f 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -2535,26 +2535,42 @@ public sealed class PlayerMovementController float? outJumpExtent = null; Vector3? outJumpVelocity = null; - if (input.Jump && _body.OnWalkable) + if (input.Jump && !_jumpCharging && !_prevJumpHeld) { - // Spacebar held and on the ground — accumulate charge. - if (!_jumpCharging) + // Press edge — ClientCombatSystem::CommenceJump @0x0056AF90. + // R3-W6 (map R1): retail's charge_jump fires at charge START + // (SmartBox/input boundary 0x0056afac) — the ONLY place + // StandingLongJump arms (grounded + Ready + no sidestep/turn). + // + // 2026-08-14 user retail gate: charge_jump @0x005281c0 has NO + // grounded check — it refuses only 0x49 (CanJump encumbrance) + // and 0x48 (fallen/crouch-family commands). Pressing jump while + // AIRBORNE charges the bar normally; retail's 0x24 "You can't + // jump while in the air" comes exclusively from the RELEASE + // path's jump_is_allowed (DoJump @0x0056B110 → CMotionInterp:: + // jump), so charging through the air and releasing after + // landing executes a normal jump. This supersedes the CH + // user-gate round 1 item A press-edge 0x24 report — CommenceJump + // DOES carry an in-air fallback text, but with a faithful + // charge_jump (0/0x48/0x49 only) that arm is unreachable. + // + // A REFUSED charge reports and never begins the bar + // (jump_pending stays 0 in retail — no powerbar, no charge). + WeenieError chargeResult = _motion.ChargeJump(); + if (chargeResult == WeenieError.None) { _jumpCharging = true; _jumpExtent = 0f; - // R3-W6 (map R1): retail's charge_jump fires at charge - // START (SmartBox/input boundary 0x0056afac) — the ONLY - // place StandingLongJump arms (grounded + Ready + no - // sidestep/turn). Never called by production code before - // this line despite the W3 port. - // - // Campaign CH slice CH2: the return value used to be - // discarded — a refused charge (CantJumpLoadedDown / - // YouCantJumpFromThisPosition) silently drained the power - // bar with no explanation. Report it exactly as - // ClientCombatSystem::CommenceJump @0x0056AF90 does. - ReportJumpRefusal(_motion.ChargeJump()); } + else + { + ReportJumpRefusal(chargeResult); + } + } + + if (input.Jump && _jumpCharging) + { + // Spacebar held — accumulate charge (grounded OR airborne). float chargeRate = _motion.InterpretedState.CurrentStyle == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle ? DualWieldJumpChargeRate @@ -2563,7 +2579,8 @@ public sealed class PlayerMovementController } else if (_jumpCharging) { - // Spacebar released (or left ground during charge) — fire jump. + // Spacebar RELEASED — fire jump (DoJump @0x0056B110). An + // airborne release refuses 0x24 through jump_is_allowed below. var jumpResult = _motion.jump(_jumpExtent); if (jumpResult == WeenieError.None) { @@ -2603,20 +2620,6 @@ public sealed class PlayerMovementController _jumpCharging = false; _jumpExtent = 0f; } - else if (input.Jump && !_prevJumpHeld && !_body.OnWalkable) - { - // Campaign CH user-gate round 1, item A: the whole jump block - // above only ever evaluates `input.Jump` inside - // `input.Jump && _body.OnWalkable` (charge) or `_jumpCharging` - // (fire/refuse) — pressing jump while airborne and NOT already - // charging never reached either branch, so retail's 0x24 "You - // can't jump while in the air" (jump_is_allowed via - // ClientCombatSystem::DoJump @0x0056B110) could never fire live. - // Report it exactly like the grounded refusals above, gated to - // the press EDGE only (see _prevJumpHeld) so holding space - // in-air raises exactly one report, not one per frame. - ReportJumpRefusal(WeenieError.NotGrounded); - } // Campaign CH user-gate round 2, item 1 (TEMPORARY): per-tick trace // bracketing every frame where jump is (or was) held, so the probe diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index c0652597..3ddb04a1 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -940,17 +940,24 @@ public class PlayerMovementControllerTests Assert.Null(exception); } - // ── Campaign CH user-gate round 1 (item A): airborne jump refusal ────── + // ── Airborne jump semantics (2026-08-14 user retail gate; supersedes CH + // user-gate round 1 item A's press-edge report) ───────────────────────── + // + // charge_jump @0x005281c0 has NO grounded check, so pressing jump while + // airborne CHARGES the bar normally; the 0x24 "You can't jump while in + // the air" fires only from the RELEASE path's jump_is_allowed + // (ClientCombatSystem::DoJump @0x0056B110 → CMotionInterp::jump). A + // charge held through landing executes a normal jump on release. [Fact] - public void JumpPress_RisingEdgeWhileAirborne_ReportsCantJumpInAir_HeldOnlyOnce_NoneAfterLanding() + public void JumpPressWhileAirborne_ChargesSilently_AirborneReleaseReportsCantJumpInAir() { var engine = MakeFlatEngine(); var controller = new PlayerMovementController(engine); controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); - // Launch into the air with an ordinary charged jump — the fix must - // not touch this grounded charge/fire path at all. + // Launch into the air with an ordinary charged jump — the grounded + // charge/fire path stays untouched. controller.Update(1.0f, new MovementInput(Jump: true)); // full charge controller.Update(0.016f, new MovementInput(Jump: false)); // release -> jump fires Assert.True(controller.IsAirborne); @@ -959,30 +966,51 @@ public class PlayerMovementControllerTests var reported = new List(); controller.OnInterfaceText = (text, _) => reported.Add(text); - // Rising edge while airborne: exactly one "You can't jump while in - // the air" report. - controller.Update(0.016f, new MovementInput(Jump: true)); - var report = Assert.Single(reported); - Assert.Equal(ClientTextRefusals.CantJumpInAir, report); - - // Holding the key across multiple further updates raises no - // additional reports. - controller.Update(0.016f, new MovementInput(Jump: true)); - controller.Update(0.016f, new MovementInput(Jump: true)); - controller.Update(0.016f, new MovementInput(Jump: true)); - Assert.Single(reported); - - // Release, then land. - controller.Update(0.016f, new MovementInput(Jump: false)); - for (int i = 0; i < 60 && controller.IsAirborne; i++) - controller.Update(0.05f, new MovementInput()); - Assert.False(controller.IsAirborne, "should have landed"); - - // Landing then pressing again while grounded raises none (the - // grounded charge succeeds normally for an unburdened character). - reported.Clear(); + // Rising edge while airborne: NO report — the bar charges normally. controller.Update(0.016f, new MovementInput(Jump: true)); Assert.Empty(reported); + Assert.True(controller.JumpCharge.IsCharging); + + // Holding stays silent and keeps charging. + controller.Update(0.016f, new MovementInput(Jump: true)); + controller.Update(0.016f, new MovementInput(Jump: true)); + Assert.Empty(reported); + + // RELEASING while still airborne: exactly one 0x24 report, and no + // second launch. + Assert.True(controller.IsAirborne); + controller.Update(0.016f, new MovementInput(Jump: false)); + var report = Assert.Single(reported); + Assert.Equal(ClientTextRefusals.CantJumpInAir, report); + Assert.False(controller.JumpCharge.IsCharging); + } + + [Fact] + public void JumpChargedInAir_HeldThroughLanding_GroundedReleaseJumpsSilently() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); + + // First jump, then begin charging the NEXT jump while airborne. + controller.Update(1.0f, new MovementInput(Jump: true)); + controller.Update(0.016f, new MovementInput(Jump: false)); + Assert.True(controller.IsAirborne); + + var reported = new List(); + controller.OnInterfaceText = (text, _) => reported.Add(text); + + // Hold jump through the landing. + for (int i = 0; i < 120 && controller.IsAirborne; i++) + controller.Update(0.05f, new MovementInput(Jump: true)); + Assert.False(controller.IsAirborne, "should have landed while holding"); + Assert.True(controller.JumpCharge.IsCharging, "charge survives the landing"); + Assert.Empty(reported); + + // Grounded release: the held charge executes a normal jump. + controller.Update(0.016f, new MovementInput(Jump: false)); + Assert.Empty(reported); + Assert.True(controller.IsAirborne, "the held charge fires on grounded release"); } // ── Campaign P Slice P5 (2026-07-30): ConstraintManager leash arming (#167) ──