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:
parent
07664b199b
commit
0219c6e03d
8 changed files with 214 additions and 38 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)} "
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue