diff --git a/docs/research/vtank-kb/06-navigation-and-nav.md b/docs/research/vtank-kb/06-navigation-and-nav.md
index 2101d530..9e5a3b8e 100644
--- a/docs/research/vtank-kb/06-navigation-and-nav.md
+++ b/docs/research/vtank-kb/06-navigation-and-nav.md
@@ -510,14 +510,20 @@ tests (`tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs`,
**Can a real `.nav` file load today? Yes.** `VtankNavRouteSerializer.TryLoad`
implements the exact header/route-type/waypoint-record grammar in §1,
-including the correct discard of the placeholder line, the correct
-per-type payload for all ten waypoint types, and — notably — correctly
-special-cases Portal2/UseNPC to overwrite the (meaningless) outer header
-coordinate with the embedded `d`-record's own coordinate (`VtankNavRouteSerializer.cs:151-157`).
-It is wired to actual import via `MossTankRouteProfileStore.TryImportLegacy`,
-which reads a `.nav` text file from plugin storage by filename. Manual
-verification against `bunny_stuck_jump.nav` and `deathnav.nav` (§1.4) round-trips
-cleanly against the documented grammar.
+including the correct discard of the placeholder line, and the correct
+per-type payload for all ten waypoint types. Portal2/UseNPC now keep both
+coordinate triples on `RouteWaypoint` — the (meaningless, per §1.2) outer
+header coordinate in `Position` and the embedded `d`-record's own real
+target coordinate in `ReferencePosition` (`VtankNavRouteSerializer.cs:127-145`;
+Campaign VT slice-1 Part A fix round — a prior port overwrote `Position`
+with the `d`-record instead of keeping both, which made a `.af` round trip
+of the same waypoint lossy). `Navigation.TickUse` searches for the live
+world object using `ReferencePosition`, matching retail's "real target
+coordinate" role for that field. It is wired to actual import via
+`MossTankRouteProfileStore.TryImportLegacy`, which reads a `.nav` text file
+from plugin storage by filename. Manual verification against
+`bunny_stuck_jump.nav` and `deathnav.nav` (§1.4) round-trips cleanly against
+the documented grammar.
Ranked by impact (highest first):
@@ -531,6 +537,7 @@ Ranked by impact (highest first):
| 6 | **Portal2/UseNPC candidate filter omits the ground truth's `item.c()==0` gate.** | `e9.g()`/`fa.g()` only consider candidates where `item.c() == 0` (§7 — exact meaning undetermined, plausibly "not dead"/"visible") in addition to name+class+proximity. | `TryFindObject` is opaque from this file (defined elsewhere in the plugin abstraction); could not confirm whether an equivalent filter exists. | **Low** — flagged for follow-up rather than asserted as missing. |
| 7 | **Chat-color gate on UseNPC's "got a response" detection is dropped.** | `fa.a(ChatTextInterceptEventArgs)` only accepts color-3 "tells you" or color-0 "gives you" lines (`fa.cs:160-177`). | `HasNpcResponse` (`Navigation.cs:829-848`) matches on text content and sender name only, with no color/channel check, plus an extra `Sender.Equals(npcName)` branch not present in ground truth. | **Low** — small false-positive risk (any channel's text matching the phrase would complete the node), unlikely to matter in practice given the fairly specific phrase match. |
| 8 | **Door frame-count debounce vs. time-based retry.** | Portal2's post-use verification waits `PluginCore.@do >= startFrame + 2` — at least two *rendered frames*, not a duration — before checking arrival (`e9.cs:126-139`). | acdream's equivalents are all elapsed-seconds based (`UseRetrySeconds`, etc., `Navigation.cs:181` and throughout). | **Low** — a frame-based debounce doesn't map cleanly onto acdream's tick model in the first place; noted for completeness, not actionable. |
+| 9 | **`.af` (metaf) cannot represent a strafe jump's direction at all.** | The binary `.nav` format's Jump record ends with one combined line encoding both charge-ms and a direction digit ∈ {3=Forward,4=StrafeLeft,5=StrafeRight} (§1.2 row 9, `di.cs:116-172`). | metaf's `NJump` class (`metaf_monolithic.py:11708-11821`, both `ImportFromMetAF`/`ExportToMetAF`) has no direction field whatsoever — only x/y/z, heading, holdShift, and delay-ms. `RouteWaypoint.JumpDirection` survives a `.nav`⇄model round trip exactly, but a route saved to `.af` and reloaded always comes back `Forward` regardless of what it held before the save, because the `.af` text itself never carried the value. | **Medium** — a real, unavoidable format limitation (not a porting gap): confirmed by reading metaf's own class end to end, not inferred. `MetafSerializer`'s `.af` writer does not claim otherwise and the reader does not force-assign `Forward` (it leaves the model's own default), but the value is still lost across a save-as-`.af`/reload cycle for StrafeLeft/StrafeRight waypoints. |
Correctly and precisely ported (confirmed, not a gap — listed since they
were non-obvious and worth recording as verified rather than re-litigated):
diff --git a/src/AcDream.Plugins.MossTank/MetafSerializer.cs b/src/AcDream.Plugins.MossTank/MetafSerializer.cs
index a6817431..3668ae06 100644
--- a/src/AcDream.Plugins.MossTank/MetafSerializer.cs
+++ b/src/AcDream.Plugins.MossTank/MetafSerializer.cs
@@ -652,9 +652,9 @@ internal static class MetafSerializer
: waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37;
lines.Add(objectClass.ToString(CultureInfo.InvariantCulture));
lines.Add(waypoint.LegacyReferenceValid.ToString(CultureInfo.InvariantCulture));
- lines.Add(FormatBinaryDouble(waypoint.Position.EastWest));
- lines.Add(FormatBinaryDouble(waypoint.Position.NorthSouth));
- lines.Add(FormatBinaryDouble(waypoint.Position.Elevation));
+ lines.Add(FormatBinaryDouble(waypoint.ReferencePosition.EastWest));
+ lines.Add(FormatBinaryDouble(waypoint.ReferencePosition.NorthSouth));
+ lines.Add(FormatBinaryDouble(waypoint.ReferencePosition.Elevation));
break;
case RouteWaypointType.Jump:
lines.Add(FormatBinaryDouble(waypoint.JumpHeadingDegrees));
@@ -840,10 +840,19 @@ internal static class MetafSerializer
waypoint.ObjectName = StripDelimiters(args.Groups["s"].Value);
break;
case "ptl" or "tlk":
+ // metaf's own grammar carries TWO coordinate triples
+ // (metaf_monolithic.py:356-357,11482,11618 —
+ // "FORMAT: ptl/tlk myx myy myz tgtx tgty tgtz tgtObjectClass
+ // tgtName"): d/d2/d3 (already folded into the shared
+ // waypoint.Position above) is "myxyz" — where the character
+ // stood when the waypoint was authored; d4/d5/d6 is
+ // "tgtxyz" — the portal/NPC object's own recorded position,
+ // kept separately so a reload can still disambiguate which
+ // object at that name to interact with.
waypoint.ObjectName = StripDelimiters(args.Groups["s"].Value);
waypoint.LegacyObjectClass = ParseInt(args.Groups["i"].Value);
waypoint.LegacyReferenceValid = true;
- waypoint.Position = new PluginNavigationPosition(
+ waypoint.ReferencePosition = new PluginNavigationPosition(
0u,
ParseDouble(args.Groups["d4"].Value),
ParseDouble(args.Groups["d5"].Value),
@@ -857,15 +866,20 @@ internal static class MetafSerializer
// 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 own .af shape cannot represent the
- // binary format's strafe-direction suffix, so JumpDirection
- // always comes back Forward from an .af load.
+ // 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)));
- waypoint.JumpDirection = RouteJumpDirection.Forward;
break;
}
return waypoint;
@@ -1052,7 +1066,7 @@ internal static class MetafSerializer
$"\tvnd {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} {FormatHex(waypoint.ObjectId)} {{{waypoint.ObjectName}}}",
RouteWaypointType.PortalByName or RouteWaypointType.UseNpc =>
$"\t{NodeKeyword(waypoint.Type)} {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} "
- + $"{FormatNumber(waypoint.Position.EastWest)} {FormatNumber(waypoint.Position.NorthSouth)} {FormatNumber(waypoint.Position.Elevation)} "
+ + $"{FormatNumber(waypoint.ReferencePosition.EastWest)} {FormatNumber(waypoint.ReferencePosition.NorthSouth)} {FormatNumber(waypoint.ReferencePosition.Elevation)} "
+ $"{waypoint.LegacyObjectClass} {{{waypoint.ObjectName}}}",
RouteWaypointType.Jump =>
$"\tjmp {FormatNumber(x)} {FormatNumber(y)} {FormatNumber(z)} {FormatNumber(waypoint.JumpHeadingDegrees)} "
diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
index 56971d04..6ed38b81 100644
--- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
@@ -1587,7 +1587,15 @@ internal sealed partial class MossTankPanel
AddRouteWaypoint(new RouteWaypoint
{
Type = type,
- Position = target.Position,
+ // Retail's own header x/y/z for Portal2/UseNPC is dead weight —
+ // e9/fa extend `at`, whose position accessor is the player's
+ // OWN live position, never read back into anything meaningful
+ // (docs/research/vtank-kb/06-navigation-and-nav.md section 1.2).
+ // ReferencePosition is the embedded d-record: the real target
+ // coordinate matched against live world objects by name+class+
+ // proximity (Navigation.cs's TickUse).
+ Position = _host.Automation.Navigation.Snapshot.Position,
+ ReferencePosition = target.Position,
ObjectId = target.ObjectId,
ObjectName = target.Name,
});
diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
index 1eca05e0..d6af35d8 100644
--- a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
@@ -370,6 +370,18 @@ internal sealed class MossTankRouteProfileStore
public double Elevation { get; set; }
public float HeadingDegrees { get; set; }
public bool IsOutdoor { get; set; }
+ ///
+ /// The second coordinate triple Portal2/UseNPC waypoints carry
+ /// (VTank's embedded "d"-record / metaf's "tgtxyz" — see
+ /// ). Unused for every
+ /// other waypoint type.
+ ///
+ public uint ReferenceCellId { get; set; }
+ public double ReferenceEastWest { get; set; }
+ public double ReferenceNorthSouth { get; set; }
+ public double ReferenceElevation { get; set; }
+ public float ReferenceHeadingDegrees { get; set; }
+ public bool ReferenceIsOutdoor { get; set; }
public uint ObjectId { get; set; }
public string ObjectName { get; set; } = string.Empty;
public int LegacyObjectClass { get; set; }
@@ -393,6 +405,12 @@ internal sealed class MossTankRouteProfileStore
Elevation = value.Position.Elevation,
HeadingDegrees = value.Position.HeadingDegrees,
IsOutdoor = value.Position.IsOutdoor,
+ ReferenceCellId = value.ReferencePosition.CellId,
+ ReferenceEastWest = value.ReferencePosition.EastWest,
+ ReferenceNorthSouth = value.ReferencePosition.NorthSouth,
+ ReferenceElevation = value.ReferencePosition.Elevation,
+ ReferenceHeadingDegrees = value.ReferencePosition.HeadingDegrees,
+ ReferenceIsOutdoor = value.ReferencePosition.IsOutdoor,
ObjectId = value.ObjectId,
ObjectName = value.ObjectName,
LegacyObjectClass = value.LegacyObjectClass,
@@ -418,6 +436,13 @@ internal sealed class MossTankRouteProfileStore
Elevation,
HeadingDegrees,
IsOutdoor),
+ ReferencePosition = new PluginNavigationPosition(
+ ReferenceCellId,
+ ReferenceEastWest,
+ ReferenceNorthSouth,
+ ReferenceElevation,
+ ReferenceHeadingDegrees,
+ ReferenceIsOutdoor),
ObjectId = ObjectId,
ObjectName = ObjectName ?? string.Empty,
LegacyObjectClass = LegacyObjectClass,
diff --git a/src/AcDream.Plugins.MossTank/Navigation.cs b/src/AcDream.Plugins.MossTank/Navigation.cs
index 839972ad..9965fd23 100644
--- a/src/AcDream.Plugins.MossTank/Navigation.cs
+++ b/src/AcDream.Plugins.MossTank/Navigation.cs
@@ -44,6 +44,17 @@ internal sealed class RouteWaypoint
{
public RouteWaypointType Type { get; set; }
public PluginNavigationPosition Position { get; set; }
+ ///
+ /// The second of the two coordinate triples VTank's own Portal2/UseNPC
+ /// waypoint records carry (metaf's "ptl"/"tlk" nodes: "myx myy myz tgtx
+ /// tgty tgtz" — metaf_monolithic.py:356-357,11482,11618; the
+ /// binary .nav record's own trailing xyz).
+ /// is where the bot stands to use the object ("myxyz"); this is the
+ /// portal/NPC object's own recorded position ("objxyz"), used to
+ /// disambiguate which object at that name to interact with. Unused for
+ /// every other waypoint type.
+ ///
+ public PluginNavigationPosition ReferencePosition { get; set; }
public uint ObjectId { get; set; }
public string ObjectName { get; set; } = string.Empty;
/// Decal ObjectClass retained for exact VTank NAV interchange.
@@ -64,6 +75,7 @@ internal sealed class RouteWaypoint
{
Type = Type,
Position = Position,
+ ReferencePosition = ReferencePosition,
ObjectId = ObjectId,
ObjectName = ObjectName,
LegacyObjectClass = LegacyObjectClass,
@@ -711,9 +723,15 @@ internal sealed class NavigationController
StringComparison.OrdinalIgnoreCase));
if (!currentStillExists)
{
+ // Search near the object's own recorded position ("tgtxyz"
+ // in metaf's ptl/tlk grammar), not where the bot was
+ // standing when the waypoint was authored ("myxyz" —
+ // waypoint.Position): the object can be meaningfully far
+ // from the approach point (e.g. a portal at the far end of
+ // a room).
if (!_host.Automation.Navigation.TryFindObject(
waypoint.ObjectName,
- waypoint.Position,
+ waypoint.ReferencePosition,
ObjectReacquireRadiusMeters,
out PluginNavigationObject reacquired))
{
diff --git a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs
index aa995081..cf994ae6 100644
--- a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs
+++ b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs
@@ -126,13 +126,23 @@ internal static class VtankNavRouteSerializer
break;
case 6:
case 7:
+ // The record's leading eastWest/northSouth/elevation (read
+ // above into waypoint.Position) is "myxyz" — where the
+ // character stood when the waypoint was authored. This
+ // trailing triple is the portal/NPC object's own recorded
+ // position ("tgtxyz" in metaf's parallel ptl/tlk grammar —
+ // see MetafSerializer.ReadNavNode), kept separately so a
+ // reload can still disambiguate which object at that name
+ // to interact with, matching the record shape exactly:
+ // the pre-existing single-Position port used to overwrite
+ // "myxyz" with this value instead of keeping both.
waypoint.ObjectName = ReadLine(reader);
waypoint.LegacyObjectClass = ReadInt(reader);
waypoint.LegacyReferenceValid = ReadBoolean(reader);
double referenceEastWest = ReadDouble(reader);
double referenceNorthSouth = ReadDouble(reader);
double referenceElevation = ReadDouble(reader);
- waypoint.Position = Position(
+ waypoint.ReferencePosition = Position(
referenceEastWest,
referenceNorthSouth,
referenceElevation);
diff --git a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs
index 545adb5a..9934aac3 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs
@@ -176,28 +176,28 @@ public sealed class MetafSerializerTests
public static TheoryData ByteIdenticalFixtureData()
{
var data = new TheoryData();
- // bore_enhanced.af is committed but was hand-edited after generation
- // (some IF:/DO: lines use a space instead of metaf's own tab
- // separator, confirmed by diffing it against metaf's own
- // af->met->af canonical round-trip during authoring) — excluded
- // from this byte-identity proof for that reason; it still exercises
+ // bore_enhanced.af AND bore_quest.af are committed but were
+ // hand-edited after generation (some IF:/DO: lines use a space
+ // instead of metaf's own tab separator between the label and the
+ // condition/action keyword — confirmed by inspection: e.g.
+ // bore_quest.af line 9 is "\tIF: Death" rather than "\tIF:\tDeath").
+ // metaf's own Rule.ExportToMetAF always joins with a tab
+ // (metaf_monolithic.py:12371,12373), so this port's writer (which
+ // matches that) can never byte-match either file — excluded from
+ // this byte-identity proof for that reason; both still exercise
// every other proof (parse, parse-write-parse) normally.
//
- // aphus.af, augments.af, lockandkey.af, and neftet.af are also
- // excluded here: all four embed a "ptl"/"tlk" nav node inside an
- // EmbedNav route, and this port's RouteWaypoint (pre-existing,
- // shared with the binary VtankNavRouteSerializer) has a single
- // Position field — matching VtankNavRouteSerializer's own
- // established case-6/7 behavior of overwriting the "approach" xyz
- // with the "reference" xyz on load. metaf's own model keeps both
- // sets distinct, so a byte-identical round trip is architecturally
- // impossible without widening RouteWaypoint, which is out of this
- // slice's scope. All four still pass every other proof (parse,
- // parse-write-parse, binary-vs-af model equality where a .met
- // counterpart exists).
+ // aphus.af, augments.af, lockandkey.af, and neftet.af all embed a
+ // "ptl"/"tlk" nav node inside an EmbedNav route; now that
+ // RouteWaypoint carries both coordinate triples
+ // (Position/ReferencePosition, Campaign VT slice-1 Part A fix
+ // round), they round-trip byte-identically too and are included
+ // here rather than excluded.
foreach (string name in new[]
{
"bella", "hunting", "gauntlet_leader", "empyrean_facility", "follower",
+ "aphus", "augments", "lockandkey", "neftet",
+ "example_sort_meta",
})
{
data.Add(Path.Combine(FixturesRoot, "af", name + ".af"));
@@ -250,6 +250,79 @@ public sealed class MetafSerializerTests
AssertProfilesEqual(fromMet, fromAf);
}
+ // aphus.af's embedded "nav0__stipend_nav" route carries a real "ptl"
+ // node whose two coordinate triples genuinely differ (line 1026:
+ // "-101.597905190786 -96.6216093699137 2.08333134651184E-05" myxyz,
+ // "59.3936458587647 -28.7256083488464 0.0508250035345554" tgtxyz). A
+ // prior port collapsed both onto RouteWaypoint.Position, which this
+ // test would have caught immediately: before the fix, Position held
+ // 59.39/-28.72/0.0508 (the second triple) and there was nowhere to
+ // read -101.59/-96.62/2.08e-05 back from at all.
+ [Fact]
+ public void PtlNodeKeepsBothCoordinateTriplesDistinct()
+ {
+ string original = File.ReadAllText(Path.Combine(FixturesRoot, "af", "aphus.af"));
+ Assert.True(
+ MetafSerializer.TryLoadMeta(original, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
+ error);
+
+ RouteWaypoint waypoint = FindNavWaypoint(profile, "Portal to Town Network");
+
+ Assert.Equal(-101.597905190786d, waypoint.Position.EastWest, 6);
+ Assert.Equal(-96.6216093699137d, waypoint.Position.NorthSouth, 6);
+ Assert.Equal(2.08333134651184E-05d, waypoint.Position.Elevation, 10);
+ Assert.Equal(59.3936458587647d, waypoint.ReferencePosition.EastWest, 6);
+ Assert.Equal(-28.7256083488464d, waypoint.ReferencePosition.NorthSouth, 6);
+ Assert.Equal(0.0508250035345554d, waypoint.ReferencePosition.Elevation, 6);
+
+ string rewritten = MetafSerializer.SaveMeta(profile);
+ Assert.Contains(
+ "ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 "
+ + "59.3936458587647 -28.7256083488464 0.0508250035345554 14 "
+ + "{Portal to Town Network}",
+ rewritten,
+ StringComparison.Ordinal);
+ }
+
+ ///
+ /// Walks every
+ /// action's synthesized "uTank2 NAV 1.2" blob (
+ /// — the shape MetaEngine.LoadEmbeddedNavigationRoute already
+ /// expects) looking for a waypoint by object name.
+ ///
+ private static RouteWaypoint FindNavWaypoint(MetaProfile profile, string objectName)
+ {
+ foreach (MetaRule rule in profile.Rules)
+ {
+ RouteWaypoint? found = FindNavWaypoint(rule.Action, objectName);
+ if (found is not null)
+ return found;
+ }
+ throw new InvalidOperationException($"no nav waypoint named '{objectName}' found.");
+ }
+
+ private static RouteWaypoint? FindNavWaypoint(MetaAction action, string objectName)
+ {
+ if (action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute)
+ {
+ var nav = new NavigationSettings();
+ Assert.True(VtankNavRouteSerializer.TryLoad(
+ action.Text, nav, NoOpSpellCatalog.Instance, out string error), error);
+ foreach (RouteWaypoint waypoint in nav.Waypoints)
+ {
+ if (waypoint.ObjectName == objectName)
+ return waypoint;
+ }
+ }
+ foreach (MetaAction child in action.Children)
+ {
+ RouteWaypoint? found = FindNavWaypoint(child, objectName);
+ if (found is not null)
+ return found;
+ }
+ return null;
+ }
+
[Fact]
public void NestedAllAnyNotParsesCorrectly()
{
@@ -409,6 +482,9 @@ public sealed class MetafSerializerTests
Assert.Equal(a.Position.EastWest, b.Position.EastWest, 3);
Assert.Equal(a.Position.NorthSouth, b.Position.NorthSouth, 3);
Assert.Equal(a.Position.Elevation, b.Position.Elevation, 3);
+ Assert.Equal(a.ReferencePosition.EastWest, b.ReferencePosition.EastWest, 3);
+ Assert.Equal(a.ReferencePosition.NorthSouth, b.ReferencePosition.NorthSouth, 3);
+ Assert.Equal(a.ReferencePosition.Elevation, b.ReferencePosition.Elevation, 3);
Assert.Equal(a.ObjectId, b.ObjectId);
Assert.Equal(a.ObjectName, b.ObjectName);
Assert.Equal(a.Text, b.Text);
@@ -424,6 +500,12 @@ public sealed class MetafSerializerTests
Assert.Equal(a.JumpHeadingDegrees, b.JumpHeadingDegrees, 3);
Assert.Equal(a.JumpRun, b.JumpRun);
Assert.Equal(a.JumpChargeMilliseconds, b.JumpChargeMilliseconds);
+ // JumpDirection is likewise NOT compared here: metaf's .af
+ // format has no field for it at all (docs/research/vtank-kb/
+ // 06-navigation-and-nav.md section 6, gap 9), so a binary .nav
+ // fixture using StrafeLeft/StrafeRight always comes back
+ // Forward from the matching .af conversion — a real, expected
+ // asymmetry, not a bug in either reader.
}
}
diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankNavRouteSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankNavRouteSerializerTests.cs
index 4d5a7c62..64cd90db 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/VtankNavRouteSerializerTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/VtankNavRouteSerializerTests.cs
@@ -102,8 +102,20 @@ public sealed class VtankNavRouteSerializerTests
Assert.Equal(2500, settings.Waypoints[3].DurationMilliseconds);
Assert.Equal("/say hello", settings.Waypoints[4].Text);
Assert.Equal("Vendor Bob", settings.Waypoints[5].ObjectName);
- Assert.Equal(18.5, settings.Waypoints[6].Position.EastWest);
+ // Waypoint 6 (Portal2/type 6) carries TWO coordinate triples: the
+ // outer header (18, myxyz — where the character stood when the
+ // route was saved, meaningless per the retail decompile) lands in
+ // Position, and the embedded "d"-record (18.5, tgtxyz — the real
+ // target used for the world-object search) lands in
+ // ReferencePosition. A prior port collapsed both onto a single
+ // Position field, overwriting 18 with 18.5.
+ Assert.Equal(18, settings.Waypoints[6].Position.EastWest);
+ Assert.Equal(18.5, settings.Waypoints[6].ReferencePosition.EastWest);
+ Assert.Equal(-9.5, settings.Waypoints[6].ReferencePosition.NorthSouth);
+ Assert.Equal(1, settings.Waypoints[6].ReferencePosition.Elevation);
Assert.Equal("Town Crier", settings.Waypoints[7].ObjectName);
+ Assert.Equal(19, settings.Waypoints[7].Position.EastWest);
+ Assert.Equal(19.5, settings.Waypoints[7].ReferencePosition.EastWest);
Assert.Equal(14, settings.Waypoints[6].LegacyObjectClass);
Assert.Equal(37, settings.Waypoints[7].LegacyObjectClass);
Assert.True(settings.Waypoints[6].LegacyReferenceValid);