Five small fixes bundled per the round's cleanup item:
- VtankNavRouteSerializer.cs's doc comment cited a "WriteBinaryNavBlob"
method that no longer exists anywhere in the codebase (MetaEngine's
embedded-navigation contract moved to the typed MetaAction.EmbeddedRoute
NavigationSettings, saved/loaded through MetafSerializer.SaveNav/
TryLoadNav, back at round 2 step B) — corrected to name the real
mechanism.
- MossTankCommands.cs:274's comment referenced an "exports/nav/" mirror
directory that stopped existing when route profiles cut over to writing
their real .af file directly (round 2 steps 2-3) — corrected.
- docs/research/vtank-kb/07-meta-and-expressions.md section 5.2 row 6
described the pre-cutover "MossTankMetaProfileStore.WriteLegacyExport
convenience mirror" design; .af is now the SOLE authoritative Meta
store, so a disabled rule's save refusal now blocks the profile itself
— the row now says a disabled rule makes the profile file genuinely
unsaveable, not that a mirror goes stale.
- The two bare `catch (FormatException) { }` blocks that silently dropped
a corrupt monster-rule expression (one in SideCarDocument.Apply, reached
from a corrupt side-car; one in LegacyCombatProfileDocument.Apply,
reached during legacy-JSON migration) now log a warning via the host's
IPluginLogger, threaded through as an optional parameter from every call
site.
- VtankDatabase.Render()'s table-sort doc comment now states explicitly
that StringComparer.Ordinal matching .NET Framework's SortedDictionary
default order is confirmed only for the plain-ASCII table names VTank
ships (AntiExtraBuffSpells, MyMonsters, Settings, …), not as a general
claim for any string — comment only, no behavior change.
Added CorruptSideCarMonsterRuleIsLoggedNotSilentlySwallowed (FakeLogger
now captures Warn() calls via a new FakeHost.Logger property) pinning the
swallow-to-log fix.
Mutation: reverted MossTankProfileStore.cs to HEAD (keeping only the new
test) and ran it — failed with an empty Warnings collection, confirming
the silent-swallow bug before the fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
237 lines
9.6 KiB
C#
237 lines
9.6 KiB
C#
using System.Globalization;
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank;
|
|
|
|
/// <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.
|
|
/// <see cref="MetaEngine"/>'s embedded-navigation contract (a Meta rule's
|
|
/// <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/> action,
|
|
/// <c>.af</c> tag <c>EmbedNav</c>) is a typed
|
|
/// <see cref="MetaAction.EmbeddedRoute"/> <see cref="NavigationSettings"/>
|
|
/// now (round 2 step B, replacing an earlier binary-blob shape), saved and
|
|
/// loaded through the SAME <see cref="MetafSerializer.SaveNav"/>/
|
|
/// <see cref="MetafSerializer.TryLoadNav"/> grammar this class no longer
|
|
/// owns any writer for — round 3 item 12 cleanup: this doc comment
|
|
/// previously cited a "<c>WriteBinaryNavBlob</c>" method that does not
|
|
/// exist anywhere in the codebase.
|
|
/// </summary>
|
|
internal static class VtankNavRouteSerializer
|
|
{
|
|
private const string Header = "uTank2 NAV 1.2";
|
|
|
|
public static bool TryLoad(
|
|
string source,
|
|
NavigationSettings target,
|
|
ISpellCatalog spells,
|
|
out string error)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(target);
|
|
ArgumentNullException.ThrowIfNull(spells);
|
|
try
|
|
{
|
|
string nav = UnwrapEmbedded(source);
|
|
using var reader = new StringReader(nav);
|
|
if (!ReadLine(reader).Equals(Header, StringComparison.Ordinal))
|
|
throw new FormatException("Nav file version does not match uTank2 NAV 1.2.");
|
|
|
|
var parsed = new NavigationSettings
|
|
{
|
|
Enabled = target.Enabled,
|
|
Priority = target.Priority,
|
|
MinimumDistanceMeters = target.MinimumDistanceMeters,
|
|
FollowAroundCorners = target.FollowAroundCorners,
|
|
OpenDoors = target.OpenDoors,
|
|
Mode = ReadInt(reader) switch
|
|
{
|
|
1 => RouteMode.Circular,
|
|
2 => RouteMode.Linear,
|
|
3 => RouteMode.Target,
|
|
4 => RouteMode.Once,
|
|
_ => throw new FormatException("Unknown VTank navigation type."),
|
|
},
|
|
};
|
|
|
|
if (parsed.Mode == RouteMode.Target)
|
|
{
|
|
parsed.FollowTargetName = ReadLine(reader);
|
|
parsed.FollowTargetObjectId = unchecked((uint)ReadInt(reader));
|
|
}
|
|
else
|
|
{
|
|
int count = ReadInt(reader);
|
|
if (count is < 0 or > 100_000)
|
|
throw new FormatException("Invalid VTank waypoint count.");
|
|
for (int index = 0; index < count; index++)
|
|
parsed.Waypoints.Add(ReadWaypoint(reader, spells));
|
|
}
|
|
|
|
Apply(parsed, target);
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
catch (Exception exception) when (exception is FormatException
|
|
or OverflowException or EndOfStreamException)
|
|
{
|
|
error = exception.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static RouteWaypoint ReadWaypoint(
|
|
TextReader reader,
|
|
ISpellCatalog spells)
|
|
{
|
|
int type = ReadInt(reader);
|
|
double eastWest = ReadDouble(reader);
|
|
double northSouth = ReadDouble(reader);
|
|
double elevation = ReadDouble(reader);
|
|
_ = ReadLine(reader); // historical unused coordinate component
|
|
var waypoint = new RouteWaypoint
|
|
{
|
|
Type = type switch
|
|
{
|
|
0 => RouteWaypointType.Point,
|
|
1 => RouteWaypointType.Portal,
|
|
2 => RouteWaypointType.Recall,
|
|
3 => RouteWaypointType.Pause,
|
|
4 => RouteWaypointType.ChatCommand,
|
|
5 => RouteWaypointType.OpenVendor,
|
|
6 => RouteWaypointType.PortalByName,
|
|
7 => RouteWaypointType.UseNpc,
|
|
8 => RouteWaypointType.Checkpoint,
|
|
9 => RouteWaypointType.Jump,
|
|
_ => throw new FormatException($"Unknown VTank waypoint type {type}."),
|
|
},
|
|
Position = Position(eastWest, northSouth, elevation),
|
|
};
|
|
|
|
switch (type)
|
|
{
|
|
case 1:
|
|
waypoint.ObjectId = unchecked((uint)ReadInt(reader));
|
|
break;
|
|
case 2:
|
|
waypoint.RecallSpellId = checked((uint)ReadInt(reader));
|
|
if (spells.TryGet(waypoint.RecallSpellId, out PluginSpellInfo spell))
|
|
waypoint.RecallSpellName = spell.Name;
|
|
break;
|
|
case 3:
|
|
waypoint.DurationMilliseconds = ReadInt(reader);
|
|
break;
|
|
case 4:
|
|
waypoint.Text = ReadLine(reader);
|
|
break;
|
|
case 5:
|
|
waypoint.ObjectId = unchecked((uint)ReadInt(reader));
|
|
waypoint.ObjectName = ReadLine(reader);
|
|
break;
|
|
case 6:
|
|
case 7:
|
|
// The record's leading eastWest/northSouth/elevation (read
|
|
// above into waypoint.Position) is "myxyz" — where the
|
|
// character stood when the waypoint was authored. This
|
|
// trailing triple is the portal/NPC object's own recorded
|
|
// position ("tgtxyz" in metaf's parallel ptl/tlk grammar —
|
|
// see MetafSerializer.ReadNavNode), kept separately so a
|
|
// reload can still disambiguate which object at that name
|
|
// to interact with, matching the record shape exactly:
|
|
// the pre-existing single-Position port used to overwrite
|
|
// "myxyz" with this value instead of keeping both.
|
|
waypoint.ObjectName = ReadLine(reader);
|
|
waypoint.LegacyObjectClass = ReadInt(reader);
|
|
waypoint.LegacyReferenceValid = ReadBoolean(reader);
|
|
double referenceEastWest = ReadDouble(reader);
|
|
double referenceNorthSouth = ReadDouble(reader);
|
|
double referenceElevation = ReadDouble(reader);
|
|
waypoint.ReferencePosition = Position(
|
|
referenceEastWest,
|
|
referenceNorthSouth,
|
|
referenceElevation);
|
|
break;
|
|
case 9:
|
|
waypoint.JumpHeadingDegrees = checked((float)ReadDouble(reader));
|
|
waypoint.JumpRun = ReadBoolean(reader);
|
|
ParseJump(ReadLine(reader), waypoint);
|
|
break;
|
|
}
|
|
return waypoint;
|
|
}
|
|
|
|
private static void ParseJump(string source, RouteWaypoint target)
|
|
{
|
|
string value = source.Trim();
|
|
char suffix = value.Length == 0 ? '\0' : value[^1];
|
|
bool encoded = suffix is '3' or '4' or '5'
|
|
&& value.Length >= 6
|
|
&& value[^6] == '.';
|
|
string milliseconds = encoded ? value[..^1] : value;
|
|
target.JumpChargeMilliseconds = checked((int)Math.Round(
|
|
double.Parse(milliseconds, NumberStyles.Float, CultureInfo.InvariantCulture),
|
|
MidpointRounding.AwayFromZero));
|
|
target.JumpDirection = suffix switch
|
|
{
|
|
'4' when encoded => RouteJumpDirection.StrafeLeft,
|
|
'5' when encoded => RouteJumpDirection.StrafeRight,
|
|
_ => RouteJumpDirection.Forward,
|
|
};
|
|
}
|
|
|
|
private static string UnwrapEmbedded(string source)
|
|
{
|
|
string normalized = source?.Replace("\r\n", "\n", StringComparison.Ordinal)
|
|
?? string.Empty;
|
|
if (normalized.StartsWith(Header, StringComparison.Ordinal))
|
|
return normalized;
|
|
using var reader = new StringReader(normalized);
|
|
_ = ReadLine(reader); // embedded route display name
|
|
_ = ReadInt(reader); // embedded point count
|
|
return reader.ReadToEnd();
|
|
}
|
|
|
|
private static PluginNavigationPosition Position(
|
|
double eastWest,
|
|
double northSouth,
|
|
double elevation) => new(
|
|
0u,
|
|
eastWest,
|
|
northSouth,
|
|
elevation,
|
|
0f,
|
|
IsOutdoor: true);
|
|
|
|
/// <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;
|
|
target.FollowTargetName = source.FollowTargetName;
|
|
target.Waypoints.Clear();
|
|
target.Waypoints.AddRange(source.Waypoints.Select(static value => value.Clone()));
|
|
}
|
|
|
|
private static string ReadLine(TextReader reader) =>
|
|
reader.ReadLine() ?? throw new EndOfStreamException("Unexpected end of VTank nav data.");
|
|
|
|
private static int ReadInt(TextReader reader) => int.Parse(
|
|
ReadLine(reader),
|
|
NumberStyles.Integer,
|
|
CultureInfo.InvariantCulture);
|
|
|
|
private static double ReadDouble(TextReader reader) => double.Parse(
|
|
ReadLine(reader),
|
|
NumberStyles.Float,
|
|
CultureInfo.InvariantCulture);
|
|
|
|
private static bool ReadBoolean(TextReader reader) => bool.Parse(ReadLine(reader));
|
|
}
|