using System.Globalization;
namespace AcDream.Plugins.MossTank;
///
/// Reads and writes VTank's exact line-encoded CondAct Meta database.
/// The format is the public interchange contract used by legacy .met
/// profiles; it is deliberately independent from MossTank's native JSON store.
///
internal static class VtankMetaProfileSerializer
{
private static readonly string[] Header =
[
"1", "CondAct", "5", "CType", "AType", "CData", "AData",
"State", "n", "n", "n", "n", "n",
];
private static readonly string[] TablePrefix = ["TABLE", "2", "k", "v", "n", "n"];
private static readonly string[] RecursiveTablePrefix = ["TABLE", "2", "K", "V", "n", "n"];
private const int MaximumRules = 100_000;
private const int MaximumNesting = 256;
public static bool TryLoad(string source, out MetaProfile profile, out string error)
{
try
{
var reader = new LineReader(source);
reader.Expect(Header);
int count = reader.ReadInt();
if (count is < 0 or > MaximumRules)
throw reader.Error("Invalid VTank Meta rule count.");
var parsed = new MetaProfile();
for (int index = 0; index < count; index++)
{
reader.Expect("i");
int conditionType = reader.ReadInt();
reader.Expect("i");
int actionType = reader.ReadInt();
MetaCondition condition = ReadCondition(reader, conditionType, 0);
MetaAction action = ReadAction(reader, actionType, 0);
reader.Expect("s");
parsed.Rules.Add(new MetaRule
{
State = reader.Read(),
Condition = condition,
Action = action,
Enabled = true,
});
}
reader.ExpectEnd();
profile = parsed;
error = string.Empty;
return true;
}
catch (Exception exception) when (exception is FormatException
or OverflowException or ArgumentOutOfRangeException)
{
profile = new MetaProfile();
error = exception.Message;
return false;
}
}
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);
var value = new MetaCondition { Kind = ConditionKind(type) };
switch (type)
{
case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20:
reader.Expect("i", "0");
break;
case 2 or 3:
reader.Expect(RecursiveTablePrefix);
ReadConditions(reader, value, reader.ReadCount(), depth);
break;
case 4:
reader.Expect("s");
value.Text = reader.Read();
break;
case 5 or 6 or 17 or 18 or 22 or 24:
reader.Expect("i");
value.Number = reader.ReadInt();
break;
case 11 or 12:
reader.Expect(TablePrefix, "2", "s", "n", "s");
value.Text = reader.Read();
reader.Expect("s", "c", "i");
value.Number = reader.ReadInt();
break;
case 13:
reader.Expect(TablePrefix, "3", "s", "n", "s");
value.Text = reader.Read();
reader.Expect("s", "c", "i");
value.Number = reader.ReadInt();
reader.Expect("s", "r", "d");
value.SecondaryNumber = reader.ReadDouble();
break;
case 14:
reader.Expect(TablePrefix, "3", "s", "p", "i");
value.TertiaryNumber = reader.ReadInt();
reader.Expect("s", "c", "i");
value.Number = reader.ReadInt();
reader.Expect("s", "r", "d");
value.SecondaryNumber = reader.ReadDouble();
break;
case 16:
reader.Expect(TablePrefix, "1", "s", "r", "d");
value.Number = reader.ReadDouble();
break;
case 21:
reader.Expect(RecursiveTablePrefix);
if (reader.ReadCount() != 1)
throw reader.Error("VTank Meta Not requires exactly one condition.");
reader.Expect("i");
value.Children.Add(ReadCondition(reader, reader.ReadInt(), depth + 1));
break;
case 23:
reader.Expect(TablePrefix, "2", "s", "sid", "i");
value.Number = reader.ReadInt();
reader.Expect("s", "sec", "i");
value.SecondaryNumber = reader.ReadInt();
break;
case 25:
reader.Expect(TablePrefix, "1", "s", "dist", "d");
value.Number = reader.ReadDouble();
break;
case 26:
reader.Expect(TablePrefix, "1", "s", "e", "s");
value.Text = reader.Read();
break;
case 28:
reader.Expect(TablePrefix, "2", "s", "p", "s");
value.Text = reader.Read();
reader.Expect("s", "c", "s");
value.SecondaryText = reader.Read();
break;
default:
throw reader.Error($"Unknown VTank Meta condition type {type}.");
}
return value;
}
private static void ReadConditions(
LineReader reader,
MetaCondition target,
int count,
int depth)
{
for (int index = 0; index < count; index++)
{
reader.Expect("i");
target.Children.Add(ReadCondition(reader, reader.ReadInt(), depth + 1));
}
}
private static MetaAction ReadAction(LineReader reader, int type, int depth)
{
CheckDepth(reader, depth);
var value = new MetaAction { Kind = ActionKind(type) };
switch (type)
{
case 0 or 6:
reader.Expect("i", "0");
break;
case 1 or 2:
reader.Expect("s");
value.Text = reader.Read();
break;
case 3:
reader.Expect(RecursiveTablePrefix);
int count = reader.ReadCount();
for (int index = 0; index < count; index++)
{
reader.Expect("i");
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1));
}
break;
case 4:
ReadEmbeddedNavigation(reader, value);
break;
case 5:
reader.Expect(TablePrefix, "2", "s", "st", "s");
value.Text = reader.Read();
reader.Expect("s", "ret", "s");
value.SecondaryText = reader.Read();
break;
case 7 or 8:
reader.Expect(TablePrefix, "1", "s", "e", "s");
value.Text = reader.Read();
break;
case 9:
reader.Expect(TablePrefix, "3", "s", "s", "s");
value.Text = reader.Read();
reader.Expect("s", "r", "d");
value.Number = reader.ReadDouble();
reader.Expect("s", "t", "d");
value.SecondaryNumber = reader.ReadDouble();
break;
case 10 or 15:
reader.Expect(TablePrefix, "0");
break;
case 11:
reader.Expect(TablePrefix, "2", "s", "o", "s");
value.Text = reader.Read();
reader.Expect("s", "v", "s");
value.SecondaryText = reader.Read();
break;
case 12:
reader.Expect(TablePrefix, "2", "s", "o", "s");
value.Text = reader.Read();
reader.Expect("s", "v", "s");
value.SecondaryText = reader.Read();
break;
case 13:
reader.Expect(TablePrefix, "2", "s", "n", "s");
value.Text = reader.Read();
reader.Expect("s", "x", "ba");
int length = reader.ReadCount();
value.SecondaryText = reader.ReadByteArray(length);
break;
case 14:
reader.Expect(TablePrefix, "1", "s", "n", "s");
value.Text = reader.Read();
break;
default:
throw reader.Error($"Unknown VTank Meta action type {type}.");
}
return value;
}
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target)
{
reader.Expect("ba");
int serializedCharacters = reader.ReadCount();
target.SecondaryText = reader.Read();
int statedNodeCount = reader.ReadCount();
if (serializedCharacters <= 5)
{
target.Text = EmptyNavigation();
return;
}
var lines = new List { reader.ReadExpected("uTank2 NAV 1.2") };
int mode = reader.ReadInt(out string modeLine);
lines.Add(modeLine);
int actualNodeCount;
if (mode == 3)
{
lines.Add(reader.Read());
lines.Add(reader.Read());
actualNodeCount = 1;
}
else if (mode is 1 or 2 or 4)
{
actualNodeCount = reader.ReadCount(out string countLine);
lines.Add(countLine);
for (int index = 0; index < actualNodeCount; index++)
ReadNavigationNode(reader, lines);
}
else
{
throw reader.Error($"Unknown embedded VTank navigation type {mode}.");
}
if (actualNodeCount != statedNodeCount)
throw reader.Error("Embedded VTank navigation node counts do not match.");
target.Text = string.Join("\r\n", lines) + "\r\n";
}
private static void ReadNavigationNode(LineReader reader, List lines)
{
int type = reader.ReadInt(out string typeLine);
lines.Add(typeLine);
for (int index = 0; index < 4; index++)
lines.Add(reader.Read());
int extra = type switch
{
0 or 8 => 0,
1 or 2 or 3 or 4 => 1,
5 => 2,
6 or 7 => 6,
9 => 3,
_ => throw reader.Error($"Unknown embedded VTank waypoint type {type}."),
};
for (int index = 0; index < extra; index++)
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,
1 => MetaConditionKind.Always,
2 => MetaConditionKind.All,
3 => MetaConditionKind.Any,
4 => MetaConditionKind.ChatMessage,
5 => MetaConditionKind.PackSlotsLessThanOrEqual,
6 => MetaConditionKind.SecondsInStateGreaterThanOrEqual,
7 => MetaConditionKind.NavigationRouteEmpty,
8 => MetaConditionKind.CharacterDeath,
9 => MetaConditionKind.AnyVendorOpen,
10 => MetaConditionKind.VendorClosed,
11 => MetaConditionKind.InventoryItemCountLessThanOrEqual,
12 => MetaConditionKind.InventoryItemCountGreaterThanOrEqual,
13 => MetaConditionKind.MonsterNameCountWithinDistance,
14 => MetaConditionKind.MonsterPriorityCountWithinDistance,
15 => MetaConditionKind.NeedToBuff,
16 => MetaConditionKind.NoMonstersWithinDistance,
17 => MetaConditionKind.LandblockEquals,
18 => MetaConditionKind.LandcellEquals,
19 => MetaConditionKind.PortalspaceEntered,
20 => MetaConditionKind.PortalspaceExited,
21 => MetaConditionKind.Not,
22 => MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual,
23 => MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual,
24 => MetaConditionKind.BurdenPercentGreaterThanOrEqual,
25 => MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual,
26 => MetaConditionKind.Expression,
28 => MetaConditionKind.ChatMessageCapture,
_ => 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,
1 => MetaActionKind.SetMetaState,
2 => MetaActionKind.ChatCommand,
3 => MetaActionKind.All,
4 => MetaActionKind.LoadEmbeddedNavigationRoute,
5 => MetaActionKind.CallMetaState,
6 => MetaActionKind.ReturnFromCall,
7 => MetaActionKind.ExpressionAction,
8 => MetaActionKind.ChatExpression,
9 => MetaActionKind.SetWatchdog,
10 => MetaActionKind.ClearWatchdog,
11 => MetaActionKind.GetVtankOption,
12 => MetaActionKind.SetVtankOption,
13 => MetaActionKind.CreateView,
14 => MetaActionKind.DestroyView,
15 => MetaActionKind.DestroyAllViews,
_ => 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 _lines;
private int _index;
public LineReader(string source)
{
string normalized = NormalizeNewlines(source ?? string.Empty);
_lines = normalized.Split('\n', StringSplitOptions.None).ToList();
if (_lines.Count != 0 && _lines[^1].Length == 0)
_lines.RemoveAt(_lines.Count - 1);
}
public string Read()
{
if (_index >= _lines.Count)
throw Error("Unexpected end of VTank Meta data.");
return _lines[_index++];
}
public string ReadExpected(string expected)
{
string actual = Read();
if (!actual.Equals(expected, StringComparison.Ordinal))
throw Error($"Expected '{expected}', found '{actual}'.");
return actual;
}
public void Expect(params string[] expected)
{
foreach (string value in expected)
ReadExpected(value);
}
public void Expect(string[] first, params string[] rest)
{
Expect(first);
Expect(rest);
}
public int ReadInt() => int.Parse(
Read(), NumberStyles.Integer, CultureInfo.InvariantCulture);
public int ReadInt(out string line)
{
line = Read();
return int.Parse(line, NumberStyles.Integer, CultureInfo.InvariantCulture);
}
public int ReadCount()
{
int value = ReadInt();
if (value is < 0 or > MaximumRules)
throw Error("Invalid VTank Meta collection count.");
return value;
}
public int ReadCount(out string line)
{
int value = ReadInt(out line);
if (value is < 0 or > MaximumRules)
throw Error("Invalid VTank Meta collection count.");
return value;
}
public double ReadDouble() => double.Parse(
Read(), NumberStyles.Float, CultureInfo.InvariantCulture);
public string ReadByteArray(int length)
{
if (length < 0)
throw Error("Invalid VTank Meta byte-array length.");
string first = Read();
if (first.Length >= length)
{
string value = first[..length];
string remainder = first[length..];
if (remainder.Length != 0)
_lines.Insert(_index, remainder);
return value;
}
var valueBuilder = new System.Text.StringBuilder(first);
while (valueBuilder.Length < length && _index < _lines.Count)
{
valueBuilder.Append("\r\n");
valueBuilder.Append(Read());
}
if (valueBuilder.Length < length)
throw Error("Truncated VTank Meta byte array.");
string combined = valueBuilder.ToString();
string result = combined[..length];
string remaining = combined[length..];
if (remaining.Length != 0)
_lines.Insert(_index, remaining.TrimStart('\r', '\n'));
return result;
}
public void ExpectEnd()
{
while (_index < _lines.Count && _lines[_index].Length == 0)
_index++;
if (_index != _lines.Count)
throw Error($"Unexpected trailing VTank Meta data '{_lines[_index]}'.");
}
public FormatException Error(string message) =>
new($"VTank Meta line {Math.Min(_index + 1, _lines.Count + 1)}: {message}");
}
private sealed class LineWriter
{
private readonly List _lines = [];
private readonly List _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