diff --git a/src/AcDream.Plugins.MossTank/Meta.cs b/src/AcDream.Plugins.MossTank/Meta.cs index e361d4b6c..3251d6db7 100644 --- a/src/AcDream.Plugins.MossTank/Meta.cs +++ b/src/AcDream.Plugins.MossTank/Meta.cs @@ -77,6 +77,17 @@ internal sealed class MetaAction public double Number { get; set; } public double SecondaryNumber { get; set; } public List Children { get; set; } = []; + /// + /// The parsed route for a + /// action. Replaces a prior port's binary "uTank2 NAV 1.2" text blob + /// carried in : both the .af importer + /// (MetafSerializer.ResolveEmbeddedNavs) and the .met + /// importer (VtankMetaProfileSerializer.ReadEmbeddedNavigation) + /// now produce this typed model directly, and + /// MetaEngine.LoadEmbeddedNavigationRoute consumes it without a + /// re-parse. Null only for an unresolved/never-defined tag. + /// + public NavigationSettings? EmbeddedRoute { get; set; } } internal sealed class MetaRule @@ -102,7 +113,7 @@ internal sealed class MetaServices static () => double.PositiveInfinity; public Func CountMonstersByPriority { get; init; } = static (_, _) => 0; - public Action LoadEmbeddedNavigationRoute { get; init; } = static _ => { }; + public Action LoadEmbeddedNavigationRoute { get; init; } = static _ => { }; public Func GetOption { get; init; } = static _ => ExpressionValue.Zero; public Func 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) diff --git a/src/AcDream.Plugins.MossTank/MetafSerializer.cs b/src/AcDream.Plugins.MossTank/MetafSerializer.cs index 3668ae066..26ce9ae7d 100644 --- a/src/AcDream.Plugins.MossTank/MetafSerializer.cs +++ b/src/AcDream.Plugins.MossTank/MetafSerializer.cs @@ -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 + /// Internal (not private) so other one-shot import readers can share this no-op. + internal sealed class NoOpSpells : ISpellCatalog { public static NoOpSpells Instance { get; } = new(); public IReadOnlyList KnownSelfBuffs => []; @@ -534,11 +531,10 @@ internal static class MetafSerializer /// EmbedNav stores the referenced Nav's tag in /// (see ) 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 — exactly the shape - /// already expects from a - /// binary-imported profile's embedded route (MetaEngine's - /// LoadEmbeddedNavigationRoute service feeds this straight through). + /// display name plus the typed + /// (an unresolved tag gets an empty + /// rather than null, matching the prior blob path's "0 waypoints" + /// fallback). /// 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 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"; - - /// - /// Writes the "uTank2 NAV 1.2" binary-format TEXT blob - /// already expects - /// in (per MetaEngine's existing - /// contract, previously produced by - /// VtankMetaProfileSerializer.ReadEmbeddedNavigation when - /// importing a real binary .met). This is a small, deliberate - /// duplicate of what used to be VtankNavRouteSerializer.Save — - /// 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. - /// - private static string WriteBinaryNavBlob(NavigationSettings source) - { - var lines = new List { "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 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). // ================================================================== diff --git a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs index e6c4cc2f9..676aa1396 100644 --- a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs @@ -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; diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 6ed38b81e..9648341f9 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -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(); diff --git a/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs b/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs index 342f397c6..ea8ab1815 100644 --- a/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs +++ b/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs @@ -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 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'); diff --git a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs index cf994ae63..5d86edf29 100644 --- a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs +++ b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs @@ -198,7 +198,14 @@ internal static class VtankNavRouteSerializer 0f, IsOutdoor: true); - private static void Apply(NavigationSettings source, NavigationSettings target) + /// + /// Copies every field owns from + /// into the live + /// instance (internal, not private, so MetaEngine's embedded- + /// route consumer can reuse the exact same copy the top-level import + /// path uses instead of re-parsing already-typed data). + /// + internal static void Apply(NavigationSettings source, NavigationSettings target) { target.Mode = source.Mode; target.FollowTargetObjectId = source.FollowTargetObjectId; diff --git a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs index 9934aac3b..46070bdf2 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs @@ -286,9 +286,8 @@ public sealed class MetafSerializerTests /// /// Walks every - /// action's synthesized "uTank2 NAV 1.2" blob ( - /// — the shape MetaEngine.LoadEmbeddedNavigationRoute already - /// expects) looking for a waypoint by object name. + /// action's typed looking for a + /// waypoint by object name. /// 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);