fix(vt): B typed MetaAction.EmbeddedRoute replaces the binary nav blob

Item B (slice-1 fix round). A LoadEmbeddedNavigationRoute action carried
a synthesized "uTank2 NAV 1.2" TEXT blob in MetaAction.Text that both
MetaEngine and the runtime consumer (MossTankPanel) had to re-parse on
every load — an unnecessary re-parse of already-typed data, and the
reason proof (3)'s AssertActionsEqual skipped comparing embedded routes
entirely (the blob's exact byte shape wasn't a meaningful comparison
target).

- MetaAction: new EmbeddedRoute (NavigationSettings?) property replaces
  the blob. Null only for a genuinely unresolved/never-defined Nav tag.
- MetaServices.LoadEmbeddedNavigationRoute: Action<string> ->
  Action<NavigationSettings?>; MetaEngine's dispatch passes
  action.EmbeddedRoute directly.
- MetafSerializer.ResolveEmbeddedNavs (.af importer) builds the
  NavigationSettings directly via the existing ApplyNavBody helper
  (renamed SynthesizeNavBlob -> BuildNavSettings) instead of serializing
  it back into text. WriteBinaryNavBlob/WriteBinaryWaypoint/
  FormatBinaryDouble are deleted outright (the only caller was the
  now-removed blob synthesis).
- SaveMeta had its own now-dead re-parse of action.Text via
  VtankNavRouteSerializer.TryLoad to rebuild each embedded NAV: block on
  save; this silently started producing EMPTY NAV: blocks once Text
  stopped carrying the blob (Text is now always cleared for this action
  kind), caught immediately by the full suite: MetaParseWriteParseIsIdentical
  started failing "Expected: Once / Actual: Circular" (a re-parsed route
  falling back to NavigationSettings' default Mode because its NAV: block
  vanished). Fixed by writing straight from action.EmbeddedRoute.
- VtankMetaProfileSerializer (.met importer): TryLoad gained an
  ISpellCatalog overload (threaded through ReadAction/ReadEmbeddedNavigation);
  ReadEmbeddedNavigation now parses its reassembled blob text through
  VtankNavRouteSerializer.TryLoad into a real NavigationSettings instead
  of handing the raw text to the caller. The existing 2-arg TryLoad
  overload defers to MetafSerializer.NoOpSpells.Instance (promoted from
  private to internal) so both test call sites and MossTankMetaProfileStore
  (which now passes _host.Automation.Spells) keep working.
- VtankNavRouteSerializer.Apply promoted from private to internal so
  MossTankPanel.LoadEmbeddedNavigationRoute can copy an already-typed
  NavigationSettings into the live _navigationSettings instance directly,
  replacing its own VtankNavRouteSerializer.TryLoad(string, ...) re-parse.
- MetafSerializerTests: AssertActionsEqual now asserts
  EmbeddedRoute is non-null on both sides and calls AssertNavigationEqual
  on them (waypoint-by-waypoint) for LoadEmbeddedNavigationRoute actions,
  instead of skipping the comparison. PtlNodeKeepsBothCoordinateTriplesDistinct's
  FindNavWaypoint helper reads action.EmbeddedRoute directly instead of
  re-parsing action.Text (which is now empty).

Full MossTank suite: 568/568 passing (net zero change in count — this
is a representation change, not new coverage, though the AssertActionsEqual
tightening now exercises real waypoint comparisons on every fixture with
an EmbedNav action that it previously skipped).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 22:22:39 +02:00
parent 0219c6e03d
commit e5bc5c6a3f
7 changed files with 84 additions and 146 deletions

View file

