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