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