@ -77,6 +77,17 @@ internal sealed class MetaAction
public double Number { get; set; }
public double SecondaryNumber { get; set; }
public List<MetaAction> Children { get; set; } = [];
/// <summary>
/// The parsed route for a <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/>
/// action. Replaces a prior port's binary "uTank2 NAV 1.2" text blob
/// carried in <see cref="Text"/>: both the <c>.af</c> importer
/// (<c>MetafSerializer.ResolveEmbeddedNavs</c>) and the <c>.met</c>
/// importer (<c>VtankMetaProfileSerializer.ReadEmbeddedNavigation</c>)
/// now produce this typed model directly, and
/// <c>MetaEngine.LoadEmbeddedNavigationRoute</c> consumes it without a
/// re-parse. Null only for an unresolved/never-defined tag.
/// </summary>
public NavigationSettings? EmbeddedRoute { get; set; }
}
internal sealed class MetaRule
@ -102,7 +113,7 @@ internal sealed class MetaServices
static () => double.PositiveInfinity;
public Func<int, double, int> CountMonstersByPriority { get; init; } =
static (_, _) => 0;
public Action<string> LoadEmbeddedNavigationRoute { get; init; } = static _ => { };
public Action<NavigationSettings?> LoadEmbeddedNavigationRoute { get; init; } = static _ => { };
public Func<string, ExpressionValue> GetOption { get; init; } =
static _ => ExpressionValue.Zero;
public Func<string, ExpressionValue, bool> SetOption { get; init; } =
@ -379,7 +390,7 @@ internal sealed class MetaEngine
}
return true;
case MetaActionKind.LoadEmbeddedNavigationRoute:
_services.LoadEmbeddedNavigationRoute(action.Text);
_services.LoadEmbeddedNavigationRoute(action.EmbeddedRoute);
return true;
case MetaActionKind.CallMetaState:
if (_callStack.Count >= MaximumCallDepth)

View file

