diff --git a/src/AcDream.Plugins.MossTank/MetafSerializer.cs b/src/AcDream.Plugins.MossTank/MetafSerializer.cs index ac33a4db0..dfdd49cca 100644 --- a/src/AcDream.Plugins.MossTank/MetafSerializer.cs +++ b/src/AcDream.Plugins.MossTank/MetafSerializer.cs @@ -915,23 +915,31 @@ internal static class MetafSerializer case "jmp": waypoint.JumpHeadingDegrees = checked((float)ParseDouble(args.Groups["d4"].Value)); waypoint.JumpRun = StripDelimiters(args.Groups["s"].Value) == "True"; - // KB doc 06 section: retail/VTank's own jump state machine - // clamps the charge duration to 2000 ms (bi.a, bi.cs:502-526); - // apply that ceiling here, at .af load, per the slice-1 - // contract. metaf's NJump class carries no strafe-direction - // field at all (metaf_monolithic.py:11708-11821, confirmed - // by reading ImportFromMetAF/ExportToMetAF end to end) — the - // .af format cannot represent JumpDirection, full stop. - // JumpDirection is left at the model's own default - // (Forward) rather than assigned here, so this load path - // never claims to have decoded a value it did not read; see - // docs/research/vtank-kb/06-navigation-and-nav.md section 6 - // for the recorded representational loss. - waypoint.JumpChargeMilliseconds = Math.Min( - 2000, - checked((int)Math.Round( - ParseDouble(args.Groups["d5"].Value), - MidpointRounding.AwayFromZero))); + // Round 3 item 5: retail/VTank's own jump state machine + // clamps the charge duration to 2000 ms at EXECUTION time + // (bi.a, bi.cs:502-505 — the clamp is inside the "start + // charging" call, not the file format), and metaf's own + // NJump carries no such ceiling either + // (metaf_monolithic.py:11708-11820). A ROUND TRIP through + // .af must therefore preserve the authored value verbatim — + // clamping here (a prior slice-1 misreading of the retail + // ceiling as a LOAD-time clamp) silently rewrote an + // authored 5000 ms waypoint to 2000 ms on every load, even + // when the file is never executed. NavigationController.TickJump + // (Navigation.cs) applies the real bi.a ceiling at the one + // place retail actually enforces it: the moment the jump + // starts charging. metaf's NJump class carries no strafe- + // direction field at all (metaf_monolithic.py:11708-11821, + // confirmed by reading ImportFromMetAF/ExportToMetAF end to + // end) — the .af format cannot represent JumpDirection, + // full stop. JumpDirection is left at the model's own + // default (Forward) rather than assigned here, so this load + // path never claims to have decoded a value it did not + // read; see docs/research/vtank-kb/06-navigation-and-nav.md + // section 6 for the recorded representational loss. + waypoint.JumpChargeMilliseconds = checked((int)Math.Round( + ParseDouble(args.Groups["d5"].Value), + MidpointRounding.AwayFromZero)); break; } return waypoint; diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs index 9eeb4a83e..13121ceb1 100644 --- a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs @@ -525,7 +525,12 @@ internal sealed class MossTankRouteProfileStore RecallSpellName = RecallSpellName ?? string.Empty, JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f, JumpRun = JumpRun, - JumpChargeMilliseconds = Math.Clamp(JumpChargeMilliseconds, 0, 10_000), + // Round 3 item 5: no load-time clamp here either — the real + // bi.a 2000 ms ceiling is retail's EXECUTION-time behavior + // (NavigationController.TickJump), not a storage-format limit; + // this legacy migration path preserves whatever the pre-cutover + // JSON authored, exactly like the .af load path now does. + JumpChargeMilliseconds = JumpChargeMilliseconds, JumpDirection = Enum.IsDefined(JumpDirection) ? JumpDirection : RouteJumpDirection.Forward, }; } diff --git a/src/AcDream.Plugins.MossTank/Navigation.cs b/src/AcDream.Plugins.MossTank/Navigation.cs index 9965fd238..fc3622c57 100644 --- a/src/AcDream.Plugins.MossTank/Navigation.cs +++ b/src/AcDream.Plugins.MossTank/Navigation.cs @@ -197,6 +197,13 @@ internal sealed class NavigationController private const double RecallExitDistanceMeters = 2.4d; private const double JumpLaunchGraceSeconds = 0.25d; private const double JumpCompletionTimeoutSeconds = 3d; + // Round 3 item 5: retail's real jump-charge ceiling + // (refs/vtank/decompiled/bi.cs:502-505, bi.a — "if (A_2 > 2000.0) A_2 = + // 2000.0" at the moment the jump starts charging) belongs HERE, at + // execution, not at .af/legacy-JSON load — a waypoint's authored + // JumpChargeMilliseconds round-trips through storage unclamped; only + // the actual in-game charge duration is capped. + private const int JumpChargeCeilingMilliseconds = 2000; private const double CheckpointRetrySeconds = 15d; private const double FollowBreadcrumbSpacingMeters = 0.096d; private const double FollowPathCaptureRangeMeters = 240d; @@ -959,15 +966,16 @@ internal sealed class NavigationController } _jumpChargeElapsed += elapsedSeconds; - bool hold = _jumpChargeElapsed * 1000d - < Math.Max(0, waypoint.JumpChargeMilliseconds); + int effectiveChargeMilliseconds = Math.Clamp( + waypoint.JumpChargeMilliseconds, 0, JumpChargeCeilingMilliseconds); + bool hold = _jumpChargeElapsed * 1000d < effectiveChargeMilliseconds; if (hold) { PluginMovementIntent intent = JumpIntent(waypoint, jump: true); _hadMovementIntent = _host.Automation.Navigation .SetMovementIntent(intent) == PluginNavigationCommandStatus.Accepted; - _status = $"Charging jump: {waypoint.JumpChargeMilliseconds}ms."; + _status = $"Charging jump: {effectiveChargeMilliseconds}ms."; return true; } PluginMovementIntent release = JumpIntent(waypoint, jump: false); diff --git a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs index c7086b633..8e31beb41 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs @@ -498,8 +498,18 @@ public sealed class MetafSerializerTests Assert.Equal("Some Monster", nav.FollowTargetName); } + /// + /// Round 3 item 5: the retail 2000 ms jump-charge ceiling + /// (refs/vtank/decompiled/bi.cs:502-505) is EXECUTION-time behavior + /// (NavigationController.TickJump), not a storage-format limit — metaf + /// and VTank both round-trip the authored value verbatim + /// (metaf_monolithic.py:11708-11820, NJump — no clamp at all). An + /// authored 5000 ms waypoint must therefore survive an .af load + /// unchanged; renamed from JumpNodeClampsChargeMillisecondsTo2000, + /// which pinned the pre-fix (wrong) load-time clamp. + /// [Fact] - public void JumpNodeClampsChargeMillisecondsTo2000() + public void JumpNodeLoadPreservesAuthoredChargeMillisecondsAboveRetailCeiling() { const string af = """ NAV: j once @@ -509,7 +519,7 @@ public sealed class MetafSerializerTests Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error); RouteWaypoint waypoint = Assert.Single(target.Waypoints); Assert.Equal(RouteWaypointType.Jump, waypoint.Type); - Assert.Equal(2000, waypoint.JumpChargeMilliseconds); + Assert.Equal(5000, waypoint.JumpChargeMilliseconds); Assert.True(waypoint.JumpRun); Assert.Equal(90f, waypoint.JumpHeadingDegrees); } @@ -528,6 +538,31 @@ public sealed class MetafSerializerTests Assert.False(waypoint.JumpRun); } + /// + /// Round 3 item 5 companion: SaveNav must round-trip the same + /// above-ceiling value back out unchanged (the save side never had a + /// clamp; this pins that save+load together preserve it). + /// + [Fact] + public void JumpNodeSaveThenLoadRoundTripsChargeMillisecondsAboveRetailCeiling() + { + var source = new NavigationSettings { Mode = RouteMode.Once }; + source.Waypoints.Add(new RouteWaypoint + { + Type = RouteWaypointType.Jump, + JumpHeadingDegrees = 90f, + JumpRun = true, + JumpChargeMilliseconds = 5000, + }); + + string af = MetafSerializer.SaveNav(source); + + var target = new NavigationSettings(); + Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error); + RouteWaypoint waypoint = Assert.Single(target.Waypoints); + Assert.Equal(5000, waypoint.JumpChargeMilliseconds); + } + private static void AssertProfilesEqual(MetaProfile expected, MetaProfile actual) { Assert.Equal(expected.Rules.Count, actual.Rules.Count); diff --git a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs index 0aec9e989..d1c4267b6 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs @@ -379,6 +379,37 @@ public sealed class NavigationTests Assert.False(controller.Tick(0.01d, canAct: true)); } + /// + /// Round 3 item 5: retail's real jump-charge ceiling + /// (refs/vtank/decompiled/bi.cs:502-505) applies at EXECUTION, not at + /// storage load — an authored 5000 ms waypoint (which now survives an + /// .af/legacy-JSON load unclamped) must still release the jump after + /// at most 2000 ms of in-game charging, not wait for the full 5000. + /// + [Fact] + public void JumpChargeExecutionClampsAtRetailTwoThousandMillisecondCeiling() + { + var automation = new FakeAutomation + { + // Already aligned to the jump heading so the very first tick + // starts charging immediately (no turn phase to account for). + NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)), + }; + RouteWaypoint jump = Waypoint(RouteWaypointType.Jump, Position(0d, 0d)); + jump.JumpHeadingDegrees = 90f; + jump.JumpChargeMilliseconds = 5000; + NavigationController controller = Controller(automation, RouteMode.Once, jump); + + // 1.9 s elapsed < the 2000 ms ceiling: still charging. + Assert.True(controller.Tick(1.9d, canAct: true)); + Assert.True(automation.Intents[^1].Jump); + + // +0.2 s -> 2.1 s total, past the 2000 ms ceiling even though the + // AUTHORED 5000 ms charge has not elapsed: released. + Assert.True(controller.Tick(0.2d, canAct: true)); + Assert.False(automation.Intents[^1].Jump); + } + // Campaign VT slice 1 Part A round 2: the route (.af) file now carries // ONLY Mode/Waypoints/FollowTarget — Enabled/Priority/ // MinimumDistanceMeters/FollowAroundCorners/OpenDoors/Door* are real