Editor/WeaponImporter/Core/Graph/GraphKv.cs
#nullable enable annotations
using System.Collections;
using System.Globalization;
using System.Text;
namespace WeaponImporter.Core.Graph;
// Minimal KV3 value model + writer for animgraph2 documents, ported from the original importer
// (Serialization/Kv.cs + Kv3.cs, writer only). Kept separate from Core/Output/Kv3 on purpose: the
// layout differs slightly (trailing CRLF after the root, block-array rules) and this exact layout
// is the one verified to compile in the s&box AnimGraph editor. Prefixed names avoid clashing
// with WeaponImporter.Core.Output.KvObject & co.
/// <summary>Base of the animgraph KV3 value model.</summary>
public abstract class GraphKvValue
{
}
/// <summary>A KV3 object: insertion-ordered string-keyed map.</summary>
public sealed class GraphKvObject : GraphKvValue
{
private readonly List<string> _keys = new();
private readonly Dictionary<string, GraphKvValue> _map = new(StringComparer.Ordinal);
/// <summary>Keys in insertion order.</summary>
public IReadOnlyList<string> Keys => _keys;
public int Count => _keys.Count;
/// <summary>Gets a value (throws when absent) or sets it (appends new keys at the end).</summary>
public GraphKvValue this[string key]
{
get => _map[key];
set
{
if (value is null)
throw new ArgumentNullException(nameof(value));
if (_map.TryAdd(key, value))
_keys.Add(key);
else
_map[key] = value;
}
}
public GraphKvValue? GetOrNull(string key) => _map.TryGetValue(key, out var v) ? v : null;
}
/// <summary>A KV3 array.</summary>
public sealed class GraphKvArray : GraphKvValue
{
public List<GraphKvValue> Items { get; } = new();
}
public sealed class GraphKvString : GraphKvValue
{
public string Value { get; }
public GraphKvString(string value) => Value = value ?? throw new ArgumentNullException(nameof(value));
}
public sealed class GraphKvLong : GraphKvValue
{
public long Value { get; }
public GraphKvLong(long value) => Value = value;
}
public sealed class GraphKvDouble : GraphKvValue
{
public double Value { get; }
public GraphKvDouble(double value) => Value = value;
}
public sealed class GraphKvBool : GraphKvValue
{
public bool Value { get; }
public GraphKvBool(bool value) => Value = value;
}
public sealed class GraphKvNull : GraphKvValue
{
public static readonly GraphKvNull Instance = new();
}
/// <summary>Terse builders so node construction reads like the file it produces.</summary>
public static class GraphKv
{
/// <summary>Builds an object from key/value pairs; values are converted with <see cref="From"/>.</summary>
public static GraphKvObject Obj(params (string Key, object? Value)[] pairs)
{
var obj = new GraphKvObject();
foreach (var (key, value) in pairs)
obj[key] = From(value);
return obj;
}
public static GraphKvArray Arr(IEnumerable<object?> values)
{
var array = new GraphKvArray();
foreach (var value in values)
array.Items.Add(From(value));
return array;
}
public static GraphKvArray Arr(params object?[] values) => Arr((IEnumerable<object?>)values);
/// <summary>Converts a CLR value to a KV3 value.</summary>
public static GraphKvValue From(object? value) => value switch
{
null => GraphKvNull.Instance,
GraphKvValue kv => kv,
string s => new GraphKvString(s),
bool b => new GraphKvBool(b),
int i => new GraphKvLong(i),
long l => new GraphKvLong(l),
uint u => new GraphKvLong(u),
float f => new GraphKvDouble(f),
double d => new GraphKvDouble(d),
IEnumerable enumerable => Arr(enumerable.Cast<object?>()),
_ => throw new ArgumentException($"Unsupported KV3 value type {value.GetType().Name}."),
};
/// <summary>An <c>{ m_id = N }</c> reference object.</summary>
public static GraphKvObject Id(long id) => Obj(("m_id", id));
/// <summary>
/// Serializes a document in the shipped style: header line, tabs, <c>key = </c> before block
/// values, trailing commas after array entries, inline scalar arrays, CRLF line endings.
/// </summary>
public static string Serialize(string header, GraphKvObject root)
{
if (header is null)
throw new ArgumentNullException(nameof(header));
if (root is null)
throw new ArgumentNullException(nameof(root));
var sb = new StringBuilder();
sb.Append(header).Append("\r\n");
WriteObjectBlock(sb, root, 0, trailingComma: false);
return sb.ToString();
}
private static void WriteObjectBlock(StringBuilder sb, GraphKvObject obj, int indent, bool trailingComma)
{
Indent(sb, indent).Append("{\r\n");
foreach (var key in obj.Keys)
WritePair(sb, key, obj[key], indent + 1);
Indent(sb, indent).Append(trailingComma ? "},\r\n" : "}\r\n");
}
private static void WritePair(StringBuilder sb, string key, GraphKvValue value, int indent)
{
var keyText = IsIdentifier(key) ? key : QuoteString(key);
switch (value)
{
case GraphKvObject o:
Indent(sb, indent).Append(keyText).Append(" = \r\n");
WriteObjectBlock(sb, o, indent, trailingComma: false);
break;
case GraphKvArray a when IsBlockArray(a):
Indent(sb, indent).Append(keyText).Append(" = \r\n");
WriteArrayBlock(sb, a, indent, trailingComma: false);
break;
default:
Indent(sb, indent).Append(keyText).Append(" = ").Append(ScalarText(value)).Append("\r\n");
break;
}
}
private static void WriteArrayBlock(StringBuilder sb, GraphKvArray array, int indent, bool trailingComma)
{
Indent(sb, indent).Append("[\r\n");
foreach (var item in array.Items)
{
switch (item)
{
case GraphKvObject o:
WriteObjectBlock(sb, o, indent + 1, trailingComma: true);
break;
case GraphKvArray a when IsBlockArray(a):
WriteArrayBlock(sb, a, indent + 1, trailingComma: true);
break;
case GraphKvArray a:
Indent(sb, indent + 1).Append(InlineArrayText(a)).Append(",\r\n");
break;
default:
Indent(sb, indent + 1).Append(ScalarText(item)).Append(",\r\n");
break;
}
}
Indent(sb, indent).Append(trailingComma ? "],\r\n" : "]\r\n");
}
/// <summary>Arrays holding objects or block arrays are multi-line; scalar arrays stay on one line.</summary>
private static bool IsBlockArray(GraphKvArray array)
{
foreach (var item in array.Items)
{
if (item is GraphKvObject || (item is GraphKvArray nested && IsBlockArray(nested)))
return true;
}
return false;
}
private static string InlineArrayText(GraphKvArray array)
{
if (array.Items.Count == 0)
return "[ ]";
var sb = new StringBuilder("[ ");
for (var i = 0; i < array.Items.Count; i++)
{
if (i > 0)
sb.Append(", ");
sb.Append(array.Items[i] is GraphKvArray nested ? InlineArrayText(nested) : ScalarText(array.Items[i]));
}
return sb.Append(" ]").ToString();
}
private static string ScalarText(GraphKvValue value)
=> value switch
{
GraphKvString s => QuoteString(s.Value),
GraphKvLong l => l.Value.ToString(CultureInfo.InvariantCulture),
GraphKvDouble d => DoubleText(d.Value),
GraphKvBool b => b.Value ? "true" : "false",
GraphKvNull => "null",
GraphKvArray a => InlineArrayText(a),
_ => throw new FormatException($"Cannot serialize {value.GetType().Name} as a scalar."),
};
private static string DoubleText(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
throw new FormatException($"Cannot serialize non-finite double {value} to KV3.");
var s = value.ToString("R", CultureInfo.InvariantCulture);
// The editor's text reader can treat an exponent as a separate token (1E-05 becomes 1):
// expand the round-trip digits without rounding tiny offsets away.
var exponentAt = s.IndexOf('E');
if (exponentAt >= 0)
{
var negative = s[0] == '-';
var mantissa = s.Substring(negative ? 1 : 0, exponentAt - (negative ? 1 : 0));
var dot = mantissa.IndexOf('.');
var point = (dot < 0 ? mantissa.Length : dot)
+ int.Parse(s.Substring(exponentAt + 1), CultureInfo.InvariantCulture);
var digits = mantissa.Replace(".", "");
s = point <= 0 ? "0." + new string('0', -point) + digits
: point >= digits.Length ? digits + new string('0', point - digits.Length)
: digits.Insert(point, ".");
if (negative) s = "-" + s;
}
return s.Contains('.') ? s : s + ".0";
}
private static string QuoteString(string value)
{
var sb = new StringBuilder(value.Length + 2);
sb.Append('"');
foreach (var c in value)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\'': sb.Append("\\'"); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\t': sb.Append("\\t"); break;
case '\r': sb.Append("\\r"); break;
default: sb.Append(c); break;
}
}
return sb.Append('"').ToString();
}
private static bool IsIdentifier(string s)
{
if (s.Length == 0 || (!char.IsLetter(s[0]) && s[0] != '_'))
return false;
foreach (var c in s)
{
if (!char.IsLetterOrDigit(c) && c != '_')
return false;
}
return true;
}
private static StringBuilder Indent(StringBuilder sb, int indent) => sb.Append('\t', indent);
}