@ -234,12 +234,8 @@ internal static class MetafSerializer
}
foreach ((MetaAction action, string tag) in embedTags)
{
var nav = new NavigationSettings();
if (!string.IsNullOrEmpty(action.Text)
&& VtankNavRouteSerializer.TryLoad(action.Text, nav, NoOpSpells.Instance, out _))
{
if (action.EmbeddedRoute is { } nav)
WriteNavBlock(lines, tag, nav);
}
}
return string.Join("\r\n", lines) + "\r\n";
}
@ -262,7 +258,8 @@ internal static class MetafSerializer
AssignEmbedTags(child, tags, ref counter);
}
private sealed class NoOpSpells : ISpellCatalog
/// <summary>Internal (not private) so other one-shot import readers can share this no-op.</summary>
internal sealed class NoOpSpells : ISpellCatalog
{
public static NoOpSpells Instance { get; } = new();
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
@ -534,11 +531,10 @@ internal static class MetafSerializer
/// EmbedNav stores the referenced Nav's tag in <see cref="MetaAction.SecondaryText"/>
/// (see <see cref="ReadAction"/>) until every NAV: block has been parsed;
/// this walks the rule tree once more and replaces it with the real
/// display name plus a synthesized self-contained "uTank2 NAV 1.2" blob
/// in <see cref="MetaAction.Text"/> — exactly the shape
/// <see cref="VtankNavRouteSerializer.TryLoad"/> already expects from a
/// binary-imported profile's embedded route (MetaEngine's
/// LoadEmbeddedNavigationRoute service feeds this straight through).
/// display name plus the typed <see cref="MetaAction.EmbeddedRoute"/>
/// (an unresolved tag gets an empty <see cref="NavigationSettings"/>
/// rather than null, matching the prior blob path's "0 waypoints"
/// fallback).
/// </summary>
private static void ResolveEmbeddedNavs(
MetaProfile profile,
@ -557,15 +553,16 @@ internal static class MetafSerializer
string tag = action.SecondaryText;
string displayName = action.Text;
action.SecondaryText = displayName;
action.Text = navs.TryGetValue(tag, out var nav)
? SynthesizeNavBlob(nav.NavType, nav.Nodes, nav.FollowTargetId, nav.FollowTargetName)
: EmptyNavBlob();
action.Text = string.Empty;
action.EmbeddedRoute = navs.TryGetValue(tag, out var nav)
? BuildNavSettings(nav.NavType, nav.Nodes, nav.FollowTargetId, nav.FollowTargetName)
: new NavigationSettings();
}
foreach (MetaAction child in action.Children)
ResolveEmbeddedNavs(child, navs);
}
private static string SynthesizeNavBlob(
private static NavigationSettings BuildNavSettings(
string navType,
List<RouteWaypoint> nodes,
uint followTargetId,
@ -573,109 +570,9 @@ internal static class MetafSerializer
{
var settings = new NavigationSettings();
ApplyNavBody(settings, navType, nodes, followTargetId, followTargetName);
return WriteBinaryNavBlob(settings);
return settings;
}
private static string EmptyNavBlob() => "uTank2 NAV 1.2\r\n1\r\n0\r\n";
/// <summary>
/// Writes the "uTank2 NAV 1.2" binary-format TEXT blob
/// <see cref="MetaAction.LoadEmbeddedNavigationRoute"/> already expects
/// in <see cref="MetaAction.Text"/> (per <c>MetaEngine</c>'s existing
/// contract, previously produced by
/// <c>VtankMetaProfileSerializer.ReadEmbeddedNavigation</c> when
/// importing a real binary <c>.met</c>). This is a small, deliberate
/// duplicate of what used to be <c>VtankNavRouteSerializer.Save</c> —
/// that public writer was deleted per the owner's "MossTank does not
/// implement .met and does not author .nav" direction (only the reader
/// stays, as a one-shot import), but an EmbedNav action still needs
/// this exact in-memory blob shape, which is a MossTank runtime
/// contract, not a VTank file on disk.
/// </summary>
private static string WriteBinaryNavBlob(NavigationSettings source)
{
var lines = new List<string> { "uTank2 NAV 1.2" };
lines.Add(source.Mode switch
{
RouteMode.Circular => "1",
RouteMode.Linear => "2",
RouteMode.Target => "3",
RouteMode.Once => "4",
_ => throw new InvalidOperationException("Unknown navigation type."),
});
if (source.Mode == RouteMode.Target)
{
lines.Add(source.FollowTargetName ?? string.Empty);
lines.Add(unchecked((int)source.FollowTargetObjectId)
.ToString(CultureInfo.InvariantCulture));
return string.Join("\r\n", lines) + "\r\n";
}
lines.Add(source.Waypoints.Count.ToString(CultureInfo.InvariantCulture));
foreach (RouteWaypoint waypoint in source.Waypoints)
WriteBinaryWaypoint(lines, waypoint);
return string.Join("\r\n", lines) + "\r\n";
}
private static void WriteBinaryWaypoint(List<string> lines, RouteWaypoint waypoint)
{
lines.Add(((int)waypoint.Type).ToString(CultureInfo.InvariantCulture));
lines.Add(FormatBinaryDouble(waypoint.Position.EastWest));
lines.Add(FormatBinaryDouble(waypoint.Position.NorthSouth));
lines.Add(FormatBinaryDouble(waypoint.Position.Elevation));
lines.Add("0");
switch (waypoint.Type)
{
case RouteWaypointType.Point:
case RouteWaypointType.Checkpoint:
break;
case RouteWaypointType.Portal:
lines.Add(unchecked((int)waypoint.ObjectId).ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.Recall:
lines.Add(waypoint.RecallSpellId.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.Pause:
lines.Add(waypoint.DurationMilliseconds.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.ChatCommand:
lines.Add(waypoint.Text ?? string.Empty);
break;
case RouteWaypointType.OpenVendor:
lines.Add(unchecked((int)waypoint.ObjectId).ToString(CultureInfo.InvariantCulture));
lines.Add(waypoint.ObjectName ?? string.Empty);
break;
case RouteWaypointType.PortalByName:
case RouteWaypointType.UseNpc:
lines.Add(waypoint.ObjectName ?? string.Empty);
int objectClass = waypoint.LegacyObjectClass != 0
? waypoint.LegacyObjectClass
: waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37;
lines.Add(objectClass.ToString(CultureInfo.InvariantCulture));
lines.Add(waypoint.LegacyReferenceValid.ToString(CultureInfo.InvariantCulture));
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));
lines.Add(waypoint.JumpRun.ToString(CultureInfo.InvariantCulture));
string suffix = waypoint.JumpDirection switch
{
RouteJumpDirection.StrafeLeft => "4",
RouteJumpDirection.StrafeRight => "5",
_ => "3",
};
lines.Add(waypoint.JumpChargeMilliseconds.ToString(
"0.0000", CultureInfo.InvariantCulture) + suffix);
break;
default:
throw new InvalidOperationException($"Unknown waypoint type {waypoint.Type}.");
}
}
private static string FormatBinaryDouble(double value) =>
Convert.ToString(value, CultureInfo.InvariantCulture) ?? "0";
// ==================================================================
// NAV: blocks (both inside a full .af meta and in a nav-only file).
// ==================================================================

View file

@ -139,7 +139,8 @@ internal sealed class MossTankMetaProfileStore
notice = $"VTank Meta file '{normalized}.met' was not found in imports.";
return false;
}
if (!VtankMetaProfileSerializer.TryLoad(source, out profile, out string error))
if (!VtankMetaProfileSerializer.TryLoad(
source, _host.Automation.Spells, out profile, out string error))
{
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;

View file

@ -2416,19 +2416,16 @@ internal sealed partial class MossTankPanel
return nearest;
}
private void LoadEmbeddedNavigationRoute(string source)
private void LoadEmbeddedNavigationRoute(NavigationSettings? route)
{
_navigation.Reset();
if (!VtankNavRouteSerializer.TryLoad(
source,
_navigationSettings,
_host.Automation.Spells,
out string error))
if (route is null)
{
_routeNotice = $"Embedded route rejected: {error}";
_host.Log.Warn($"MossTank Meta embedded route rejected: {error}");
_routeNotice = "Embedded route rejected: unresolved Nav tag.";
_host.Log.Warn("MossTank Meta embedded route rejected: unresolved Nav tag.");
return;
}
VtankNavRouteSerializer.Apply(route, _navigationSettings);
_routeProfiles.SaveCurrent(_navigationSettings);
_selectedRouteWaypoint = 0;
RefreshRouteEditor();

View file

@ -1,4 +1,5 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
@ -25,8 +26,16 @@ internal static class VtankMetaProfileSerializer
private const int MaximumRules = 100_000;
private const int MaximumNesting = 256;
public static bool TryLoad(string source, out MetaProfile profile, out string error)
public static bool TryLoad(string source, out MetaProfile profile, out string error) =>
TryLoad(source, MetafSerializer.NoOpSpells.Instance, out profile, out error);
public static bool TryLoad(
string source,
ISpellCatalog spells,
out MetaProfile profile,
out string error)
{
ArgumentNullException.ThrowIfNull(spells);
try
{
var reader = new LineReader(source);
@ -43,7 +52,7 @@ internal static class VtankMetaProfileSerializer
reader.Expect("i");
int actionType = reader.ReadInt();
MetaCondition condition = ReadCondition(reader, conditionType, 0);
MetaAction action = ReadAction(reader, actionType, 0);
MetaAction action = ReadAction(reader, actionType, 0, spells);
reader.Expect("s");
parsed.Rules.Add(new MetaRule
{
@ -160,7 +169,7 @@ internal static class VtankMetaProfileSerializer
}
}
private static MetaAction ReadAction(LineReader reader, int type, int depth)
private static MetaAction ReadAction(LineReader reader, int type, int depth, ISpellCatalog spells)
{
CheckDepth(reader, depth);
var value = new MetaAction { Kind = ActionKind(type) };
@ -179,11 +188,11 @@ internal static class VtankMetaProfileSerializer
for (int index = 0; index < count; index++)
{
reader.Expect("i");
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1));
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1, spells));
}
break;
case 4:
ReadEmbeddedNavigation(reader, value);
ReadEmbeddedNavigation(reader, value, spells);
break;
case 5:
reader.Expect(TablePrefix, "2", "s", "st", "s");
@ -235,7 +244,7 @@ internal static class VtankMetaProfileSerializer
return value;
}
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target)
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target, ISpellCatalog spells)
{
reader.Expect("ba");
int serializedCharacters = reader.ReadCount();
@ -243,7 +252,7 @@ internal static class VtankMetaProfileSerializer
int statedNodeCount = reader.ReadCount();
if (serializedCharacters <= 5)
{
target.Text = EmptyNavigation();
target.EmbeddedRoute = new NavigationSettings();
return;
}
@ -270,7 +279,15 @@ internal static class VtankMetaProfileSerializer
}
if (actualNodeCount != statedNodeCount)
throw reader.Error("Embedded VTank navigation node counts do not match.");
target.Text = string.Join("\r\n", lines) + "\r\n";
// Parse the reassembled "uTank2 NAV 1.2" text through the same
// reader a top-level binary .nav import uses (item B: MetaAction
// carries a typed NavigationSettings, not a re-parse-me blob).
string blob = string.Join("\r\n", lines) + "\r\n";
var route = new NavigationSettings();
if (!VtankNavRouteSerializer.TryLoad(blob, route, spells, out string navError))
throw reader.Error($"Embedded VTank navigation route is invalid: {navError}");
target.EmbeddedRoute = route;
}
private static void ReadNavigationNode(LineReader reader, List<string> lines)
@ -292,8 +309,6 @@ internal static class VtankMetaProfileSerializer
lines.Add(reader.Read());
}
private static string EmptyNavigation() => "uTank2 NAV 1.2\r\n1\r\n0\r\n";
private static string NormalizeNewlines(string value) => value
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');

