acdream/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs
Erik 0219c6e03d 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>
2026-09-06 22:13:48 +02:00

522 lines
22 KiB
C#

using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class MetafSerializerTests
{
private static readonly string FixturesRoot = Path.Combine(
AppContext.BaseDirectory, "Fixtures", "vtank");
private static string[] AllAfFixtures() => Directory.GetFiles(
Path.Combine(FixturesRoot, "af"), "*.af");
private static string[] NavOnlyAfFixtures() => Directory.GetFiles(
Path.Combine(FixturesRoot, "af"), "nav_*.af");
private static string[] MetaAfFixtures() => Directory.GetFiles(
Path.Combine(FixturesRoot, "af"), "*.af")
.Where(static path => !Path.GetFileName(path).StartsWith(
"nav_", StringComparison.Ordinal))
.ToArray();
public static TheoryData<string> AllAfFixtureData()
{
var data = new TheoryData<string>();
foreach (string path in AllAfFixtures())
data.Add(path);
return data;
}
public static TheoryData<string> MetaAfFixtureData()
{
var data = new TheoryData<string>();
foreach (string path in MetaAfFixtures())
data.Add(path);
return data;
}
public static TheoryData<string> NavOnlyAfFixtureData()
{
var data = new TheoryData<string>();
foreach (string path in NavOnlyAfFixtures())
data.Add(path);
return data;
}
// Proof (1): every .af under the fixture set parses without error.
[Theory]
[MemberData(nameof(MetaAfFixtureData))]
public void EveryMetaAfFixtureParses(string path)
{
string text = File.ReadAllText(path);
Assert.True(
MetafSerializer.TryLoadMeta(text, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
$"{Path.GetFileName(path)}: {error}");
Assert.NotEmpty(profile.Rules);
}
[Theory]
[MemberData(nameof(NavOnlyAfFixtureData))]
public void EveryNavOnlyAfFixtureParses(string path)
{
string text = File.ReadAllText(path);
var target = new NavigationSettings();
Assert.True(
MetafSerializer.TryLoadNav(text, target, NoOpSpellCatalog.Instance, out string error),
$"{Path.GetFileName(path)}: {error}");
}
// Proof (2): parse -> write -> parse is identical (model equality; the
// writer's own byte-for-byte shape is proof (4), below).
[Theory]
[MemberData(nameof(MetaAfFixtureData))]
public void MetaParseWriteParseIsIdentical(string path)
{
string text = File.ReadAllText(path);
Assert.True(MetafSerializer.TryLoadMeta(
text, NoOpSpellCatalog.Instance, out MetaProfile first, out string error1), error1);
string rewritten = MetafSerializer.SaveMeta(first);
Assert.True(MetafSerializer.TryLoadMeta(
rewritten, NoOpSpellCatalog.Instance, out MetaProfile second, out string error2), error2);
AssertProfilesEqual(first, second);
}
[Theory]
[MemberData(nameof(NavOnlyAfFixtureData))]
public void NavParseWriteParseIsIdentical(string path)
{
string text = File.ReadAllText(path);
var first = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(
text, first, NoOpSpellCatalog.Instance, out string error1), error1);
string rewritten = MetafSerializer.SaveNav(first);
var second = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(
rewritten, second, NoOpSpellCatalog.Instance, out string error2), error2);
AssertNavigationEqual(first, second);
}
// Proof (3): for the met/ and nav/ samples that have a matching af/
// conversion, our model loaded from the binary import path equals our
// model loaded from metaf's own .af conversion.
public static TheoryData<string, string> MetMatchingAfPairs()
{
var data = new TheoryData<string, string>();
string metDir = Path.Combine(FixturesRoot, "met");
string afDir = Path.Combine(FixturesRoot, "af");
foreach (string metPath in Directory.GetFiles(metDir, "*.met"))
{
string name = Path.GetFileNameWithoutExtension(metPath);
string afPath = Path.Combine(afDir, name + ".af");
if (File.Exists(afPath))
data.Add(metPath, afPath);
}
return data;
}
[Theory]
[MemberData(nameof(MetMatchingAfPairs))]
public void BinaryImportMatchesMetafAfConversion(string metPath, string afPath)
{
string metText = File.ReadAllText(metPath);
Assert.True(
VtankMetaProfileSerializer.TryLoad(metText, out MetaProfile fromMet, out string metError),
$"{Path.GetFileName(metPath)}: {metError}");
string afText = File.ReadAllText(afPath);
Assert.True(
MetafSerializer.TryLoadMeta(afText, NoOpSpellCatalog.Instance, out MetaProfile fromAf, out string afError),
$"{Path.GetFileName(afPath)}: {afError}");
AssertProfilesEqual(fromMet, fromAf);
}
public static TheoryData<string, string> NavMatchingAfPairs()
{
var data = new TheoryData<string, string>();
string navDir = Path.Combine(FixturesRoot, "nav");
string afDir = Path.Combine(FixturesRoot, "af");
foreach (string navPath in Directory.GetFiles(navDir, "*.nav"))
{
string name = Path.GetFileNameWithoutExtension(navPath);
string afPath = Path.Combine(afDir, name + ".af");
if (File.Exists(afPath))
data.Add(navPath, afPath);
}
return data;
}
[Theory]
[MemberData(nameof(NavMatchingAfPairs))]
public void BinaryNavImportMatchesMetafAfConversion(string navPath, string afPath)
{
string navText = File.ReadAllText(navPath);
var fromNav = new NavigationSettings();
Assert.True(
VtankNavRouteSerializer.TryLoad(navText, fromNav, NoOpSpellCatalog.Instance, out string navError),
$"{Path.GetFileName(navPath)}: {navError}");
string afText = File.ReadAllText(afPath);
var fromAf = new NavigationSettings();
Assert.True(
MetafSerializer.TryLoadNav(afText, fromAf, NoOpSpellCatalog.Instance, out string afError),
$"{Path.GetFileName(afPath)}: {afError}");
AssertNavigationEqual(fromNav, fromAf);
}
// Proof (4): the writer's output is byte-identical to metaf's own
// canonical emission for at least five fixtures, after stripping "~~"
// comments (metaf's own auto-completion header and any inline
// annotations do not survive a parse -> model -> write round trip
// either, on the real metaf tool — confirmed empirically by feeding
// met/aphus.met through `py metaf_monolithic.py` twice during
// authoring: the only diff against the committed af/aphus.af fixture
// was two dropped inline "~~" comments and a trailing-newline nit).
public static TheoryData<string> ByteIdenticalFixtureData()
{
var data = new TheoryData<string>();
// 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 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"));
}
return data;
}
[Theory]
[MemberData(nameof(ByteIdenticalFixtureData))]
public void WriterOutputMatchesMetafCanonicalEmission(string path)
{
string original = File.ReadAllText(path);
Assert.True(
MetafSerializer.TryLoadMeta(original, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
error);
string rewritten = MetafSerializer.SaveMeta(profile);
string expected = StripComments(original);
string actual = StripComments(rewritten);
Assert.Equal(expected, actual);
}
private static string StripComments(string text)
{
var kept = new List<string>();
foreach (string rawLine in text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'))
{
string line = rawLine.TrimEnd();
if (line.TrimStart().StartsWith("~~", StringComparison.Ordinal))
continue;
int commentIndex = line.IndexOf("~~", StringComparison.Ordinal);
string trimmed = (commentIndex >= 0 ? line[..commentIndex] : line).TrimEnd();
if (trimmed.Length != 0)
kept.Add(trimmed);
}
return string.Join("\n", kept);
}
[Fact]
public void ExampleSortMetaBinaryImportMatchesAf()
{
string metText = File.ReadAllText(Path.Combine(FixturesRoot, "met", "example_sort_meta.met"));
Assert.True(VtankMetaProfileSerializer.TryLoad(metText, out MetaProfile fromMet, out string metError), metError);
string afText = File.ReadAllText(Path.Combine(FixturesRoot, "af", "example_sort_meta.af"));
Assert.True(
MetafSerializer.TryLoadMeta(afText, NoOpSpellCatalog.Instance, out MetaProfile fromAf, out string afError),
afError);
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()
{
string af = string.Join("\r\n",
[
"STATE: {Default}",
"\tIF:\tAll",
"\t\t\tAlways",
"\t\t\tNot Death",
"\t\t\tAny",
"\t\t\t\tVendorOpen",
"\t\t\t\tVendorClosed",
"\t\tDO:\tDoAll",
"\t\t\t\tChat {hello}",
"\t\t\t\tSetState {Next}",
]) + "\r\n";
Assert.True(
MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
error);
MetaRule rule = Assert.Single(profile.Rules);
Assert.Equal(MetaConditionKind.All, rule.Condition.Kind);
Assert.Equal(3, rule.Condition.Children.Count);
Assert.Equal(MetaConditionKind.Always, rule.Condition.Children[0].Kind);
Assert.Equal(MetaConditionKind.Not, rule.Condition.Children[1].Kind);
Assert.Equal(MetaConditionKind.CharacterDeath, rule.Condition.Children[1].Children[0].Kind);
Assert.Equal(MetaConditionKind.Any, rule.Condition.Children[2].Kind);
Assert.Equal(2, rule.Condition.Children[2].Children.Count);
Assert.Equal(MetaActionKind.All, rule.Action.Kind);
Assert.Equal(2, rule.Action.Children.Count);
Assert.Equal("hello", rule.Action.Children[0].Text);
Assert.Equal("Next", rule.Action.Children[1].Text);
}
[Fact]
public void SynthesizedGetOptFollowAndJumpFixtureRoundTrips()
{
// GetOpt, the "flw" nav node, and "jmp" never appear in any real
// fixture in metas/af (grep confirmed at slice-1 authoring time), so
// this small hand-authored fixture exercises them — validated as
// real metaf grammar via `py metaf_monolithic.py` during authoring
// (not re-run automatically here, since python is not guaranteed on
// the Ubuntu portable CI lane).
string af = string.Join("\r\n",
[
"STATE: {Default}",
"\tIF:\tAlways",
"\t\tDO:\tDoAll",
"\t\t\t\tGetOpt {AttackDistance} {myvar}",
"\t\t\t\tSetOpt {AttackDistance} {myvar}",
"NAV: myfollow follow",
"\tflw 00001234 {Some Monster}",
]) + "\r\n";
Assert.True(
MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
error);
MetaRule rule = Assert.Single(profile.Rules);
MetaAction getOpt = rule.Action.Children[0];
MetaAction setOpt = rule.Action.Children[1];
Assert.Equal(MetaActionKind.GetVtankOption, getOpt.Kind);
Assert.Equal("AttackDistance", getOpt.Text);
Assert.Equal("myvar", getOpt.SecondaryText);
Assert.Equal(MetaActionKind.SetVtankOption, setOpt.Kind);
var nav = new NavigationSettings();
Assert.True(
MetafSerializer.TryLoadNav(af, nav, NoOpSpellCatalog.Instance, out string navError),
navError);
Assert.Equal(RouteMode.Target, nav.Mode);
Assert.Equal(0x00001234u, nav.FollowTargetObjectId);
Assert.Equal("Some Monster", nav.FollowTargetName);
}
[Fact]
public void JumpNodeClampsChargeMillisecondsTo2000()
{
const string af = """
NAV: j once
jmp 1 2 3 90 {True} 5000
""";
var target = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error);
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
Assert.Equal(RouteWaypointType.Jump, waypoint.Type);
Assert.Equal(2000, waypoint.JumpChargeMilliseconds);
Assert.True(waypoint.JumpRun);
Assert.Equal(90f, waypoint.JumpHeadingDegrees);
}
[Fact]
public void JumpNodeBelowClampPassesThroughUnchanged()
{
const string af = """
NAV: j once
jmp 1 2 3 90 {False} 500
""";
var target = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error);
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
Assert.Equal(500, waypoint.JumpChargeMilliseconds);
Assert.False(waypoint.JumpRun);
}
private static void AssertProfilesEqual(MetaProfile expected, MetaProfile actual)
{
Assert.Equal(expected.Rules.Count, actual.Rules.Count);
for (int i = 0; i < expected.Rules.Count; i++)
{
MetaRule a = expected.Rules[i];
MetaRule b = actual.Rules[i];
Assert.Equal(a.State, b.State);
AssertConditionsEqual(a.Condition, b.Condition);
AssertActionsEqual(a.Action, b.Action);
}
}
private static void AssertConditionsEqual(MetaCondition expected, MetaCondition actual)
{
Assert.Equal(expected.Kind, actual.Kind);
Assert.Equal(expected.Text, actual.Text);
Assert.Equal(expected.SecondaryText, actual.SecondaryText);
Assert.Equal(expected.Number, actual.Number, 6);
Assert.Equal(expected.SecondaryNumber, actual.SecondaryNumber, 6);
Assert.Equal(expected.TertiaryNumber, actual.TertiaryNumber, 6);
Assert.Equal(expected.Children.Count, actual.Children.Count);
for (int i = 0; i < expected.Children.Count; i++)
AssertConditionsEqual(expected.Children[i], actual.Children[i]);
}
private static void AssertActionsEqual(MetaAction expected, MetaAction actual)
{
Assert.Equal(expected.Kind, actual.Kind);
if (expected.Kind != MetaActionKind.LoadEmbeddedNavigationRoute)
Assert.Equal(expected.Text, actual.Text);
Assert.Equal(expected.SecondaryText, actual.SecondaryText);
Assert.Equal(expected.Number, actual.Number, 6);
Assert.Equal(expected.SecondaryNumber, actual.SecondaryNumber, 6);
Assert.Equal(expected.Children.Count, actual.Children.Count);
for (int i = 0; i < expected.Children.Count; i++)
AssertActionsEqual(expected.Children[i], actual.Children[i]);
}
private static void AssertNavigationEqual(NavigationSettings expected, NavigationSettings actual)
{
Assert.Equal(expected.Mode, actual.Mode);
if (expected.Mode == RouteMode.Target)
{
Assert.Equal(expected.FollowTargetObjectId, actual.FollowTargetObjectId);
Assert.Equal(expected.FollowTargetName, actual.FollowTargetName);
return;
}
Assert.Equal(expected.Waypoints.Count, actual.Waypoints.Count);
for (int i = 0; i < expected.Waypoints.Count; i++)
{
RouteWaypoint a = expected.Waypoints[i];
RouteWaypoint b = actual.Waypoints[i];
Assert.Equal(a.Type, b.Type);
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);
Assert.Equal(a.DurationMilliseconds, b.DurationMilliseconds);
// Recall waypoints are the one field-set that is genuinely
// asymmetric between the two formats with a no-op spell
// catalog: the binary .nav stores a spell ID and derives the
// name only via a successful catalog lookup (which NoOpSpellCatalog
// never provides), while .af stores the name literally and
// derives the ID the same (catalog-dependent) way in reverse.
// Comparing RecallSpellName/Id here would fail for reasons that
// have nothing to do with the .af port's correctness.
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.
}
}
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;
}
}
}