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:
Erik 2026-09-06 20:59:49 +02:00
parent 9a7e7c4ae8
commit 3ff9461efe
7 changed files with 168 additions and 446 deletions

View file

@ -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).
// ==================================================================

View 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)
{

View file

@ -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)
{

View file

@ -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";
}
}
}

View file

@ -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();

View file

@ -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()
{

View file

@ -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();