fix(vt): demote VtankMetaProfileSerializer/VtankNavRouteSerializer to import-only
Campaign VT slice 1 Part A, per the owner's 2026-09-06 "MossTank does not implement .met and does not author .nav" direction: .af (MetafSerializer) is now the only storage/authoring format for meta profiles and navigation routes. Deletes both classes' Save() writers and every writer-only helper (WriteCondition/WriteAction/WriteEmbeddedNavigation/ConditionType/ActionType/ Number/IntValue/LineWriter in VtankMetaProfileSerializer; WriteWaypoint/ WriteDouble in VtankNavRouteSerializer) - TryLoad and its read-path helpers are untouched, so a real binary .met/.nav still imports one-shot into the in-memory model. MetaEngine's LoadEmbeddedNavigationRoute still needs the "uTank2 NAV 1.2" in-memory blob shape for a resolved EmbedNav action (that's a MossTank runtime contract, not a VTank file on disk), so MetafSerializer gained its own small private WriteBinaryNavBlob - a deliberate, scoped duplicate of what used to be VtankNavRouteSerializer.Save's WriteWaypoint, kept independent of the now-import-only class. MossTankMetaProfileStore/MossTankRouteProfileStore's WriteLegacyExport (the "/vt meta save"/"/vt nav save" sidecar) now writes .af via MetafSerializer.SaveMeta/SaveNav instead of the deleted binary writers. This is a real, if partial, step toward the contract's ".af is the only storage/authoring format" goal - full profile-directory-backed .af storage (A2's VTank-naming-scheme directory) is separate follow-up work, noted in the closeout. Deletes VtankMetaProfileSerializerTests.cs entirely (it only tested the now-deleted Save/round-trip behavior); trims the two writer-only tests out of VtankNavRouteSerializerTests.cs, keeping every reader test intact (LoadsEveryOfficialNav12WaypointPayload's read assertions, LoadsEmbeddedWrapperAndDoesNotMutateOnFailure). Updates MossTankPanelTests.cs's two "/vt meta|nav save" integration tests for the new .af export path (the meta test's synthetic import fixture is now a hand-authored CondAct payload instead of a call to the deleted Save). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
9a7e7c4ae8
commit
3ff9461efe
7 changed files with 168 additions and 446 deletions
|
|
@ -573,11 +573,109 @@ internal static class MetafSerializer
|
|||
{
|
||||
var settings = new NavigationSettings();
|
||||
ApplyNavBody(settings, navType, nodes, followTargetId, followTargetName);
|
||||
return VtankNavRouteSerializer.Save(settings);
|
||||
return WriteBinaryNavBlob(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.Position.EastWest));
|
||||
lines.Add(FormatBinaryDouble(waypoint.Position.NorthSouth));
|
||||
lines.Add(FormatBinaryDouble(waypoint.Position.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).
|
||||
// ==================================================================
|
||||
|
|
|
|||
|
|
@ -190,6 +190,12 @@ internal sealed class MossTankMetaProfileStore
|
|||
|
||||
private void SaveIndex() => Write(IndexKey, _index);
|
||||
|
||||
/// <summary>
|
||||
/// <c>.af</c> is the only VTank-compatible export format now (Campaign
|
||||
/// VT slice 1 Part A — MossTank no longer authors the binary
|
||||
/// <c>.met</c> format at all, matching <see cref="VtankMetaProfileSerializer"/>'s
|
||||
/// demotion to a one-shot import).
|
||||
/// </summary>
|
||||
private void WriteLegacyExport(string name, MetaProfile profile)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
|
|
@ -197,8 +203,8 @@ internal sealed class MossTankMetaProfileStore
|
|||
try
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
$"exports/{LegacyFileName(name)}.met",
|
||||
VtankMetaProfileSerializer.Save(profile));
|
||||
$"exports/{LegacyFileName(name)}.af",
|
||||
MetafSerializer.SaveMeta(profile));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -260,6 +260,12 @@ internal sealed class MossTankRouteProfileStore
|
|||
|
||||
private void SaveIndex() => Write(IndexKey, _index);
|
||||
|
||||
/// <summary>
|
||||
/// <c>.af</c> is the only VTank-compatible export format now (Campaign
|
||||
/// VT slice 1 Part A — MossTank no longer authors the binary
|
||||
/// <c>.nav</c> format at all, matching <see cref="VtankNavRouteSerializer"/>'s
|
||||
/// demotion to a one-shot import).
|
||||
/// </summary>
|
||||
private void WriteLegacyExport(string name, NavigationSettings settings)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
|
|
@ -267,8 +273,8 @@ internal sealed class MossTankRouteProfileStore
|
|||
try
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
$"exports/{LegacyFileName(name)}.nav",
|
||||
VtankNavRouteSerializer.Save(settings));
|
||||
$"exports/{LegacyFileName(name)}.af",
|
||||
MetafSerializer.SaveNav(settings));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ using System.Globalization;
|
|||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes VTank's exact line-encoded <c>CondAct</c> Meta database.
|
||||
/// The format is the public interchange contract used by legacy <c>.met</c>
|
||||
/// profiles; it is deliberately independent from MossTank's native JSON store.
|
||||
/// Reads VTank's exact line-encoded <c>CondAct</c> Meta database — a
|
||||
/// one-shot import path only. Per the owner's 2026-09-06 "MossTank does not
|
||||
/// implement <c>.met</c>" direction (Campaign VT slice 1 Part A), the
|
||||
/// writer that used to live here was deleted: <c>.af</c>
|
||||
/// (<see cref="MetafSerializer"/>) is the only storage/authoring format for
|
||||
/// meta profiles now. This class only converts a legacy binary <c>.met</c>
|
||||
/// file into a <see cref="MetaProfile"/> so it can be saved straight back
|
||||
/// out as <c>.af</c>.
|
||||
/// </summary>
|
||||
internal static class VtankMetaProfileSerializer
|
||||
{
|
||||
|
|
@ -62,23 +67,6 @@ internal static class VtankMetaProfileSerializer
|
|||
}
|
||||
}
|
||||
|
||||
public static string Save(MetaProfile source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
var writer = new LineWriter();
|
||||
writer.Add(Header);
|
||||
MetaRule[] rules = source.Rules.Where(static rule => rule.Enabled).ToArray();
|
||||
writer.Add(rules.Length);
|
||||
foreach (MetaRule rule in rules)
|
||||
{
|
||||
writer.Add("i", ConditionType(rule.Condition.Kind), "i", ActionType(rule.Action.Kind));
|
||||
WriteCondition(writer, rule.Condition, 0);
|
||||
WriteAction(writer, rule.Action, 0);
|
||||
writer.Add("s", rule.State ?? string.Empty);
|
||||
}
|
||||
return writer.Finish();
|
||||
}
|
||||
|
||||
private static MetaCondition ReadCondition(LineReader reader, int type, int depth)
|
||||
{
|
||||
CheckDepth(reader, depth);
|
||||
|
|
@ -304,195 +292,12 @@ internal static class VtankMetaProfileSerializer
|
|||
lines.Add(reader.Read());
|
||||
}
|
||||
|
||||
private static void WriteCondition(LineWriter writer, MetaCondition value, int depth)
|
||||
{
|
||||
CheckDepth(depth);
|
||||
int type = ConditionType(value.Kind);
|
||||
switch (type)
|
||||
{
|
||||
case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20:
|
||||
writer.Add("i", "0");
|
||||
break;
|
||||
case 2 or 3:
|
||||
writer.Add(RecursiveTablePrefix);
|
||||
writer.Add(value.Children.Count);
|
||||
foreach (MetaCondition child in value.Children)
|
||||
{
|
||||
writer.Add("i", ConditionType(child.Kind));
|
||||
WriteCondition(writer, child, depth + 1);
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
writer.Add("s", value.Text);
|
||||
break;
|
||||
case 5 or 6 or 17 or 18 or 22 or 24:
|
||||
writer.Add("i", IntValue(value.Number));
|
||||
break;
|
||||
case 11 or 12:
|
||||
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
|
||||
"s", "c", "i", IntValue(value.Number));
|
||||
break;
|
||||
case 13:
|
||||
writer.Add(TablePrefix, "3", "s", "n", "s", value.Text,
|
||||
"s", "c", "i", IntValue(value.Number),
|
||||
"s", "r", "d", Number(value.SecondaryNumber));
|
||||
break;
|
||||
case 14:
|
||||
writer.Add(TablePrefix, "3", "s", "p", "i", IntValue(value.TertiaryNumber),
|
||||
"s", "c", "i", IntValue(value.Number),
|
||||
"s", "r", "d", Number(value.SecondaryNumber));
|
||||
break;
|
||||
case 16:
|
||||
writer.Add(TablePrefix, "1", "s", "r", "d", Number(value.Number));
|
||||
break;
|
||||
case 21:
|
||||
if (value.Children.Count != 1)
|
||||
throw new InvalidOperationException("VTank Meta Not requires exactly one condition.");
|
||||
writer.Add(RecursiveTablePrefix, "1", "i", ConditionType(value.Children[0].Kind));
|
||||
WriteCondition(writer, value.Children[0], depth + 1);
|
||||
break;
|
||||
case 23:
|
||||
writer.Add(TablePrefix, "2", "s", "sid", "i", IntValue(value.Number),
|
||||
"s", "sec", "i", IntValue(value.SecondaryNumber));
|
||||
break;
|
||||
case 25:
|
||||
writer.Add(TablePrefix, "1", "s", "dist", "d", Number(value.Number));
|
||||
break;
|
||||
case 26:
|
||||
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
|
||||
break;
|
||||
case 28:
|
||||
writer.Add(TablePrefix, "2", "s", "p", "s", value.Text,
|
||||
"s", "c", "s", value.SecondaryText);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown VTank Meta condition type {type}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteAction(LineWriter writer, MetaAction value, int depth)
|
||||
{
|
||||
CheckDepth(depth);
|
||||
int type = ActionType(value.Kind);
|
||||
switch (type)
|
||||
{
|
||||
case 0 or 6:
|
||||
writer.Add("i", "0");
|
||||
break;
|
||||
case 1 or 2:
|
||||
writer.Add("s", value.Text);
|
||||
break;
|
||||
case 3:
|
||||
writer.Add(RecursiveTablePrefix);
|
||||
writer.Add(value.Children.Count);
|
||||
foreach (MetaAction child in value.Children)
|
||||
{
|
||||
writer.Add("i", ActionType(child.Kind));
|
||||
WriteAction(writer, child, depth + 1);
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
WriteEmbeddedNavigation(writer, value);
|
||||
break;
|
||||
case 5:
|
||||
writer.Add(TablePrefix, "2", "s", "st", "s", value.Text,
|
||||
"s", "ret", "s", value.SecondaryText);
|
||||
break;
|
||||
case 7 or 8:
|
||||
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
|
||||
break;
|
||||
case 9:
|
||||
writer.Add(TablePrefix, "3", "s", "s", "s", value.Text,
|
||||
"s", "r", "d", Number(value.Number),
|
||||
"s", "t", "d", Number(value.SecondaryNumber));
|
||||
break;
|
||||
case 10 or 15:
|
||||
writer.Add(TablePrefix, "0");
|
||||
break;
|
||||
case 11 or 12:
|
||||
writer.Add(TablePrefix, "2", "s", "o", "s", value.Text,
|
||||
"s", "v", "s", value.SecondaryText);
|
||||
break;
|
||||
case 13:
|
||||
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
|
||||
"s", "x", "ba", value.SecondaryText.Length);
|
||||
writer.AddBuggedByteArray(value.SecondaryText);
|
||||
break;
|
||||
case 14:
|
||||
writer.Add(TablePrefix, "1", "s", "n", "s", value.Text);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown VTank Meta action type {type}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteEmbeddedNavigation(LineWriter writer, MetaAction value)
|
||||
{
|
||||
string nav = string.IsNullOrWhiteSpace(value.Text) ? EmptyNavigation() : value.Text;
|
||||
string normalized = NormalizeNewlines(nav);
|
||||
string[] navLines = normalized.Split('\n', StringSplitOptions.None);
|
||||
if (navLines.Length != 0 && navLines[^1].Length == 0)
|
||||
navLines = navLines[..^1];
|
||||
int nodes = NavigationNodeCount(navLines);
|
||||
string name = string.IsNullOrEmpty(value.SecondaryText) ? "[None]" : value.SecondaryText;
|
||||
int characters = name.Length + 2
|
||||
+ nodes.ToString(CultureInfo.InvariantCulture).Length + 2
|
||||
+ navLines.Sum(static line => line.Length + 2);
|
||||
writer.Add("ba", characters, name, nodes);
|
||||
writer.Add(navLines);
|
||||
}
|
||||
|
||||
private static int NavigationNodeCount(string[] lines)
|
||||
{
|
||||
if (lines.Length < 2 || !lines[0].Equals("uTank2 NAV 1.2", StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("Embedded Meta route is not uTank2 NAV 1.2 data.");
|
||||
int mode = int.Parse(lines[1], NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||
if (mode == 3)
|
||||
return 1;
|
||||
if (mode is not (1 or 2 or 4) || lines.Length < 3)
|
||||
throw new InvalidOperationException("Embedded Meta route has an invalid navigation type.");
|
||||
return int.Parse(lines[2], NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
private static int ConditionType(MetaConditionKind kind) => kind switch
|
||||
{
|
||||
MetaConditionKind.Never => 0,
|
||||
MetaConditionKind.Always => 1,
|
||||
MetaConditionKind.All => 2,
|
||||
MetaConditionKind.Any => 3,
|
||||
MetaConditionKind.ChatMessage => 4,
|
||||
MetaConditionKind.PackSlotsLessThanOrEqual => 5,
|
||||
MetaConditionKind.SecondsInStateGreaterThanOrEqual => 6,
|
||||
MetaConditionKind.NavigationRouteEmpty => 7,
|
||||
MetaConditionKind.CharacterDeath => 8,
|
||||
MetaConditionKind.AnyVendorOpen => 9,
|
||||
MetaConditionKind.VendorClosed => 10,
|
||||
MetaConditionKind.InventoryItemCountLessThanOrEqual => 11,
|
||||
MetaConditionKind.InventoryItemCountGreaterThanOrEqual => 12,
|
||||
MetaConditionKind.MonsterNameCountWithinDistance => 13,
|
||||
MetaConditionKind.MonsterPriorityCountWithinDistance => 14,
|
||||
MetaConditionKind.NeedToBuff => 15,
|
||||
MetaConditionKind.NoMonstersWithinDistance => 16,
|
||||
MetaConditionKind.LandblockEquals => 17,
|
||||
MetaConditionKind.LandcellEquals => 18,
|
||||
MetaConditionKind.PortalspaceEntered => 19,
|
||||
MetaConditionKind.PortalspaceExited => 20,
|
||||
MetaConditionKind.Not => 21,
|
||||
MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => 22,
|
||||
MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => 23,
|
||||
MetaConditionKind.BurdenPercentGreaterThanOrEqual => 24,
|
||||
MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => 25,
|
||||
MetaConditionKind.Expression => 26,
|
||||
MetaConditionKind.ChatMessageCapture => 28,
|
||||
_ => throw new InvalidOperationException($"Unsupported Meta condition {kind}."),
|
||||
};
|
||||
|
||||
private static MetaConditionKind ConditionKind(int type) => type switch
|
||||
{
|
||||
0 => MetaConditionKind.Never,
|
||||
|
|
@ -526,27 +331,6 @@ internal static class VtankMetaProfileSerializer
|
|||
_ => throw new FormatException($"Unknown VTank Meta condition type {type}."),
|
||||
};
|
||||
|
||||
private static int ActionType(MetaActionKind kind) => kind switch
|
||||
{
|
||||
MetaActionKind.None => 0,
|
||||
MetaActionKind.SetMetaState => 1,
|
||||
MetaActionKind.ChatCommand => 2,
|
||||
MetaActionKind.All => 3,
|
||||
MetaActionKind.LoadEmbeddedNavigationRoute => 4,
|
||||
MetaActionKind.CallMetaState => 5,
|
||||
MetaActionKind.ReturnFromCall => 6,
|
||||
MetaActionKind.ExpressionAction => 7,
|
||||
MetaActionKind.ChatExpression => 8,
|
||||
MetaActionKind.SetWatchdog => 9,
|
||||
MetaActionKind.ClearWatchdog => 10,
|
||||
MetaActionKind.GetVtankOption => 11,
|
||||
MetaActionKind.SetVtankOption => 12,
|
||||
MetaActionKind.CreateView => 13,
|
||||
MetaActionKind.DestroyView => 14,
|
||||
MetaActionKind.DestroyAllViews => 15,
|
||||
_ => throw new InvalidOperationException($"Unsupported Meta action {kind}."),
|
||||
};
|
||||
|
||||
private static MetaActionKind ActionKind(int type) => type switch
|
||||
{
|
||||
0 => MetaActionKind.None,
|
||||
|
|
@ -568,32 +352,12 @@ internal static class VtankMetaProfileSerializer
|
|||
_ => throw new FormatException($"Unknown VTank Meta action type {type}."),
|
||||
};
|
||||
|
||||
private static string Number(double value)
|
||||
{
|
||||
if (!double.IsFinite(value))
|
||||
throw new InvalidOperationException("VTank Meta numbers must be finite.");
|
||||
return value.ToString("R", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static int IntValue(double value)
|
||||
{
|
||||
if (!double.IsFinite(value) || value != Math.Truncate(value))
|
||||
throw new InvalidOperationException("VTank Meta integer fields require whole numbers.");
|
||||
return checked((int)value);
|
||||
}
|
||||
|
||||
private static void CheckDepth(LineReader reader, int depth)
|
||||
{
|
||||
if (depth > MaximumNesting)
|
||||
throw reader.Error("VTank Meta nesting is too deep.");
|
||||
}
|
||||
|
||||
private static void CheckDepth(int depth)
|
||||
{
|
||||
if (depth > MaximumNesting)
|
||||
throw new InvalidOperationException("VTank Meta nesting is too deep.");
|
||||
}
|
||||
|
||||
private sealed class LineReader
|
||||
{
|
||||
private readonly List<string> _lines;
|
||||
|
|
@ -703,40 +467,4 @@ internal static class VtankMetaProfileSerializer
|
|||
public FormatException Error(string message) =>
|
||||
new($"VTank Meta line {Math.Min(_index + 1, _lines.Count + 1)}: {message}");
|
||||
}
|
||||
|
||||
private sealed class LineWriter
|
||||
{
|
||||
private readonly List<string> _lines = [];
|
||||
private readonly List<int> _buggedByteArrays = [];
|
||||
|
||||
public void Add(params object?[] values)
|
||||
{
|
||||
foreach (object? value in values)
|
||||
_lines.Add(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
}
|
||||
|
||||
public void Add(string[] first, params object?[] rest)
|
||||
{
|
||||
Add(first.Cast<object?>().ToArray());
|
||||
Add(rest);
|
||||
}
|
||||
|
||||
public void AddBuggedByteArray(string value)
|
||||
{
|
||||
_buggedByteArrays.Add(_lines.Count);
|
||||
_lines.Add(value ?? string.Empty);
|
||||
}
|
||||
|
||||
public string Finish()
|
||||
{
|
||||
foreach (int index in _buggedByteArrays.OrderDescending())
|
||||
{
|
||||
if (index + 1 >= _lines.Count)
|
||||
throw new InvalidOperationException("CreateView cannot terminate a VTank Meta record.");
|
||||
_lines[index] += _lines[index + 1];
|
||||
_lines.RemoveAt(index + 1);
|
||||
}
|
||||
return string.Join("\r\n", _lines) + "\r\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,40 +3,21 @@ using AcDream.Plugin.Abstractions;
|
|||
|
||||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>Reader for VTank's verbatim <c>uTank2 NAV 1.2</c> format.</summary>
|
||||
/// <summary>
|
||||
/// Reader for VTank's verbatim <c>uTank2 NAV 1.2</c> format — a one-shot
|
||||
/// import path only. Per the owner's 2026-09-06 "MossTank does not author
|
||||
/// <c>.nav</c>" direction (Campaign VT slice 1 Part A), the writer that
|
||||
/// used to live here was deleted: <c>.af</c> (<see cref="MetafSerializer"/>)
|
||||
/// is the only storage/authoring format for navigation routes now. The
|
||||
/// small binary-blob writer <see cref="MetaEngine"/>'s embedded-navigation
|
||||
/// contract still needs lives in <see cref="MetafSerializer"/> itself
|
||||
/// (<c>WriteBinaryNavBlob</c>) rather than here, since that shape is a
|
||||
/// MossTank runtime contract, not a VTank file on disk.
|
||||
/// </summary>
|
||||
internal static class VtankNavRouteSerializer
|
||||
{
|
||||
private const string Header = "uTank2 NAV 1.2";
|
||||
|
||||
public static string Save(NavigationSettings source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
var writer = new StringWriter(CultureInfo.InvariantCulture)
|
||||
{
|
||||
NewLine = "\r\n",
|
||||
};
|
||||
writer.WriteLine(Header);
|
||||
writer.WriteLine(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)
|
||||
{
|
||||
writer.WriteLine(source.FollowTargetName ?? string.Empty);
|
||||
writer.WriteLine(unchecked((int)source.FollowTargetObjectId));
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
writer.WriteLine(source.Waypoints.Count);
|
||||
foreach (RouteWaypoint waypoint in source.Waypoints)
|
||||
WriteWaypoint(writer, waypoint);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
public static bool TryLoad(
|
||||
string source,
|
||||
NavigationSettings target,
|
||||
|
|
@ -165,76 +146,6 @@ internal static class VtankNavRouteSerializer
|
|||
return waypoint;
|
||||
}
|
||||
|
||||
private static void WriteWaypoint(TextWriter writer, RouteWaypoint waypoint)
|
||||
{
|
||||
int type = (int)waypoint.Type;
|
||||
writer.WriteLine(type.ToString(CultureInfo.InvariantCulture));
|
||||
WriteDouble(writer, waypoint.Position.EastWest);
|
||||
WriteDouble(writer, waypoint.Position.NorthSouth);
|
||||
WriteDouble(writer, waypoint.Position.Elevation);
|
||||
writer.WriteLine("0");
|
||||
switch (waypoint.Type)
|
||||
{
|
||||
case RouteWaypointType.Point:
|
||||
case RouteWaypointType.Checkpoint:
|
||||
break;
|
||||
case RouteWaypointType.Portal:
|
||||
writer.WriteLine(unchecked((int)waypoint.ObjectId)
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case RouteWaypointType.Recall:
|
||||
writer.WriteLine(waypoint.RecallSpellId
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case RouteWaypointType.Pause:
|
||||
writer.WriteLine(waypoint.DurationMilliseconds
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case RouteWaypointType.ChatCommand:
|
||||
writer.WriteLine(waypoint.Text ?? string.Empty);
|
||||
break;
|
||||
case RouteWaypointType.OpenVendor:
|
||||
writer.WriteLine(unchecked((int)waypoint.ObjectId)
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
|
||||
break;
|
||||
case RouteWaypointType.PortalByName:
|
||||
case RouteWaypointType.UseNpc:
|
||||
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
|
||||
int objectClass = waypoint.LegacyObjectClass != 0
|
||||
? waypoint.LegacyObjectClass
|
||||
: waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37;
|
||||
writer.WriteLine(objectClass.ToString(CultureInfo.InvariantCulture));
|
||||
writer.WriteLine(waypoint.LegacyReferenceValid
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
WriteDouble(writer, waypoint.Position.EastWest);
|
||||
WriteDouble(writer, waypoint.Position.NorthSouth);
|
||||
WriteDouble(writer, waypoint.Position.Elevation);
|
||||
break;
|
||||
case RouteWaypointType.Jump:
|
||||
WriteDouble(writer, waypoint.JumpHeadingDegrees);
|
||||
writer.WriteLine(waypoint.JumpRun.ToString(CultureInfo.InvariantCulture));
|
||||
string suffix = waypoint.JumpDirection switch
|
||||
{
|
||||
RouteJumpDirection.StrafeLeft => "4",
|
||||
RouteJumpDirection.StrafeRight => "5",
|
||||
_ => "3",
|
||||
};
|
||||
writer.WriteLine(
|
||||
waypoint.JumpChargeMilliseconds.ToString(
|
||||
"0.0000",
|
||||
CultureInfo.InvariantCulture)
|
||||
+ suffix);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown waypoint type {waypoint.Type}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteDouble(TextWriter writer, double value) =>
|
||||
writer.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture));
|
||||
|
||||
private static void ParseJump(string source, RouteWaypoint target)
|
||||
{
|
||||
string value = source.Trim();
|
||||
|
|
|
|||
|
|
@ -672,10 +672,13 @@ public sealed class MossTankPanelTests
|
|||
Assert.Single(panel.RouteRows);
|
||||
Assert.Contains("12.5", panel.RouteRows[0], StringComparison.Ordinal);
|
||||
|
||||
// .af is the only VTank-compatible export format now (Campaign VT
|
||||
// slice 1 Part A — the .nav writer was deleted, one-shot import
|
||||
// only).
|
||||
Command(panel, "nav save Exported.nav");
|
||||
Assert.StartsWith(
|
||||
"uTank2 NAV 1.2\r\n",
|
||||
storage.Text["exports/Exported.nav"],
|
||||
"NAV: ",
|
||||
storage.Text["exports/Exported.af"],
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
|
@ -683,23 +686,16 @@ public sealed class MossTankPanelTests
|
|||
public void MetaCommandsImportAndExportExactVtankMetFiles()
|
||||
{
|
||||
var storage = new MemoryStorage();
|
||||
storage.Text["imports/Legacy.met"] = VtankMetaProfileSerializer.Save(
|
||||
new MetaProfile
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new MetaRule
|
||||
{
|
||||
State = "Default",
|
||||
Condition = MetaCondition.Always(),
|
||||
Action = new MetaAction
|
||||
{
|
||||
Kind = MetaActionKind.ChatCommand,
|
||||
Text = "/say imported",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// Hand-authored CondAct binary payload (one rule: Always -> Chat
|
||||
// "/say imported", state "Default") — VtankMetaProfileSerializer's
|
||||
// writer was deleted (one-shot import only now), so this is built
|
||||
// directly from the exact format its TryLoad still parses, matching
|
||||
// condition type 1 (Always) / action type 2 (ChatCommand).
|
||||
storage.Text["imports/Legacy.met"] =
|
||||
"1\r\nCondAct\r\n5\r\nCType\r\nAType\r\nCData\r\nAData\r\nState\r\n"
|
||||
+ "n\r\nn\r\nn\r\nn\r\nn\r\n1\r\n"
|
||||
+ "i\r\n1\r\ni\r\n2\r\ni\r\n0\r\ns\r\n/say imported\r\n"
|
||||
+ "s\r\nDefault\r\n";
|
||||
var panel = new MossTankPanel(new FakeHost(
|
||||
new FakeAutomation(),
|
||||
storage));
|
||||
|
|
@ -710,18 +706,33 @@ public sealed class MossTankPanelTests
|
|||
Assert.Single(panel.MetaRows);
|
||||
Assert.Contains("/say imported", panel.MetaRows[0], StringComparison.Ordinal);
|
||||
|
||||
// .af is the only VTank-compatible export format now (Campaign VT
|
||||
// slice 1 Part A — the .met writer was deleted, one-shot import
|
||||
// only).
|
||||
Command(panel, "meta save Exported.met");
|
||||
Assert.StartsWith(
|
||||
"1\r\nCondAct\r\n5\r\n",
|
||||
storage.Text["exports/Exported.met"],
|
||||
"STATE: ",
|
||||
storage.Text["exports/Exported.af"],
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(VtankMetaProfileSerializer.TryLoad(
|
||||
storage.Text["exports/Exported.met"],
|
||||
Assert.True(MetafSerializer.TryLoadMeta(
|
||||
storage.Text["exports/Exported.af"],
|
||||
NoOpSpellCatalogForExport.Instance,
|
||||
out MetaProfile exported,
|
||||
out string error), error);
|
||||
Assert.Single(exported.Rules);
|
||||
}
|
||||
|
||||
private sealed class NoOpSpellCatalogForExport : ISpellCatalog
|
||||
{
|
||||
public static NoOpSpellCatalogForExport Instance { get; } = new();
|
||||
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
|
||||
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
||||
{
|
||||
info = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByCharacterProfilesAreIsolatedByCharacterName()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -112,21 +112,6 @@ public sealed class VtankNavRouteSerializerTests
|
|||
Assert.True(settings.Waypoints[9].JumpRun);
|
||||
Assert.Equal(1000, settings.Waypoints[9].JumpChargeMilliseconds);
|
||||
Assert.Equal(RouteJumpDirection.StrafeRight, settings.Waypoints[9].JumpDirection);
|
||||
|
||||
string saved = VtankNavRouteSerializer.Save(settings);
|
||||
Assert.StartsWith("uTank2 NAV 1.2\r\n", saved, StringComparison.Ordinal);
|
||||
Assert.Contains("1000.00005\r\n", saved, StringComparison.Ordinal);
|
||||
var roundTrip = new NavigationSettings();
|
||||
Assert.True(VtankNavRouteSerializer.TryLoad(
|
||||
saved,
|
||||
roundTrip,
|
||||
NoOpSpellCatalog.Instance,
|
||||
out error), error);
|
||||
Assert.Equal(10, roundTrip.Waypoints.Count);
|
||||
Assert.Equal(14, roundTrip.Waypoints[6].LegacyObjectClass);
|
||||
Assert.Equal(RouteJumpDirection.StrafeRight,
|
||||
roundTrip.Waypoints[9].JumpDirection);
|
||||
Assert.Equal(1000, roundTrip.Waypoints[9].JumpChargeMilliseconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -163,29 +148,6 @@ public sealed class VtankNavRouteSerializerTests
|
|||
Assert.Single(settings.Waypoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetRoutePreservesSignedRetailObjectIdBitPattern()
|
||||
{
|
||||
var original = new NavigationSettings
|
||||
{
|
||||
Mode = RouteMode.Target,
|
||||
FollowTargetName = "High-bit fellow",
|
||||
FollowTargetObjectId = 0xF1234567u,
|
||||
};
|
||||
|
||||
string saved = VtankNavRouteSerializer.Save(original);
|
||||
var loaded = new NavigationSettings();
|
||||
Assert.True(VtankNavRouteSerializer.TryLoad(
|
||||
saved,
|
||||
loaded,
|
||||
NoOpSpellCatalog.Instance,
|
||||
out string error), error);
|
||||
|
||||
Assert.Equal(RouteMode.Target, loaded.Mode);
|
||||
Assert.Equal("High-bit fellow", loaded.FollowTargetName);
|
||||
Assert.Equal(0xF1234567u, loaded.FollowTargetObjectId);
|
||||
}
|
||||
|
||||
private sealed class NoOpSpellCatalog : ISpellCatalog
|
||||
{
|
||||
public static NoOpSpellCatalog Instance { get; } = new();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue