From 36bd1d2829278bfdb9f5e31094944e9bd0ea4e1f Mon Sep 17 00:00:00 2001 From: Yaakov Date: Wed, 3 Jul 2024 22:18:34 +1000 Subject: [PATCH] Implement support for KV1 binary serialization with string tables (#100) * fix typo in existing test case * Bump C# lang version to latest (12 with SDK8) * Implement binary string table support * add constructors to apisurface test --- Directory.Build.props | 1 + .../ValveKeyValue.Test/ApiSurfaceTestCase.cs | 31 +++++++ .../Binary/SimpleBinaryTestCase.cs | 2 +- .../Binary/StringTableFromScratchTestCase.cs | 29 +++++++ .../Binary/StringTableTestCase.cs | 87 +++++++++++++++++++ .../Test Data/apisurface.txt | 32 +++++++ .../KeyValues1/KV1BinaryReader.cs | 17 +++- ValveKeyValue/ValveKeyValue/KVSerializer.cs | 5 +- .../ValveKeyValue/KVSerializerOptions.cs | 6 ++ .../KeyValues1/KV1BinarySerializer.cs | 21 ++++- ValveKeyValue/ValveKeyValue/StringTable.cs | 83 ++++++++++++++++++ 11 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 ValveKeyValue/ValveKeyValue.Test/Binary/StringTableFromScratchTestCase.cs create mode 100644 ValveKeyValue/ValveKeyValue.Test/Binary/StringTableTestCase.cs create mode 100644 ValveKeyValue/ValveKeyValue/StringTable.cs diff --git a/Directory.Build.props b/Directory.Build.props index 9f4d998c..717e1398 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,6 +7,7 @@ $(ProjectBaseVersion) $(ProjectVersion) $(ProjectVersion) + latest diff --git a/ValveKeyValue/ValveKeyValue.Test/ApiSurfaceTestCase.cs b/ValveKeyValue/ValveKeyValue.Test/ApiSurfaceTestCase.cs index 0fa2f964..0b2ef39c 100644 --- a/ValveKeyValue/ValveKeyValue.Test/ApiSurfaceTestCase.cs +++ b/ValveKeyValue/ValveKeyValue.Test/ApiSurfaceTestCase.cs @@ -83,6 +83,37 @@ static void GenerateTypeApiSurface(StringBuilder sb, Type type) sb.Append('\n'); } + var constructors = type + .GetConstructors(BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(t => !t.IsPrivate && !t.IsAssembly && !t.IsFamilyAndAssembly) + .OrderBy(t => t.Name, StringComparer.InvariantCulture) + .ThenBy(t => string.Join(", ", t.GetParameters().Select(GetParameterAsString)), StringComparer.InvariantCulture); + + foreach (var constructor in constructors) + { + sb.Append(" "); + + if (constructor.IsPublic) + { + sb.Append("public"); + } + else + { + sb.Append("protected"); + } + + if (constructor.IsStatic) + { + sb.Append(" static"); + } + + sb.Append(' '); + sb.Append(constructor.Name); + sb.Append('('); + sb.Append(string.Join(", ", constructor.GetParameters().Select(GetParameterAsString))); + sb.Append(");\n"); + } + var methods = type .GetMethods(BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .Where(t => !t.IsPrivate && !t.IsAssembly && !t.IsFamilyAndAssembly) diff --git a/ValveKeyValue/ValveKeyValue.Test/Binary/SimpleBinaryTestCase.cs b/ValveKeyValue/ValveKeyValue.Test/Binary/SimpleBinaryTestCase.cs index 81bcd01d..24a2f958 100644 --- a/ValveKeyValue/ValveKeyValue.Test/Binary/SimpleBinaryTestCase.cs +++ b/ValveKeyValue/ValveKeyValue.Test/Binary/SimpleBinaryTestCase.cs @@ -56,7 +56,7 @@ public void SetUp() 0x06, // pointer: ptr = 0x11223344 0x70, 0x74, 0x72, 0x00, 0x44, 0x33, 0x22, 0x11, - 0x07, // uint64: long = 0x1122334455667788 + 0x07, // uint64: lng = 0x1122334455667788 0x6C, 0x6E, 0x67, 0x00, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x0A, // int64, i64 = 0x0102030405070809 diff --git a/ValveKeyValue/ValveKeyValue.Test/Binary/StringTableFromScratchTestCase.cs b/ValveKeyValue/ValveKeyValue.Test/Binary/StringTableFromScratchTestCase.cs new file mode 100644 index 00000000..0f79bb23 --- /dev/null +++ b/ValveKeyValue/ValveKeyValue.Test/Binary/StringTableFromScratchTestCase.cs @@ -0,0 +1,29 @@ +using System.Linq; + +namespace ValveKeyValue.Test +{ + class StringTableFromScratchTestCase + { + [Test] + public void PopulatesStringTableDuringSerialization() + { + var kv = new KVObject("root", + [ + new KVObject("key", "value"), + new KVObject("child", [ + new KVObject("key", 123), + ]), + ]); + + var stringTable = new StringTable(); + + var serializer = KVSerializer.Create(KVSerializationFormat.KeyValues1Binary); + + using var ms = new MemoryStream(); + serializer.Serialize(ms, kv, new KVSerializerOptions { StringTable = stringTable }); + + var strings = stringTable.ToArray(); + Assert.That(strings, Is.EqualTo(new[] { "root", "key", "child" })); + } + } +} diff --git a/ValveKeyValue/ValveKeyValue.Test/Binary/StringTableTestCase.cs b/ValveKeyValue/ValveKeyValue.Test/Binary/StringTableTestCase.cs new file mode 100644 index 00000000..280c593b --- /dev/null +++ b/ValveKeyValue/ValveKeyValue.Test/Binary/StringTableTestCase.cs @@ -0,0 +1,87 @@ +using System.Linq; + +namespace ValveKeyValue.Test +{ + class StringTableTestCase + { + [Test] + public void IsNotNull() + => Assert.That(obj, Is.Not.Null); + + [Test] + public void HasName() + => Assert.That(obj.Name, Is.EqualTo("TestObject")); + + [Test] + public void IsObjectWithChildren() + => Assert.That(obj.Value.ValueType, Is.EqualTo(KVValueType.Collection)); + + [TestCase(ExpectedResult = 5)] + public int HasChildren() + => obj.Children.Count(); + + [TestCase("key", "value", typeof(string))] + [TestCase("int", 0x01020304, typeof(int))] + [TestCase("flt", 1234.5678f, typeof(float))] + [TestCase("lng", 0x1122334455667788, typeof(ulong))] + [TestCase("i64", 0x0102030405060708, typeof(long))] + public void HasNamedChildWithValue(string name, object value, Type valueType) + { + Assert.That(Convert.ChangeType(obj[name], valueType), Is.EqualTo(value)); + } + + [Test] + public void SymmetricStringTableSerialization() + { + var serializer = KVSerializer.Create(KVSerializationFormat.KeyValues1Binary); + + using var ms = new MemoryStream(); + serializer.Serialize(ms, obj, new KVSerializerOptions { StringTable = new(TestStringTable) }); + + Assert.That(ms.ToArray(), Is.EqualTo(TestData.ToArray())); + } + + KVObject obj; + + [OneTimeSetUp] + public void SetUp() + { + obj = KVSerializer.Create(KVSerializationFormat.KeyValues1Binary) + .Deserialize( + TestData.ToArray(), + new KVSerializerOptions { StringTable = new(TestStringTable) }); + } + + static string[] TestStringTable => [ + "flt", + "i64", + "int", + "key", + "lng", + "TestObject" + ]; + + static ReadOnlySpan TestData => + [ + 0x00, // object: TestObject + 0x05, 0x00, 0x00, 0x00, // stringTable[5] = "TestObject", + 0x01, // string: key = value + 0x03, 0x00, 0x00, 0x00, // stringTable[3] = "key", + 0x76, 0x61, 0x6C, 0x75, 0x65, 0x00, + 0x02, // int32: int = 0x01020304 + 0x02, 0x00, 0x00, 0x00, // stringTable[2] = "int" + 0x04, 0x03, 0x02, 0x01, + 0x03, // float32: flt = 1234.5678f + 0x00, 0x00, 0x00, 0x00, // stringTable[0] = "flt" + 0x2B, 0x52, 0x9A, 0x44, + 0x07, // uint64: lng = 0x1122334455667788 + 0x04, 0x00, 0x00, 0x00, // stringTable[4] = "lng" + 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, + 0x0A, // int64, i64 = 0x0102030405070809 + 0x01, 0x00, 0x00, 0x00, // stringTable[1] = "i64" + 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, + 0x08, // end object + 0x08, // end document + ]; + } +} diff --git a/ValveKeyValue/ValveKeyValue.Test/Test Data/apisurface.txt b/ValveKeyValue/ValveKeyValue.Test/Test Data/apisurface.txt index b05adec7..39620ce3 100644 --- a/ValveKeyValue/ValveKeyValue.Test/Test Data/apisurface.txt +++ b/ValveKeyValue/ValveKeyValue.Test/Test Data/apisurface.txt @@ -5,6 +5,9 @@ public interface ValveKeyValue.IIncludedFileLoader public class ValveKeyValue.KeyValueException { + public .ctor(); + public .ctor(string message); + public .ctor(string message, Exception inner); protected void add_SerializeObjectState(EventHandler`1[[System.Runtime.Serialization.SafeSerializationEventArgs]] value); public bool Equals(object obj); protected void Finalize(); @@ -31,6 +34,7 @@ public class ValveKeyValue.KeyValueException public class ValveKeyValue.KVArrayValue { + public .ctor(); public void Add(ValveKeyValue.KVValue value); public void AddRange(System.Collections.Generic.IEnumerable`1[[ValveKeyValue.KVValue]] values); public void Clear(); @@ -74,6 +78,8 @@ public class ValveKeyValue.KVArrayValue public class ValveKeyValue.KVBinaryBlob { + public .ctor(byte[] value); + public .ctor(Memory`1[[byte]] value); public bool Equals(object obj); protected void Finalize(); public Memory`1[[byte]] get_Bytes(); @@ -104,6 +110,7 @@ public class ValveKeyValue.KVBinaryBlob public class ValveKeyValue.KVDocument { + public .ctor(string name, ValveKeyValue.KVValue value); public void Add(ValveKeyValue.KVObject value); public bool Equals(object obj); protected void Finalize(); @@ -121,6 +128,7 @@ public class ValveKeyValue.KVDocument public sealed class ValveKeyValue.KVIgnoreAttribute { + public .ctor(); public bool Equals(object obj); protected void Finalize(); public object get_TypeId(); @@ -134,6 +142,8 @@ public sealed class ValveKeyValue.KVIgnoreAttribute public class ValveKeyValue.KVObject { + public .ctor(string name, System.Collections.Generic.IEnumerable`1[[ValveKeyValue.KVObject]] items); + public .ctor(string name, ValveKeyValue.KVValue value); public void Add(ValveKeyValue.KVObject value); public bool Equals(object obj); protected void Finalize(); @@ -151,6 +161,7 @@ public class ValveKeyValue.KVObject public sealed class ValveKeyValue.KVPropertyAttribute { + public .ctor(string propertyName); public bool Equals(object obj); protected void Finalize(); public string get_PropertyName(); @@ -200,6 +211,7 @@ public class ValveKeyValue.KVSerializer public sealed class ValveKeyValue.KVSerializerOptions { + public .ctor(); public bool Equals(object obj); protected void Finalize(); public System.Collections.Generic.IList`1[[string]] get_Conditions(); @@ -207,17 +219,20 @@ public sealed class ValveKeyValue.KVSerializerOptions public bool get_EnableValveNullByteBugBehavior(); public ValveKeyValue.IIncludedFileLoader get_FileLoader(); public bool get_HasEscapeSequences(); + public ValveKeyValue.StringTable get_StringTable(); public int GetHashCode(); public Type GetType(); protected object MemberwiseClone(); public void set_EnableValveNullByteBugBehavior(bool value); public void set_FileLoader(ValveKeyValue.IIncludedFileLoader value); public void set_HasEscapeSequences(bool value); + public void set_StringTable(ValveKeyValue.StringTable value); public string ToString(); } public class ValveKeyValue.KVValue { + protected .ctor(); public bool Equals(object obj); protected void Finalize(); public ValveKeyValue.KVValue get_Item(string key); @@ -295,3 +310,20 @@ public sealed enum ValveKeyValue.KVValueType public string ToString(string format, IFormatProvider provider); } +public sealed class ValveKeyValue.StringTable +{ + public .ctor(); + public .ctor(int capacity); + public .ctor(System.Collections.Generic.IList`1[[string]] values); + public void Add(string value); + public bool Equals(object obj); + protected void Finalize(); + public string get_Item(int index); + public int GetHashCode(); + public int GetOrAdd(string value); + public Type GetType(); + protected object MemberwiseClone(); + public string[] ToArray(); + public string ToString(); +} + diff --git a/ValveKeyValue/ValveKeyValue/Deserialization/KeyValues1/KV1BinaryReader.cs b/ValveKeyValue/ValveKeyValue/Deserialization/KeyValues1/KV1BinaryReader.cs index 7c4be940..d987ca9d 100644 --- a/ValveKeyValue/ValveKeyValue/Deserialization/KeyValues1/KV1BinaryReader.cs +++ b/ValveKeyValue/ValveKeyValue/Deserialization/KeyValues1/KV1BinaryReader.cs @@ -8,7 +8,7 @@ class KV1BinaryReader : IVisitingReader { public const int BinaryMagicHeader = 0x564B4256; // VBKV - public KV1BinaryReader(Stream stream, IVisitationListener listener) + public KV1BinaryReader(Stream stream, IVisitationListener listener, StringTable stringTable) { Require.NotNull(stream, nameof(stream)); Require.NotNull(listener, nameof(listener)); @@ -20,12 +20,14 @@ public KV1BinaryReader(Stream stream, IVisitationListener listener) this.stream = stream; this.listener = listener; + this.stringTable = stringTable; reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); } readonly Stream stream; readonly BinaryReader reader; readonly IVisitationListener listener; + readonly StringTable stringTable; bool disposed; KV1BinaryNodeType endMarker = KV1BinaryNodeType.End; @@ -74,9 +76,20 @@ void ReadObjectCore() } } + string ReadKeyForNextValue() + { + if (stringTable is not null) + { + var index = reader.ReadInt32(); + return stringTable[index]; + } + + return Encoding.UTF8.GetString(ReadNullTerminatedBytes()); + } + void ReadValue(KV1BinaryNodeType type) { - var name = Encoding.UTF8.GetString(ReadNullTerminatedBytes()); + var name = ReadKeyForNextValue(); KVValue value; switch (type) diff --git a/ValveKeyValue/ValveKeyValue/KVSerializer.cs b/ValveKeyValue/ValveKeyValue/KVSerializer.cs index 965765d8..35fac7fc 100644 --- a/ValveKeyValue/ValveKeyValue/KVSerializer.cs +++ b/ValveKeyValue/ValveKeyValue/KVSerializer.cs @@ -115,7 +115,7 @@ IVisitingReader MakeReader(Stream stream, IParsingVisitationListener listener, K return format switch { KVSerializationFormat.KeyValues1Text => new KV1TextReader(new StreamReader(stream), listener, options), - KVSerializationFormat.KeyValues1Binary => new KV1BinaryReader(stream, listener), + KVSerializationFormat.KeyValues1Binary => new KV1BinaryReader(stream, listener, options.StringTable), _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Invalid serialization format."), }; } @@ -128,9 +128,10 @@ IVisitationListener MakeSerializer(Stream stream, KVSerializerOptions options) return format switch { KVSerializationFormat.KeyValues1Text => new KV1TextSerializer(stream, options), - KVSerializationFormat.KeyValues1Binary => new KV1BinarySerializer(stream), + KVSerializationFormat.KeyValues1Binary => new KV1BinarySerializer(stream, options.StringTable), _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Invalid serialization format."), }; + ; } } } diff --git a/ValveKeyValue/ValveKeyValue/KVSerializerOptions.cs b/ValveKeyValue/ValveKeyValue/KVSerializerOptions.cs index d3b3c8d6..f58367e5 100644 --- a/ValveKeyValue/ValveKeyValue/KVSerializerOptions.cs +++ b/ValveKeyValue/ValveKeyValue/KVSerializerOptions.cs @@ -27,6 +27,12 @@ public sealed class KVSerializerOptions /// public IIncludedFileLoader FileLoader { get; set; } + + /// + /// Gets or sets the string table used for smaller binary serialization. + /// + public StringTable StringTable { get; set; } + /// /// Gets the default options (used when none are specified). /// diff --git a/ValveKeyValue/ValveKeyValue/Serialization/KeyValues1/KV1BinarySerializer.cs b/ValveKeyValue/ValveKeyValue/Serialization/KeyValues1/KV1BinarySerializer.cs index 6c2dc155..4a5705d3 100644 --- a/ValveKeyValue/ValveKeyValue/Serialization/KeyValues1/KV1BinarySerializer.cs +++ b/ValveKeyValue/ValveKeyValue/Serialization/KeyValues1/KV1BinarySerializer.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Text; using ValveKeyValue.Abstraction; using ValveKeyValue.KeyValues1; @@ -6,14 +7,16 @@ namespace ValveKeyValue.Serialization.KeyValues1 { sealed class KV1BinarySerializer : IVisitationListener, IDisposable { - public KV1BinarySerializer(Stream stream) + public KV1BinarySerializer(Stream stream, StringTable stringTable) { Require.NotNull(stream, nameof(stream)); writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + this.stringTable = stringTable; } readonly BinaryWriter writer; + readonly StringTable stringTable; int objectDepth; public void Dispose() @@ -25,7 +28,7 @@ public void OnObjectStart(string name) { objectDepth++; Write(KV1BinaryNodeType.ChildObject); - WriteNullTerminatedBytes(Encoding.UTF8.GetBytes(name)); + WriteKeyForNextValue(name); } public void OnObjectEnd() @@ -42,7 +45,7 @@ public void OnObjectEnd() public void OnKeyValuePair(string name, KVValue value) { Write(GetNodeType(value.ValueType)); - WriteNullTerminatedBytes(Encoding.UTF8.GetBytes(name)); + WriteKeyForNextValue(name); switch (value.ValueType) { @@ -83,6 +86,18 @@ void WriteNullTerminatedBytes(byte[] value) writer.Write((byte)0); } + void WriteKeyForNextValue(string name) + { + if (stringTable is not null) + { + writer.Write(stringTable.GetOrAdd(name)); + } + else + { + WriteNullTerminatedBytes(Encoding.UTF8.GetBytes(name)); + } + } + static KV1BinaryNodeType GetNodeType(KVValueType type) { return type switch diff --git a/ValveKeyValue/ValveKeyValue/StringTable.cs b/ValveKeyValue/ValveKeyValue/StringTable.cs new file mode 100644 index 00000000..0fa13ae4 --- /dev/null +++ b/ValveKeyValue/ValveKeyValue/StringTable.cs @@ -0,0 +1,83 @@ +using System.Linq; + +namespace ValveKeyValue +{ + public sealed class StringTable + { + public StringTable() + : this(new List(), writable: true) + { + } + + public StringTable(int capacity) + : this(new List(capacity), writable: true) + { + } + + public StringTable(IList values) + : this(values, writable: !values.IsReadOnly) + { + } + + StringTable(IList values, bool writable) + { + this.lookup = values; + this.writable = writable; + + reverse = new Dictionary(capacity: lookup.Count, StringComparer.Ordinal); + + for (var i = 0; i < lookup.Count; i++) + { + var value = lookup[i]; + reverse[value] = i; + } + } + + + readonly IList lookup; + readonly bool writable; + readonly Dictionary reverse; + + public string this[int index] + { + get + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), "Index must be non-negative."); + } + + if (index >= lookup.Count) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "Index must be less than the number of strings in the table."); + } + + return lookup[index]; + } + } + + public void Add(string value) + { + if (!writable) + { + throw new InvalidOperationException("Unable to add to read-only string table."); + } + + lookup.Add(value); + reverse.TryAdd(value, lookup.Count - 1); + } + + public int GetOrAdd(string value) + { + if (!reverse.TryGetValue(value, out var index)) + { + Add(value); + index = lookup.Count - 1; + } + + return index; + } + + public string[] ToArray() => lookup.ToArray(); + } +}