fix(vt): round 3 item 5 — jump-charge ceiling moves from storage load to execution

Retail's real 2000 ms jump-charge ceiling (refs/vtank/decompiled/bi.cs:502-505,
bi.a) is enforced at the moment a jump STARTS charging, not by the storage
format — metaf's own NJump (metaf_monolithic.py:11708-11820) and VTank's
.nav both round-trip the authored value unclamped. The prior slice-1 port
misread this as a LOAD-time clamp: MetafSerializer's .af "jmp" parser and
the legacy-JSON route migration both clamped JumpChargeMilliseconds on
read, silently rewriting an authored 5000 ms waypoint down to 2000 ms even
when the route is never executed.

Removed both load-time clamps; NavigationController.TickJump now clamps
the EFFECTIVE charge duration (Math.Clamp(..., 0, 2000)) only at the one
place retail actually enforces it — the charge-hold comparison during
execution — leaving the stored/authored value untouched.

Renamed MetafSerializerTests.JumpNodeClampsChargeMillisecondsTo2000 to
JumpNodeLoadPreservesAuthoredChargeMillisecondsAboveRetailCeiling (now
asserts the 5000 ms value survives the .af load) and added a save+load
round-trip test and a Navigation execution test asserting the jump
releases at ~2000 ms of in-game charging despite a 5000 ms authored value.

Mutation: reverted MetafSerializer.cs/MossTankRouteProfileStore.cs/
Navigation.cs to HEAD (keeping only the new/changed tests) and ran the
three new/renamed tests — all three failed (load clamped to 2000,
execution never released before 5000 ms) — confirming they exercise the
bug before the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 01:21:50 +02:00
parent 0398913538
commit 7aee57fa22
5 changed files with 110 additions and 23 deletions

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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);

View file

@ -498,8 +498,18 @@ public sealed class MetafSerializerTests
Assert.Equal("Some Monster", nav.FollowTargetName);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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);

View file

@ -379,6 +379,37 @@ public sealed class NavigationTests
Assert.False(controller.Tick(0.01d, canAct: true));
}
/// <summary>
/// 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.
/// </summary>
[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