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:
parent
0398913538
commit
7aee57fa22
5 changed files with 110 additions and 23 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue