fix(vt): A ptl/tlk carry two coordinate triples, jmp direction loss recorded

Item A (slice-1 fix round). VTank/metaf's Portal2/UseNPC nav nodes carry
TWO coordinate triples (metaf_monolithic.py:356-357,11482,11618 —
"FORMAT: ptl/tlk myx myy myz tgtx tgty tgtz tgtObjectClass tgtName"): the
outer header ("myxyz", retail's own dead-weight last-save player position
per docs/research/vtank-kb/06-navigation-and-nav.md section 1.2) and the
embedded d-record ("tgtxyz", the real target coordinate used to match a
live world object by name+class+proximity). The prior port's
RouteWaypoint had a single Position field, so both the .af reader
(MetafSerializer.ReadNavNode) and the binary .nav reader
(VtankNavRouteSerializer.ReadWaypoint, case 6/7) overwrote "myxyz" with
"tgtxyz" on load, and the .af writer echoed the same Position value for
BOTH triples on save — a real .af round trip of the same waypoint was
lossy, which is why aphus/augments/lockandkey/neftet were excluded from
the byte-identity proof.

- RouteWaypoint: new ReferencePosition field (Position stays "myxyz",
  ReferencePosition is "tgtxyz"); included in Clone().
- MetafSerializer.ReadNavNode/RenderNavNode: ptl/tlk read/write both
  triples distinctly. WriteBinaryNavBlob's embedded-route writer (the
  MossTank runtime blob EmbedNav actions carry) fixed the same way — it
  was echoing Position for the reference triple too.
- VtankNavRouteSerializer.ReadWaypoint case 6/7: keep the header triple in
  Position, read the trailing triple into ReferencePosition instead of
  overwriting Position.
- Navigation.TickUse: TryFindObject now searches near ReferencePosition
  (the real target coordinate) instead of Position, preserving the
  correct runtime search behavior now that Position no longer aliases it.
- MossTankPanel.AddSelectedObjectWaypoint: new Portal2/UseNPC waypoints
  now set Position from the live snapshot (matching retail's own
  "wherever the character stood") and ReferencePosition from the selected
  object's live position (the real search anchor) — previously both were
  set from the object's position.
- MossTankRouteProfileStore's WaypointDocument DTO carries the reference
  triple too, so MossTank's own JSON-persisted routes round-trip it.
- MetafSerializerTests: un-excluded aphus/augments/lockandkey/neftet.af
  from the byte-identity proof (they all embed a ptl/tlk node and now
  round-trip correctly) and added example_sort_meta.af, which also
  passes. bore_quest.af was NOT added despite the slice-1 contract's
  ask: it is hand-edited the same way as the already-excluded
  bore_enhanced.af (space instead of tab between "IF:"/"DO:" and the
  following keyword, confirmed at bore_quest.af line 9 — metaf's own
  Rule.ExportToMetAF always joins with a tab, metaf_monolithic.py:12371),
  so it can never byte-match; documented alongside bore_enhanced's
  existing exclusion note instead. New PtlNodeKeepsBothCoordinateTriplesDistinct
  test pins the two-triple split directly (failed before this change:
  Position held the second triple with nowhere to read the first triple
  back from). VtankNavRouteSerializerTests updated to assert the split
  instead of the old collapsed value.
- jmp direction: metaf's NJump class has no strafe-direction field at all
  (metaf_monolithic.py:11708-11821, confirmed reading ImportFromMetAF/
  ExportToMetAF end to end) — the .af format cannot represent
  RouteWaypoint.JumpDirection, full stop. ReadNavNode no longer assigns
  JumpDirection = Forward explicitly (the model's own default), and the
  loss is now recorded as gap 9 in docs/research/vtank-kb/
  06-navigation-and-nav.md section 6.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 22:13:48 +02:00
parent 07664b199b
commit 0219c6e03d
8 changed files with 214 additions and 38 deletions

View file

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

View file

@ -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)} "

View file

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

View file

@ -370,6 +370,18 @@ internal sealed class MossTankRouteProfileStore
public double Elevation { get; set; }
public float HeadingDegrees { get; set; }
public bool IsOutdoor { get; set; }
/// <summary>
/// The second coordinate triple Portal2/UseNPC waypoints carry
/// (VTank's embedded "d"-record / metaf's "tgtxyz" — see
/// <see cref="RouteWaypoint.ReferencePosition"/>). Unused for every
/// other waypoint type.
/// </summary>
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,

View file

@ -44,6 +44,17 @@ internal sealed class RouteWaypoint
{
public RouteWaypointType Type { get; set; }
public PluginNavigationPosition Position { get; set; }
/// <summary>
/// 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" — <c>metaf_monolithic.py:356-357,11482,11618</c>; the
/// binary <c>.nav</c> record's own trailing xyz). <see cref="Position"/>
/// 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.
/// </summary>
public PluginNavigationPosition ReferencePosition { get; set; }
public uint ObjectId { get; set; }
public string ObjectName { get; set; } = string.Empty;
/// <summary>Decal ObjectClass retained for exact VTank NAV interchange.</summary>
@ -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))
{

View file

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

View file

@ -176,28 +176,28 @@ public sealed class MetafSerializerTests
public static TheoryData<string> ByteIdenticalFixtureData()
{
var data = new TheoryData<string>();
// 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);
}
/// <summary>
/// Walks every <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/>
/// action's synthesized "uTank2 NAV 1.2" blob (<see cref="MetaAction.Text"/>
/// — the shape <c>MetaEngine.LoadEmbeddedNavigationRoute</c> already
/// expects) looking for a waypoint by object name.
/// </summary>
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.
}
}

View file

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