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>
173 lines
5 KiB
C#
173 lines
5 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank.Tests;
|
|
|
|
public sealed class VtankNavRouteSerializerTests
|
|
{
|
|
[Fact]
|
|
public void LoadsEveryOfficialNav12WaypointPayload()
|
|
{
|
|
const string nav = """
|
|
uTank2 NAV 1.2
|
|
1
|
|
10
|
|
0
|
|
12.5
|
|
-3.25
|
|
0.5
|
|
0
|
|
1
|
|
13
|
|
-4
|
|
0
|
|
0
|
|
1234
|
|
2
|
|
14
|
|
-5
|
|
0
|
|
0
|
|
987
|
|
3
|
|
15
|
|
-6
|
|
0
|
|
0
|
|
2500
|
|
4
|
|
16
|
|
-7
|
|
0
|
|
0
|
|
/say hello
|
|
5
|
|
17
|
|
-8
|
|
0
|
|
0
|
|
4321
|
|
Vendor Bob
|
|
6
|
|
18
|
|
-9
|
|
0
|
|
0
|
|
Portal to Holtburg
|
|
14
|
|
True
|
|
18.5
|
|
-9.5
|
|
1
|
|
7
|
|
19
|
|
-10
|
|
0
|
|
0
|
|
Town Crier
|
|
37
|
|
True
|
|
19.5
|
|
-10.5
|
|
2
|
|
8
|
|
20
|
|
-11
|
|
0
|
|
0
|
|
9
|
|
21
|
|
-12
|
|
0
|
|
0
|
|
180.5
|
|
True
|
|
1000.00005
|
|
""";
|
|
var settings = new NavigationSettings { Enabled = true };
|
|
|
|
bool loaded = VtankNavRouteSerializer.TryLoad(
|
|
nav,
|
|
settings,
|
|
NoOpSpellCatalog.Instance,
|
|
out string error);
|
|
|
|
Assert.True(loaded, error);
|
|
Assert.True(settings.Enabled);
|
|
Assert.Equal(RouteMode.Circular, settings.Mode);
|
|
Assert.Equal(10, settings.Waypoints.Count);
|
|
Assert.Equal(RouteWaypointType.Point, settings.Waypoints[0].Type);
|
|
Assert.Equal(12.5, settings.Waypoints[0].Position.EastWest);
|
|
Assert.Equal(1234u, settings.Waypoints[1].ObjectId);
|
|
Assert.Equal(987u, settings.Waypoints[2].RecallSpellId);
|
|
Assert.Equal(2500, settings.Waypoints[3].DurationMilliseconds);
|
|
Assert.Equal("/say hello", settings.Waypoints[4].Text);
|
|
Assert.Equal("Vendor Bob", settings.Waypoints[5].ObjectName);
|
|
// 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);
|
|
Assert.Equal(RouteWaypointType.Checkpoint, settings.Waypoints[8].Type);
|
|
Assert.Equal(180.5f, settings.Waypoints[9].JumpHeadingDegrees);
|
|
Assert.True(settings.Waypoints[9].JumpRun);
|
|
Assert.Equal(1000, settings.Waypoints[9].JumpChargeMilliseconds);
|
|
Assert.Equal(RouteJumpDirection.StrafeRight, settings.Waypoints[9].JumpDirection);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadsEmbeddedWrapperAndDoesNotMutateOnFailure()
|
|
{
|
|
const string wrapped = """
|
|
Route Name
|
|
1
|
|
uTank2 NAV 1.2
|
|
4
|
|
1
|
|
0
|
|
1
|
|
2
|
|
3
|
|
0
|
|
""";
|
|
var settings = new NavigationSettings();
|
|
Assert.True(VtankNavRouteSerializer.TryLoad(
|
|
wrapped,
|
|
settings,
|
|
NoOpSpellCatalog.Instance,
|
|
out _));
|
|
Assert.Equal(RouteMode.Once, settings.Mode);
|
|
Assert.Single(settings.Waypoints);
|
|
|
|
Assert.False(VtankNavRouteSerializer.TryLoad(
|
|
"broken",
|
|
settings,
|
|
NoOpSpellCatalog.Instance,
|
|
out string error));
|
|
Assert.NotEmpty(error);
|
|
Assert.Equal(RouteMode.Once, settings.Mode);
|
|
Assert.Single(settings.Waypoints);
|
|
}
|
|
|
|
private sealed class NoOpSpellCatalog : ISpellCatalog
|
|
{
|
|
public static NoOpSpellCatalog Instance { get; } = new();
|
|
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
|
|
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
|
{
|
|
info = default;
|
|
return false;
|
|
}
|
|
}
|
|
}
|