View file

@ -198,7 +198,14 @@ internal static class VtankNavRouteSerializer
0f,
IsOutdoor: true);
private static void Apply(NavigationSettings source, NavigationSettings target)
/// <summary>
/// Copies every field <see cref="NavigationSettings"/> owns from
/// <paramref name="source"/> into the live <paramref name="target"/>
/// instance (internal, not private, so <c>MetaEngine</c>'s embedded-
/// route consumer can reuse the exact same copy the top-level import
/// path uses instead of re-parsing already-typed data).
/// </summary>
internal static void Apply(NavigationSettings source, NavigationSettings target)
{
target.Mode = source.Mode;
target.FollowTargetObjectId = source.FollowTargetObjectId;

View file

@ -286,9 +286,8 @@ public sealed class MetafSerializerTests
/// <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.
/// action's typed <see cref="MetaAction.EmbeddedRoute"/> looking for a
/// waypoint by object name.
/// </summary>
private static RouteWaypoint FindNavWaypoint(MetaProfile profile, string objectName)
{
@ -303,11 +302,9 @@ public sealed class MetafSerializerTests
private static RouteWaypoint? FindNavWaypoint(MetaAction action, string objectName)
{
if (action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute)
if (action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute
&& action.EmbeddedRoute is { } nav)
{
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)
@ -454,8 +451,21 @@ public sealed class MetafSerializerTests
private static void AssertActionsEqual(MetaAction expected, MetaAction actual)
{
Assert.Equal(expected.Kind, actual.Kind);
if (expected.Kind != MetaActionKind.LoadEmbeddedNavigationRoute)
if (expected.Kind == MetaActionKind.LoadEmbeddedNavigationRoute)
{
// Item B: MetaAction carries a typed EmbeddedRoute, not a
// re-parse-me blob in Text (which is now always empty for this
// action kind) — compare the routes waypoint-by-waypoint
// instead of skipping the comparison entirely.
Assert.Equal(expected.Text, actual.Text);
Assert.NotNull(expected.EmbeddedRoute);
Assert.NotNull(actual.EmbeddedRoute);
AssertNavigationEqual(expected.EmbeddedRoute!, actual.EmbeddedRoute!);
}
else
{
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);