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 <noreply@anthropic.com>
This commit is contained in:
parent
88cdfdc3c7
commit
1390f9477d
2 changed files with 87 additions and 56 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
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) ──
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue