diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index e0c0049..9536938 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -19,7 +19,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
- dotnet-version: 9.0.x
+ dotnet-version: 10.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b709b98..cdd66c5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -20,14 +20,11 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: 9.0.x
+ dotnet-version: 10.0.x
- name: Test
run: dotnet test -c Release
- # KeyValues2 depends on KeyValues2.ElementFactoryGenerator
- name: Pack
- run: |
- dotnet pack ElementFactoryGenerator/ElementFactoryGenerator.csproj -c Release -o out
- dotnet pack Datamodel.NET/Datamodel.NET.csproj -c Release -o out
+ run: dotnet pack Datamodel.NET/Datamodel.NET.csproj -c Release -o out
# Kept next to the push: the issued key is only valid for an hour.
- name: NuGet login
uses: NuGet/login@v1
diff --git a/Benchmarks/Benchmarks.csproj b/Benchmarks/Benchmarks.csproj
new file mode 100644
index 0000000..034eb2d
--- /dev/null
+++ b/Benchmarks/Benchmarks.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ false
+ true
+ true
+
+
+
+
+
+
+
+
diff --git a/Benchmarks/Program.cs b/Benchmarks/Program.cs
new file mode 100644
index 0000000..ab0ef84
--- /dev/null
+++ b/Benchmarks/Program.cs
@@ -0,0 +1,100 @@
+using System.Diagnostics;
+using Datamodel.Codecs;
+using Tests.VMAP;
+using DM = Datamodel.Datamodel;
+
+// Measures loading a vmap as plain elements and as the typed classes of Tests/ValveMap.cs, and saving the typed model.
+//
+// Benchmarks [--iterations N] ...
+//
+// A directory contributes every .vmap inside it, largest last. Each figure is the best of N iterations (default 1).
+// "typed alloc" is the managed memory allocated by the typed load, "live heap" the managed heap that survives it.
+
+// dots as decimal separators whatever the machine locale, so that tables can be pasted anywhere
+System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.InvariantCulture;
+
+var iterations = 1;
+var inputs = new List();
+
+for (var i = 0; i < args.Length; i++)
+{
+ if (args[i] == "--iterations" && i + 1 < args.Length)
+ {
+ iterations = int.Parse(args[++i]);
+ continue;
+ }
+
+ inputs.Add(args[i]);
+}
+
+if (inputs.Count == 0)
+{
+ Console.WriteLine("usage: Benchmarks [--iterations N] ...");
+ return 1;
+}
+
+var files = inputs
+ .SelectMany(input => Directory.Exists(input) ? Directory.GetFiles(input, "*.vmap") : [input])
+ .OrderBy(file => new FileInfo(file).Length)
+ .ToList();
+
+Console.WriteLine($"{"map",-26} {"file size",9} {"elements",9} | {"read file",10} {"untyped load",13} {"typed load",11} {"typed/untyped",13} | {"typed alloc",11} {"live heap",9} | {"binary save",11}");
+
+foreach (var path in files)
+{
+ var name = Path.GetFileName(path);
+ var size = new FileInfo(path).Length;
+
+ var read = Time(() => File.ReadAllBytes(path), out var bytes);
+
+ var untyped = double.MaxValue;
+ var typed = double.MaxValue;
+ var save = double.MaxValue;
+ long allocated = 0, heap = 0, elements = 0;
+
+ for (var i = 0; i < iterations; i++)
+ {
+ Collect();
+ untyped = Math.Min(untyped, Time(() => DM.Load(new MemoryStream(bytes, false), DeferredMode.Disabled), out var plain));
+ elements = plain.AllElements.Count;
+ plain.Dispose();
+
+ Collect();
+ var before = GC.GetTotalAllocatedBytes(true);
+ typed = Math.Min(typed, Time(() => DM.Load(new MemoryStream(bytes, false), DeferredMode.Disabled), out var map));
+ allocated = GC.GetTotalAllocatedBytes(true) - before;
+ heap = GC.GetTotalMemory(true);
+
+ if (map.Root is not CMapRootElement)
+ {
+ throw new InvalidOperationException($"{name}: the root was not loaded as {nameof(CMapRootElement)}");
+ }
+
+ save = Math.Min(save, Time(() => { map.Save(Stream.Null, "binary", 9); return 0; }, out _));
+ map.Dispose();
+ }
+
+ Console.WriteLine($"{name,-26} {size / 1048576.0,7:F1}MB {elements,9} | {Duration(read),10} {Duration(untyped),13} {Duration(typed),11} {typed / untyped,12:F2}x | {allocated / 1048576.0,9:F0}MB {heap / 1048576.0,7:F0}MB | {Duration(save),11}");
+}
+
+return 0;
+
+/// Milliseconds up to a tenth of a second, seconds with two decimals above.
+static string Duration(double milliseconds)
+{
+ return milliseconds < 100 ? $"{milliseconds:F0}ms" : $"{milliseconds / 1000:F2}s";
+}
+
+static double Time(Func action, out T result)
+{
+ var stopwatch = Stopwatch.StartNew();
+ result = action();
+ return stopwatch.Elapsed.TotalMilliseconds;
+}
+
+static void Collect()
+{
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+}
diff --git a/Benchmarks/Properties/PublishProfiles/FolderProfile.pubxml b/Benchmarks/Properties/PublishProfiles/FolderProfile.pubxml
new file mode 100644
index 0000000..61020ed
--- /dev/null
+++ b/Benchmarks/Properties/PublishProfiles/FolderProfile.pubxml
@@ -0,0 +1,16 @@
+
+
+
+
+ Release
+ Any CPU
+ bin\Release\net10.0\publish\win-x64\
+ FileSystem
+ <_TargetId>Folder
+ net10.0
+ win-x64
+ true
+ true
+ true
+
+
\ No newline at end of file
diff --git a/Benchmarks/Properties/launchSettings.json b/Benchmarks/Properties/launchSettings.json
new file mode 100644
index 0000000..dab8e3e
--- /dev/null
+++ b/Benchmarks/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "Benchmarks": {
+ "commandName": "Project",
+ "commandLineArgs": "--iterations 1 \"E:\\Steam\\steamapps\\common\\Counter-Strike Global Offensive\\content\\csgo_addons\\decomp_mesh_test_2\\maps\""
+ }
+ }
+}
\ No newline at end of file
diff --git a/Datamodel.NET.sln b/Datamodel.NET.sln
index 8e09549..d5cd3eb 100644
--- a/Datamodel.NET.sln
+++ b/Datamodel.NET.sln
@@ -1,3 +1,4 @@
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.3.32901.215
@@ -8,31 +9,113 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ElementFactoryGenerator", "ElementFactoryGenerator\ElementFactoryGenerator.csproj", "{FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.VMAP", "Tests.VMAP\Tests.VMAP.csproj", "{A96B12D7-DC15-4A8C-A477-146B87BF7C9F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "Benchmarks\Benchmarks.csproj", "{7BB7432B-C6A6-4366-B351-D6DD284EC61D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Documentation|Any CPU = Documentation|Any CPU
+ Documentation|x64 = Documentation|x64
+ Documentation|x86 = Documentation|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{075743A9-B292-410C-B68F-6E6CF588D60A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{075743A9-B292-410C-B68F-6E6CF588D60A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Debug|x64.Build.0 = Debug|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Debug|x86.Build.0 = Debug|Any CPU
{075743A9-B292-410C-B68F-6E6CF588D60A}.Documentation|Any CPU.ActiveCfg = Release|Any CPU
{075743A9-B292-410C-B68F-6E6CF588D60A}.Documentation|Any CPU.Build.0 = Release|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Documentation|x64.ActiveCfg = Documentation|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Documentation|x64.Build.0 = Documentation|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Documentation|x86.ActiveCfg = Documentation|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Documentation|x86.Build.0 = Documentation|Any CPU
{075743A9-B292-410C-B68F-6E6CF588D60A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{075743A9-B292-410C-B68F-6E6CF588D60A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Release|x64.ActiveCfg = Release|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Release|x64.Build.0 = Release|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Release|x86.ActiveCfg = Release|Any CPU
+ {075743A9-B292-410C-B68F-6E6CF588D60A}.Release|x86.Build.0 = Release|Any CPU
{4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Debug|x64.Build.0 = Debug|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Debug|x86.Build.0 = Debug|Any CPU
{4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Documentation|Any CPU.ActiveCfg = Debug|Any CPU
{4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Documentation|Any CPU.Build.0 = Debug|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Documentation|x64.ActiveCfg = Documentation|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Documentation|x64.Build.0 = Documentation|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Documentation|x86.ActiveCfg = Documentation|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Documentation|x86.Build.0 = Documentation|Any CPU
{4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Release|x64.ActiveCfg = Release|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Release|x64.Build.0 = Release|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Release|x86.ActiveCfg = Release|Any CPU
+ {4C928D60-5E48-4C0D-9C7E-C75D9734CD58}.Release|x86.Build.0 = Release|Any CPU
{FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Debug|x64.Build.0 = Debug|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Debug|x86.Build.0 = Debug|Any CPU
{FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Documentation|Any CPU.ActiveCfg = Release|Any CPU
{FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Documentation|Any CPU.Build.0 = Release|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Documentation|x64.ActiveCfg = Documentation|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Documentation|x64.Build.0 = Documentation|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Documentation|x86.ActiveCfg = Documentation|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Documentation|x86.Build.0 = Documentation|Any CPU
{FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Release|x64.ActiveCfg = Release|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Release|x64.Build.0 = Release|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Release|x86.ActiveCfg = Release|Any CPU
+ {FE0CDDEB-F817-0758-1DFC-A472CC81F0F3}.Release|x86.Build.0 = Release|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Debug|x64.Build.0 = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Debug|x86.Build.0 = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Documentation|Any CPU.ActiveCfg = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Documentation|Any CPU.Build.0 = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Documentation|x64.ActiveCfg = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Documentation|x64.Build.0 = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Documentation|x86.ActiveCfg = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Documentation|x86.Build.0 = Debug|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Release|x64.ActiveCfg = Release|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Release|x64.Build.0 = Release|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Release|x86.ActiveCfg = Release|Any CPU
+ {A96B12D7-DC15-4A8C-A477-146B87BF7C9F}.Release|x86.Build.0 = Release|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Debug|x64.Build.0 = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Debug|x86.Build.0 = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Documentation|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Documentation|Any CPU.Build.0 = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Documentation|x64.ActiveCfg = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Documentation|x64.Build.0 = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Documentation|x86.ActiveCfg = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Documentation|x86.Build.0 = Debug|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Release|x64.ActiveCfg = Release|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Release|x64.Build.0 = Release|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Release|x86.ActiveCfg = Release|Any CPU
+ {7BB7432B-C6A6-4366-B351-D6DD284EC61D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Datamodel.NET/AttributeList.cs b/Datamodel.NET/AttributeList.cs
index 1f4582f..877db71 100644
--- a/Datamodel.NET/AttributeList.cs
+++ b/Datamodel.NET/AttributeList.cs
@@ -9,7 +9,6 @@
using System.Numerics;
using AttrKVP = System.Collections.Generic.KeyValuePair;
-using System.Reflection;
using System.IO;
namespace Datamodel
@@ -21,26 +20,60 @@ namespace Datamodel
[DebuggerDisplay("Count = {Count}")]
public class AttributeList : IDictionary, IDictionary
{
- internal OrderedDictionary PropertyInfos;
internal OrderedDictionary Inner;
protected object Attribute_ChangeLock = new();
- private ICollection GetPropertyBasedAttributes(bool useSerializationName)
+ ///
+ /// Gets the properties of this class that are stored as attributes. Empty unless a schema is registered for the class.
+ ///
+ public ElementSchema Schema { get; }
+
+ private IEnumerable GetPropertyBasedAttributes(bool useSerializationName)
+ {
+ foreach (var binding in Schema.Properties)
+ {
+ var name = useSerializationName ? binding.AttributeName : binding.PropertyName;
+ yield return new Attribute(name, this, binding.GetValue(this));
+ }
+ }
+
+ ///
+ /// Converts between the bool, int and float attribute types the way Valve's datamodel does when a value is assigned
+ /// to an attribute of another of those types. Returns null for any other combination.
+ ///
+ private static object? ConvertScalar(object value, Type targetType)
{
- var result = new List();
- foreach (DictionaryEntry entry in PropertyInfos)
+ if (targetType == typeof(int))
{
- if (entry.Value is null)
+ return value switch
{
- throw new InvalidDataException("Property value can not be null");
- }
+ bool b => b ? 1 : 0,
+ float f => (int)f,
+ _ => null,
+ };
+ }
- var prop = (PropertyInfo)entry.Value;
- var name = useSerializationName ? (string)entry.Key : prop.Name;
- var attr = new Attribute(name, this, prop.GetValue(this));
- result.Add(attr);
+ if (targetType == typeof(float))
+ {
+ return value switch
+ {
+ bool b => b ? 1f : 0f,
+ int i => (float)i,
+ _ => null,
+ };
+ }
+
+ if (targetType == typeof(bool))
+ {
+ return value switch
+ {
+ int i => i != 0,
+ float f => f != 0f,
+ _ => null,
+ };
}
- return result;
+
+ return null;
}
internal class DebugView
@@ -89,36 +122,15 @@ public enum OverrideType
Binary,
}
- ///
- /// Cache of the read-only shared by every instance of a given type. The contents
- /// only depend on the type, so building one per instance is pure overhead when many are created at once.
- ///
- static readonly ConcurrentDictionary PropertyInfoCache = new();
-
public AttributeList(Datamodel? owner)
{
- PropertyInfos = PropertyInfoCache.TryGetValue(GetType(), out var cached) ? cached : CachePropertyInfos();
+ var type = GetType();
+ Schema = type == typeof(AttributeList) || type == typeof(Element) ? ElementSchema.Empty : ElementSchema.For(type);
Inner = [];
Owner = owner;
}
- OrderedDictionary CachePropertyInfos()
- {
- var propertyAttributes = GetPropertyDerivedAttributeList();
- var propertyInfos = new OrderedDictionary(propertyAttributes?.Count ?? 0);
- if (propertyAttributes != null)
- {
- foreach (var attr in propertyAttributes)
- {
- propertyInfos.Add(attr.Name, attr.Property);
- }
- }
-
- // Read-only so that the shared instance can't be mutated through one of its owners.
- return PropertyInfoCache.GetOrAdd(GetType(), propertyInfos.AsReadOnly());
- }
-
///
/// Gets the that this AttributeList is owned by.
///
@@ -134,12 +146,6 @@ public void Add(string key, object? value)
this[key] = value;
}
-
- protected virtual ICollection<(string Name, PropertyInfo Property)>? GetPropertyDerivedAttributeList()
- {
- return null;
- }
-
///
/// Gets the given atttribute's "override type". This applies when multiple Datamodel types map to the same CLR type.
///
@@ -256,10 +262,10 @@ public virtual object? this[string name]
var attr = (Attribute?)Inner[name];
if (attr == null)
{
- var prop_attr = (PropertyInfo?)PropertyInfos[name];
- if (prop_attr != null)
+ var binding = Schema.GetProperty(name);
+ if (binding != null)
{
- return prop_attr.GetValue(this);
+ return binding.GetValue(this);
}
throw new KeyNotFoundException($"{this} does not have an attribute called \"{name}\"");
@@ -276,40 +282,42 @@ public virtual object? this[string name]
if (Owner != null && this == Owner.PrefixAttributes && value?.GetType() == typeof(Element))
throw new AttributeTypeException("Elements are not supported as prefix attributes.");
- var prop = (PropertyInfo?)PropertyInfos[name];
+ var binding = Schema.GetProperty(name);
- if (prop != null)
+ if (binding != null)
{
- if (prop.CanWrite)
+ if (binding.CanWrite)
{
// null is fine, it will just set the value to null
- if (value != null && !prop.PropertyType.IsInstanceOfType(value))
+ if (value != null && !binding.PropertyType.IsInstanceOfType(value))
{
- throw new InvalidDataException($"class property '{prop.DeclaringType!.Name}.{prop.Name}' with type '{prop.PropertyType}' can not hold a value of type '{value.GetType()}' (attribute '{name}'), this is likely a mismatch between the real class and the class from the datamodel");
+ value = ConvertScalar(value, binding.PropertyType)
+ ?? throw new InvalidDataException($"class property '{Schema.ElementType.Name}.{binding.PropertyName}' with type '{binding.PropertyType}' can not hold a value of type '{value.GetType()}' (attribute '{name}'), this is likely a mismatch between the real class and the class from the datamodel");
}
- prop.SetValue(this, value);
+ binding.SetValue(this, value);
}
else
{
- var existingArray = prop.GetValue(this) as Array;
- var incomingArray = value as Array;
+ // a read-only array property takes the items of an incoming array of the same type, so that a file can fill it once
+ var existingArray = binding.GetValue(this) as IList;
+ var incomingArray = value as IList;
- if (existingArray is not null && incomingArray is not null)
+ if (existingArray is not null && incomingArray is not null && existingArray.GetType() == incomingArray.GetType())
{
- // special case for reflection based deserialization
if (existingArray.Count == 0)
{
- existingArray.AddRange(incomingArray);
+ foreach (var item in incomingArray)
+ existingArray.Add(item);
}
else
{
- throw new InvalidOperationException($"Attribute '{name}' modifies property {prop.DeclaringType!.Name}.{prop.Name}, which is write only and can't be replaced.");
+ throw new InvalidOperationException($"Attribute '{name}' modifies property {Schema.ElementType.Name}.{binding.PropertyName}, which is read-only and already has items.");
}
}
else
{
- throw new InvalidDataException($"Property '{prop.DeclaringType!.Name}.{prop.Name}' of deserialisation class must be writeable, make sure it's public and has a public setter");
+ throw new InvalidDataException($"Property '{Schema.ElementType.Name}.{binding.PropertyName}' of deserialisation class must be writeable, make sure it has a setter");
}
}
diff --git a/Datamodel.NET/Codecs/Binary.cs b/Datamodel.NET/Codecs/Binary.cs
index 458123b..bb5415d 100644
--- a/Datamodel.NET/Codecs/Binary.cs
+++ b/Datamodel.NET/Codecs/Binary.cs
@@ -10,12 +10,6 @@
namespace Datamodel.Codecs
{
- [CodecFormat("binary", 1)]
- [CodecFormat("binary", 2)]
- [CodecFormat("binary", 3)]
- [CodecFormat("binary", 4)]
- [CodecFormat("binary", 5)]
- [CodecFormat("binary", 9)]
class Binary : IDeferredAttributeCodec
{
static readonly Dictionary SupportedAttributes = [];
@@ -79,7 +73,10 @@ static byte TypeToId(Type type, int version)
return ++i;
}
- Tuple IdToType(byte id)
+ ///
+ /// Maps a type id of the stream to the attribute type, or to the item type when the id denotes an array.
+ ///
+ (Type Type, bool IsArray) IdToType(byte id)
{
var type_list = SupportedAttributes[EncodingVersion];
bool array = false;
@@ -100,14 +97,12 @@ static byte TypeToId(Type type, int version)
}
}
- try
- {
- return new Tuple((array ? type_list[id]?.MakeListType() : type_list[id]), (array ? type_list[id] : null));
- }
- catch (IndexOutOfRangeException)
+ if (id >= type_list.Length || type_list[id] is not Type type)
{
throw new CodecException(String.Format("Unrecognised attribute type: {0}", id + 1));
}
+
+ return (type, array);
}
protected string ReadString_Raw(BinaryReader reader)
@@ -395,10 +390,8 @@ private static Matrix4x4 ReadMatrix4x4(BinaryReader reader)
reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle());
}
- public Datamodel Decode(string encoding, int encoding_version, string format, int format_version, Stream stream, DeferredMode defer_mode, ReflectionParams reflectionParams)
+ public Datamodel Decode(string encoding, int encoding_version, string format, int format_version, Stream stream, DeferredMode defer_mode, ElementTypeResolver resolver)
{
- var resolver = new ElementTypeResolver(reflectionParams);
-
stream.Seek(0, SeekOrigin.Begin);
while (true)
{
@@ -503,17 +496,16 @@ public Datamodel Decode(string encoding, int encoding_version, string format, in
object? DecodeAttribute(Datamodel dm, bool prefix, BinaryReader reader)
{
- var types = IdToType(reader.ReadByte());
+ var (type, isArray) = IdToType(reader.ReadByte());
- if (types.Item2 == null)
- return ReadValue(dm, TypeMap[types.Item1.TypeHandle], EncodingVersion < 4 || prefix, reader);
+ if (!isArray)
+ return ReadValue(dm, TypeMap[type.TypeHandle], EncodingVersion < 4 || prefix, reader);
else
{
var count = reader.ReadInt32();
- var inner_type = types.Item2;
- var array = CodecUtilities.MakeList(inner_type, count);
+ var array = CodecUtilities.MakeList(type, count);
- var typeId = TypeMap[inner_type.TypeHandle];
+ var typeId = TypeMap[type.TypeHandle];
foreach (var x in Enumerable.Range(0, count))
array.Add(ReadValue(dm, typeId, true, reader));
@@ -523,20 +515,13 @@ public Datamodel Decode(string encoding, int encoding_version, string format, in
void SkipAttribute(BinaryReader reader)
{
- var types = IdToType(reader.ReadByte());
+ var (type, isArray) = IdToType(reader.ReadByte());
int count = 1;
- Type? type = types.Item1;
- if (type is null)
- {
- throw new InvalidDataException("Failed to match id to type");
- }
-
- if (types.Item2 != null)
+ if (isArray)
{
count = reader.ReadInt32();
- type = types.Item2;
}
if (type == typeof(Element))
@@ -562,7 +547,7 @@ void SkipAttribute(BinaryReader reader)
}
else if (type == typeof(string))
{
- if (!StringDict!.Dummy && types.Item2 == null && EncodingVersion >= 4)
+ if (!StringDict!.Dummy && !isArray && EncodingVersion >= 4)
length = StringDict.IndiceSize;
else
{
@@ -582,8 +567,16 @@ void SkipAttribute(BinaryReader reader)
length = sizeof(float) * 4;
else if (type == typeof(Matrix4x4))
length = sizeof(float) * 4 * 4;
+ else if (type == typeof(QAngle))
+ length = sizeof(float) * 3;
+ else if (type == typeof(int) || type == typeof(float))
+ length = 4;
+ else if (type == typeof(byte))
+ length = sizeof(byte);
+ else if (type == typeof(ulong))
+ length = sizeof(ulong);
else
- length = System.Runtime.InteropServices.Marshal.SizeOf(type);
+ throw new CodecException($"Cannot skip an attribute of type {type.Name}.");
reader.BaseStream.Seek(length * count, SeekOrigin.Current);
}
diff --git a/Datamodel.NET/Codecs/KeyValues2.cs b/Datamodel.NET/Codecs/KeyValues2.cs
index 96eae02..3a20176 100644
--- a/Datamodel.NET/Codecs/KeyValues2.cs
+++ b/Datamodel.NET/Codecs/KeyValues2.cs
@@ -5,20 +5,10 @@
using System.Numerics;
using System.IO;
using System.Globalization;
-using System.Reflection;
-using System.Xml.Linq;
using System.Collections;
namespace Datamodel.Codecs
{
- [CodecFormat("keyvalues2", 1)]
- [CodecFormat("keyvalues2", 2)]
- [CodecFormat("keyvalues2", 3)]
- [CodecFormat("keyvalues2", 4)]
- [CodecFormat("keyvalues2_noids", 1)]
- [CodecFormat("keyvalues2_noids", 2)]
- [CodecFormat("keyvalues2_noids", 3)]
- [CodecFormat("keyvalues2_noids", 4)]
class KeyValues2 : ICodec
{
static readonly Dictionary TypeNames = [];
@@ -610,10 +600,8 @@ string Decode_NextToken(StreamReader reader)
else throw new ArgumentException($"Internal error: ParseValue passed unsupported type: {type}.");
}
- public Datamodel Decode(string encoding, int encoding_version, string format, int format_version, Stream stream, DeferredMode defer_mode, ReflectionParams reflectionParams)
+ public Datamodel Decode(string encoding, int encoding_version, string format, int format_version, Stream stream, DeferredMode defer_mode, ElementTypeResolver resolver)
{
- var resolver = new ElementTypeResolver(reflectionParams);
-
var dataModel = new Datamodel(format, format_version);
if (encoding == "keyvalues2_noids")
diff --git a/Datamodel.NET/Datamodel.NET.csproj b/Datamodel.NET/Datamodel.NET.csproj
index b4488bc..efec38c 100644
--- a/Datamodel.NET/Datamodel.NET.csproj
+++ b/Datamodel.NET/Datamodel.NET.csproj
@@ -1,6 +1,6 @@
- net9.0
+ net10.0
Library
Datamodel
KeyValues2
@@ -13,11 +13,11 @@
Implements Valve's Datamodel Exchange (DMX) file format.
https://github.com/ValveResourceFormat/Datamodel.NET
valve;dmx;datamodel;keyvalues2;source-engine;vmap
- false
true
true
snupkg
false
+ true
IDE0018
@@ -47,17 +47,11 @@
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/Datamodel.NET/Datamodel.cs b/Datamodel.NET/Datamodel.cs
index 4e993b7..46d96d8 100644
--- a/Datamodel.NET/Datamodel.cs
+++ b/Datamodel.NET/Datamodel.cs
@@ -8,8 +8,10 @@
using System.Runtime.Serialization;
using System.Security;
using System.Numerics;
+using System.Collections.Concurrent;
using CodecRegistration = System.Tuple;
-using System.Reflection;
+
+[assembly: System.CLSCompliant(true)]
namespace Datamodel
{
@@ -57,10 +59,56 @@ public DebugView(Datamodel dm)
public static Type[] AttributeTypes => attributeTypes;
+ ///
+ /// The interface of every attribute type, paired with T. A type implementing one of these is an array of T.
+ ///
+ private static readonly (Type List, Type Item)[] arrayInterfaces = [
+ (typeof(IList), typeof(Element)),
+ (typeof(IList), typeof(int)),
+ (typeof(IList), typeof(float)),
+ (typeof(IList), typeof(bool)),
+ (typeof(IList), typeof(string)),
+ (typeof(IList), typeof(byte[])),
+ (typeof(IList), typeof(TimeSpan)),
+ (typeof(IList), typeof(Color)),
+ (typeof(IList), typeof(Vector2)),
+ (typeof(IList), typeof(Vector3)),
+ (typeof(IList), typeof(Vector4)),
+ (typeof(IList), typeof(Quaternion)),
+ (typeof(IList), typeof(Matrix4x4)),
+ (typeof(IList), typeof(byte)),
+ (typeof(IList), typeof(ulong)),
+ (typeof(IList), typeof(QAngle)),
+ ];
+
+ ///
+ /// The item type of every type that has been checked with , or null for types that are not arrays.
+ ///
+ private static readonly ConcurrentDictionary arrayItemTypes = new()
+ {
+ [typeof(ElementArray)] = typeof(Element),
+ [typeof(IntArray)] = typeof(int),
+ [typeof(FloatArray)] = typeof(float),
+ [typeof(BoolArray)] = typeof(bool),
+ [typeof(StringArray)] = typeof(string),
+ [typeof(BinaryArray)] = typeof(byte[]),
+ [typeof(TimeSpanArray)] = typeof(TimeSpan),
+ [typeof(ColorArray)] = typeof(Color),
+ [typeof(Vector2Array)] = typeof(Vector2),
+ [typeof(Vector3Array)] = typeof(Vector3),
+ [typeof(Vector4Array)] = typeof(Vector4),
+ [typeof(QuaternionArray)] = typeof(Quaternion),
+ [typeof(MatrixArray)] = typeof(Matrix4x4),
+ [typeof(ByteArray)] = typeof(byte),
+ [typeof(UInt64Array)] = typeof(ulong),
+ [typeof(Element)] = null,
+ [typeof(string)] = null,
+ };
+
///
/// Determines whether the given Type is valid as a Datamodel .
///
- /// objects pass if their generic argument is valid.
+ /// objects pass if their generic argument is valid.
///
/// The Type to check.
public static bool IsDatamodelType(Type t)
@@ -76,103 +124,81 @@ public static bool IsDatamodelType(Type t)
/// The Type to check.
public static bool IsDatamodelArrayType(Type t)
{
- var inner = GetArrayInnerType(t);
- return inner != null && Datamodel.AttributeTypes.Contains(inner);
+ return GetArrayInnerType(t) != null;
}
///
- /// Returns the inner Type of an object which implements IList<T>, or null if there is no inner Type.
+ /// Returns the inner Type of an object which implements for an attribute type T, or null if there is no inner Type.
///
/// The Type to check.
public static Type? GetArrayInnerType(Type t)
{
- if (t == typeof(Element))
+ if (arrayItemTypes.TryGetValue(t, out var inner))
{
- return null;
+ return inner;
}
- var i_type = t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IList<>) ? t : t.GetInterface("IList`1");
- if (i_type == null)
+ foreach (var (list, item) in arrayInterfaces)
{
- return null;
+ if (list.IsAssignableFrom(t))
+ {
+ inner = item;
+ break;
+ }
}
- var inner = i_type.GetGenericArguments()[0];
+ arrayItemTypes[t] = inner;
return inner;
}
#endregion
static Datamodel()
{
- RegisterCodec(typeof(Binary));
- RegisterCodec(typeof(KeyValues2));
+ RegisterCodec("binary", [1, 2, 3, 4, 5, 9], () => new Binary());
+ RegisterCodec("keyvalues2", [1, 2, 3, 4], () => new KeyValues2());
+ RegisterCodec("keyvalues2_noids", [1, 2, 3, 4], () => new KeyValues2());
TextEncoding = new System.Text.UTF8Encoding(false);
}
#region Codecs
- public static readonly Dictionary Codecs = [];
+ private static readonly Dictionary> Codecs = [];
public static IEnumerable CodecsRegistered => Codecs.Keys.OrderBy(reg => string.Join(null, reg.Item1, reg.Item2)).ToArray();
///
- /// Registers a new with an encoding name and one or more encoding versions.
+ /// Registers an for an encoding name and one or more encoding versions.
///
/// Existing codecs will be replaced.
- /// The ICodec implementation being registered.
- public static void RegisterCodec(Type type)
+ /// The encoding name that the codec handles.
+ /// The encoding version(s) that the codec handles.
+ /// Creates a new instance of the codec. Called once per encode or decode.
+ public static void RegisterCodec(string encoding, IEnumerable versions, Func create)
{
- if (type.GetInterface(typeof(ICodec).FullName!) == null)
- {
- throw new CodecException($"{type.Name} does not implement Datamodel.Codecs.ICodec.");
- }
-
- if (type.GetConstructor(Type.EmptyTypes) == null)
- {
- throw new CodecException($"{type.Name} does not have a default constructor.");
- }
-
- var format_attrs = (CodecFormatAttribute[])type.GetCustomAttributes(typeof(CodecFormatAttribute), true);
- if (format_attrs.Length == 0)
- {
- throw new CodecException($"{type.Name} does not provide Datamodel.Codecs.CodecFormatAttribute.");
- }
+ ArgumentNullException.ThrowIfNull(encoding);
+ ArgumentNullException.ThrowIfNull(versions);
+ ArgumentNullException.ThrowIfNull(create);
- foreach (var format_attr in format_attrs)
+ foreach (var version in versions)
{
- foreach (var version in format_attr.Versions)
- {
- var reg = new CodecRegistration(format_attr.Name, version);
- AddCodec(type, format_attr, reg);
- }
- }
+ var reg = new CodecRegistration(encoding, version);
- static void AddCodec(Type type, CodecFormatAttribute format_attr, CodecRegistration reg)
- {
- if (Codecs.ContainsKey(reg) && Codecs[reg] != type)
+ if (Codecs.ContainsKey(reg))
{
- Trace.TraceInformation("Datamodel.NET: Replacing existing codec for {0} {1} ({2}) with {3}", format_attr.Name, reg.Item2, Codecs[reg].Name, type.Name);
+ Trace.TraceInformation("Datamodel.NET: Replacing existing codec for {0} {1}", encoding, version);
}
- Codecs[reg] = type;
+ Codecs[reg] = create;
}
}
private static ICodec GetCodec(string encoding, int encoding_version)
{
- Type? codec_type;
- if (!Codecs.TryGetValue(new CodecRegistration(encoding, encoding_version), out codec_type))
+ if (!Codecs.TryGetValue(new CodecRegistration(encoding, encoding_version), out var create))
{
throw new CodecException($"No codec found for {encoding} version {encoding_version}.");
}
- var codecConstructor = codec_type.GetConstructor(Type.EmptyTypes);
-
- if (codecConstructor is null)
- {
- throw new InvalidOperationException("Failed to get codec constructor.");
- }
-
- return (ICodec)codecConstructor.Invoke(null);
+ return create();
}
///
@@ -186,6 +212,40 @@ public static bool HaveCodec(string encoding, int encoding_version)
}
#endregion
+ #region Element factories
+ private static readonly object elementFactoryLock = new();
+ private static IElementFactory[] elementFactories = [];
+
+ ///
+ /// Registers a factory whose classes constructs, and the schemas of those classes.
+ /// The ElementFactory generated by KeyValues2.ElementFactoryGenerator calls this when its assembly is initialised.
+ ///
+ public static void RegisterElementFactory(IElementFactory factory)
+ {
+ ArgumentNullException.ThrowIfNull(factory);
+
+ lock (elementFactoryLock)
+ {
+ if (Array.IndexOf(elementFactories, factory) >= 0)
+ {
+ return;
+ }
+
+ foreach (var schema in factory.Schemas)
+ {
+ ElementSchema.Register(schema);
+ }
+
+ elementFactories = [.. elementFactories, factory];
+ }
+ }
+
+ ///
+ /// Gets the registered factories, in registration order.
+ ///
+ public static IReadOnlyList ElementFactories => elementFactories;
+ #endregion
+
#region Save / Load
///
@@ -244,15 +304,16 @@ public static Datamodel Load(Stream stream, DeferredMode defer_mode = DeferredMo
return Load_Internal(stream, defer_mode, null);
}
///
- /// Loads a Datamodel from a .
- ///
+ /// Loads a Datamodel from a , constructing every element whose class name matches an subclass in the namespace of .
+ ///
/// The input Stream.
/// How to handle deferred loading.
- /// Type hint for what the Root of this datamodel should be when using reflection
- public static Datamodel Load(Stream stream, DeferredMode defer_mode = DeferredMode.Automatic, ReflectionParams? reflectionParams = null)
+ /// Which namespace and factory to use. Defaults to those of .
+ /// The class of the Root element.
+ public static Datamodel Load(Stream stream, DeferredMode defer_mode = DeferredMode.Automatic, LoadOptions? options = null)
where T : Element
{
- return Load_Internal(stream, defer_mode, reflectionParams);
+ return Load_Internal(stream, defer_mode, options);
}
///
@@ -265,15 +326,15 @@ public static Datamodel Load(byte[] data, DeferredMode defer_mode = DeferredMode
return Load_Internal(new MemoryStream(data, true), defer_mode);
}
///
- /// Loads a Datamodel from a byte array.
+ /// Loads a Datamodel from a byte array, constructing every element whose class name matches an subclass in the namespace of .
///
/// The input byte array.
- /// How to handle deferred loading.
- /// Type hint for what the Root of this datamodel should be when using reflection
- public static Datamodel Load(byte[] data, ReflectionParams? reflectionParams = null)
+ /// Which namespace and factory to use. Defaults to those of .
+ /// The class of the Root element.
+ public static Datamodel Load(byte[] data, LoadOptions? options = null)
where T : Element
{
- return Load_Internal(new MemoryStream(data, true), DeferredMode.Disabled, reflectionParams);
+ return Load_Internal(new MemoryStream(data, true), DeferredMode.Disabled, options);
}
///
@@ -296,46 +357,22 @@ public static Datamodel Load(string path, DeferredMode defer_mode = DeferredMode
}
}
///
- /// Loads a Datamodel from a file path, unserializing the Root as .
+ /// Loads a Datamodel from a file path, constructing every element whose class name matches an subclass in the namespace of .
///
/// The source file path.
- /// Type hint for what the Root of this datamodel should be when using reflection
- public static Datamodel Load(string path, ReflectionParams? reflectionParams = null)
+ /// Which namespace and factory to use. Defaults to those of .
+ /// The class of the Root element.
+ public static Datamodel Load(string path, LoadOptions? options = null)
where T : Element
{
using var stream = File.OpenRead(path);
- return Load_Internal(stream, DeferredMode.Disabled, reflectionParams);
+ return Load_Internal(stream, DeferredMode.Disabled, options);
}
- private static Datamodel Load_Internal(Stream stream, DeferredMode defer_mode = DeferredMode.Automatic, ReflectionParams? reflectionParams = null)
+ private static Datamodel Load_Internal(Stream stream, DeferredMode defer_mode = DeferredMode.Automatic, LoadOptions? options = null)
where T : Element
{
- reflectionParams ??= new();
-
- var templateType = typeof(T);
-
- if (templateType is null)
- {
- throw new InvalidDataException("Template type can't be null");
- }
-
- if (templateType == typeof(Element))
- {
- reflectionParams.AttemptReflection = false;
- }
-
- // if user doesnt specify these assume assembly and namespace of root node
- if (reflectionParams.Assembly == string.Empty)
- {
- reflectionParams.Assembly = templateType.Assembly.GetName().Name!;
- }
-
- if (reflectionParams.Namespace == string.Empty)
- {
- reflectionParams.Namespace = templateType.Namespace!;
- }
-
- reflectionParams.RootAssembly ??= templateType.Assembly;
+ var resolver = ElementTypeResolver.For(typeof(T), options);
stream.Seek(0, SeekOrigin.Begin);
var header = string.Empty;
@@ -362,10 +399,7 @@ private static Datamodel Load_Internal(Stream stream, DeferredMode defer_mode
ICodec codec = GetCodec(encoding, encoding_version);
- var typeNamespace = typeof(T).Namespace;
- var typeAssembly = typeof(T).Assembly;
-
- var dm = codec.Decode(encoding, encoding_version, format, format_version, stream, defer_mode, reflectionParams);
+ var dm = codec.Decode(encoding, encoding_version, format, format_version, stream, defer_mode, resolver);
if (defer_mode == DeferredMode.Automatic && codec is IDeferredAttributeCodec deferredCodec)
{
dm.Stream = stream;
@@ -378,7 +412,10 @@ private static Datamodel Load_Internal(Stream stream, DeferredMode defer_mode
dm.Encoding = encoding;
dm.EncodingVersion = encoding_version;
- dm.Root = (T?)dm.Root;
+ if (dm.Root is not null and not T)
+ {
+ throw new InvalidDataException($"The root element is a '{dm.Root.ClassName}' loaded as {dm.Root.GetType().Name}, not {typeof(T).Name}. Check that the class exists in the namespace used to load the file.");
+ }
return dm;
}
@@ -960,12 +997,4 @@ protected DestubException(SerializationInfo info, StreamingContext context)
}
#endregion
-
- static class Extensions
- {
- public static Type MakeListType(this Type t)
- {
- return typeof(List<>).MakeGenericType(t);
- }
- }
}
diff --git a/Datamodel.NET/Element.cs b/Datamodel.NET/Element.cs
index 1b46fc4..c0bd41a 100644
--- a/Datamodel.NET/Element.cs
+++ b/Datamodel.NET/Element.cs
@@ -3,7 +3,6 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
-using System.Reflection;
namespace Datamodel
{
@@ -80,7 +79,7 @@ public Element()
type = type[..index];
}
- ClassName = type;
+ ClassName = Schema.ClassName ?? type;
}
}
@@ -164,44 +163,10 @@ internal set
#endregion
- #region Properties
-
- ///
- /// This is expensive enough to dominate Element construction, so it must only ever be called once per
- /// type. caches the result; don't call it from a hot path.
- ///
- protected override ICollection<(string Name, PropertyInfo Property)>? GetPropertyDerivedAttributeList()
- {
- var type = GetType();
- if (type == typeof(Element))
- {
- return null; // The base class has no auto-properties
- }
-
- var properties = new List<(string Name, PropertyInfo Property)>();
- foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
- {
- // Check if the property is an auto-property and is declared by a subclass of Element
- var declaringType = property.DeclaringType!;
-
- if (declaringType.IsSubclassOf(typeof(Element)))
- {
- var name = property.Name;
- name = declaringType.GetCustomAttribute()?.GetAttributeName(name, property.PropertyType) ?? name;
- name = property.GetCustomAttribute()?.Name ?? name;
- properties.Add((name, property));
- }
- }
-
- return properties;
- }
-
- #endregion Properties
-
///
/// Returns the value of the with the specified type and name. An exception is thrown there is no Attribute of the given name and type.
///
- ///
+ ///
/// The expected Type of the Attribute.
/// The Attribute name to search for.
/// The value of the Attribute with the given name.
@@ -221,12 +186,12 @@ internal set
///
/// Returns the value of the with the specified type and name, if it is an array. An exception is thrown there is no array Attribute of the given name and type.
///
- /// This is a convenience function that calls .
+ /// This is a convenience function that calls .
/// The expected of the array's items.
/// The name to search for.
/// The value of the Attribute with the given name.
/// Thrown when the value of name is null.
- /// Thrown when the value of the requested Attribute is not compatible with IList<T>.
+ /// Thrown when the value of the requested Attribute is not compatible with .
/// Thrown when an attempt is made to get a name that is not present on this Element.
public IList? GetArray(string name)
{
diff --git a/Datamodel.NET/ElementSchema.cs b/Datamodel.NET/ElementSchema.cs
new file mode 100644
index 0000000..8fe2b1d
--- /dev/null
+++ b/Datamodel.NET/ElementSchema.cs
@@ -0,0 +1,197 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+
+namespace Datamodel
+{
+ ///
+ /// A public property of an subclass that is read and written as an attribute.
+ ///
+ ///
+ /// Instances are emitted by the ElementFactory that the KeyValues2.ElementFactoryGenerator generates into the assembly declaring the class,
+ /// so no reflection is needed to move values between properties and attributes.
+ ///
+ public sealed class PropertyBinding
+ {
+ readonly Func getter;
+ readonly Action? setter;
+
+ /// The name of the property in the class.
+ /// The name of the attribute in the file.
+ /// The type of the property.
+ /// Reads the property of the given element.
+ /// Writes the property of the given element, or null when the property has no setter.
+ public PropertyBinding(string propertyName, string attributeName, Type propertyType, Func getter, Action? setter)
+ {
+ ArgumentNullException.ThrowIfNull(propertyName);
+ ArgumentNullException.ThrowIfNull(attributeName);
+ ArgumentNullException.ThrowIfNull(propertyType);
+ ArgumentNullException.ThrowIfNull(getter);
+
+ PropertyName = propertyName;
+ AttributeName = attributeName;
+ PropertyType = propertyType;
+ this.getter = getter;
+ this.setter = setter;
+ }
+
+ ///
+ /// Creates a binding from typed accessors, so that generated code needs no casts.
+ ///
+ /// The class declaring the property.
+ /// The type of the property.
+ /// The name of the property in the class.
+ /// The name of the attribute in the file.
+ /// Reads the property.
+ /// Writes the property, or null when it has no setter.
+ public static PropertyBinding Create(string propertyName, string attributeName, Func getter, Action? setter)
+ where TElement : AttributeList
+ {
+ ArgumentNullException.ThrowIfNull(getter);
+
+ return new PropertyBinding(
+ propertyName,
+ attributeName,
+ typeof(TValue),
+ element => getter((TElement)element),
+ setter == null ? null : (element, value) => setter((TElement)element, (TValue)value!));
+ }
+
+ ///
+ /// Gets the name of the property in the class.
+ ///
+ public string PropertyName { get; }
+
+ ///
+ /// Gets the name of the attribute in the file, after any naming convention or is applied.
+ ///
+ public string AttributeName { get; }
+
+ ///
+ /// Gets the type of the property.
+ ///
+ public Type PropertyType { get; }
+
+ ///
+ /// Gets whether the property can be assigned.
+ ///
+ public bool CanWrite => setter != null;
+
+ ///
+ /// Reads the property of the given element.
+ ///
+ public object? GetValue(AttributeList owner) => getter(owner);
+
+ ///
+ /// Writes the property of the given element.
+ ///
+ /// Thrown when the property has no setter.
+ public void SetValue(AttributeList owner, object? value)
+ {
+ if (setter == null)
+ {
+ throw new InvalidOperationException($"Property '{PropertyName}' is read-only.");
+ }
+
+ setter(owner, value);
+ }
+
+ public override string ToString() => $"{PropertyName} <{PropertyType.Name}> as \"{AttributeName}\"";
+ }
+
+ ///
+ /// Describes how an subclass maps onto a file: its class name and the properties that are stored as attributes.
+ ///
+ ///
+ /// Schemas are registered by the generated ElementFactory of each assembly through .
+ /// An Element subclass without a registered schema stores every attribute in its attribute list, like a plain Element.
+ ///
+ public sealed class ElementSchema
+ {
+ ///
+ /// The schema of a class that declares no properties.
+ ///
+ public static ElementSchema Empty { get; } = new(typeof(Element), null);
+
+ static readonly ConcurrentDictionary Registry = new();
+
+ readonly Dictionary ByAttributeName;
+
+ /// The Element subclass described.
+ /// The class name written to the file, or null to use the type name.
+ /// The properties of every class in the inheritance chain, base class first, each in declaration order.
+ public ElementSchema(Type elementType, string? className, params PropertyBinding[][] propertyGroups)
+ {
+ ArgumentNullException.ThrowIfNull(elementType);
+ ArgumentNullException.ThrowIfNull(propertyGroups);
+
+ ElementType = elementType;
+ ClassName = className;
+
+ var properties = new List();
+ ByAttributeName = [];
+
+ foreach (var group in propertyGroups)
+ {
+ foreach (var binding in group)
+ {
+ // a derived class hiding a base property replaces it in place
+ if (ByAttributeName.TryGetValue(binding.AttributeName, out var existing))
+ {
+ properties[properties.IndexOf(existing)] = binding;
+ }
+ else
+ {
+ properties.Add(binding);
+ }
+
+ ByAttributeName[binding.AttributeName] = binding;
+ }
+ }
+
+ Properties = properties;
+ }
+
+ ///
+ /// Gets the Element subclass described by this schema.
+ ///
+ public Type ElementType { get; }
+
+ ///
+ /// Gets the class name written to the file, or null when the type name is used.
+ ///
+ public string? ClassName { get; }
+
+ ///
+ /// Gets the properties stored as attributes, base class first, each class in declaration order.
+ ///
+ public IReadOnlyList Properties { get; }
+
+ ///
+ /// Gets the property stored under the given attribute name, or null when no property claims it.
+ ///
+ public PropertyBinding? GetProperty(string attributeName)
+ {
+ return ByAttributeName.TryGetValue(attributeName, out var binding) ? binding : null;
+ }
+
+ ///
+ /// Registers the schema of an Element subclass. A schema registered earlier for the same type is replaced.
+ ///
+ public static void Register(ElementSchema schema)
+ {
+ ArgumentNullException.ThrowIfNull(schema);
+ Registry[schema.ElementType] = schema;
+ }
+
+ ///
+ /// Gets the registered schema of the given type, or when none is registered.
+ ///
+ public static ElementSchema For(Type elementType)
+ {
+ return Registry.TryGetValue(elementType, out var schema) ? schema : Empty;
+ }
+
+ public override string ToString() => $"{ElementType.Name} ({Properties.Count} properties)";
+ }
+}
diff --git a/Datamodel.NET/Format.cs b/Datamodel.NET/Format.cs
new file mode 100644
index 0000000..60ec8f0
--- /dev/null
+++ b/Datamodel.NET/Format.cs
@@ -0,0 +1,118 @@
+// This file is also compiled into the ElementFactoryGenerator, which applies the naming conventions at build time, so it must stay netstandard2.0 compatible.
+using System;
+
+namespace Datamodel.Format;
+
+///
+/// Subclass this attribute to define a custom attribute name convention.
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
+public abstract class AttributeNamingConventionAttribute : System.Attribute
+{
+ public abstract string GetAttributeName(string propertyName, Type propertyType);
+}
+
+///
+/// This class' property names are mostly lowercase.
+///
+public class LowercasePropertiesAttribute : AttributeNamingConventionAttribute
+{
+ public override string GetAttributeName(string propertyName, Type _)
+ => NamingConventions.Lowercase(propertyName);
+}
+
+///
+/// This class' property names are mostly camelCase.
+///
+public class CamelCasePropertiesAttribute : AttributeNamingConventionAttribute
+{
+ public override string GetAttributeName(string propertyName, Type _)
+ => NamingConventions.CamelCase(propertyName);
+}
+
+///
+/// This class' property names are mostly m_hungarian.
+///
+public class HungarianPropertiesAttribute : CamelCasePropertiesAttribute
+{
+ public override string GetAttributeName(string propertyName, Type propertyType)
+ => NamingConventions.Hungarian(propertyName, propertyType.FullName);
+}
+
+[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
+public sealed class DMProperty : System.Attribute
+{
+ /// The name to use for serialization.
+ /// Ignore serialization if property is on the default value.
+ public DMProperty(string? name = null, bool optional = false)
+ {
+ Name = name;
+ Optional = optional;
+ }
+
+ public string? Name { get; }
+ public bool Optional { get; }
+}
+
+///
+/// The rules behind the naming convention attributes, in one place for the library and the generator.
+///
+internal static class NamingConventions
+{
+ ///
+ /// The conventions the generator applies at build time. Any other runs when the assembly is loaded.
+ ///
+ public static readonly Type[] BuiltIn =
+ [
+ typeof(LowercasePropertiesAttribute),
+ typeof(CamelCasePropertiesAttribute),
+ typeof(HungarianPropertiesAttribute),
+ ];
+
+ ///
+ /// Applies the rule of one of the attributes.
+ ///
+ /// The attribute class, one of .
+ /// The full name of the property type, as gives it.
+ public static string Apply(Type attributeType, string propertyName, string? propertyTypeName)
+ {
+ if (attributeType == typeof(LowercasePropertiesAttribute))
+ return Lowercase(propertyName);
+ if (attributeType == typeof(CamelCasePropertiesAttribute))
+ return CamelCase(propertyName);
+ if (attributeType == typeof(HungarianPropertiesAttribute))
+ return Hungarian(propertyName, propertyTypeName);
+
+ throw new ArgumentException($"{attributeType} is not a built-in naming convention.", nameof(attributeType));
+ }
+
+ /// The rule of .
+ public static string Lowercase(string propertyName)
+ => propertyName.ToLowerInvariant();
+
+ /// The rule of .
+ public static string CamelCase(string propertyName)
+ => char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1);
+
+ /// The rule of .
+ /// The full name of the property type, as gives it.
+ public static string Hungarian(string propertyName, string? propertyTypeName)
+ {
+ var typeAnnotation = propertyTypeName switch
+ {
+ "System.Int32" => "n",
+ "System.Single" => "fl",
+ "System.Boolean" => "b",
+ "System.Numerics.Vector2" or "System.Numerics.Vector3" or "System.Numerics.Vector4" => "v",
+ "System.Numerics.Matrix4x4" => "mat",
+ _ => string.Empty,
+ };
+
+ if (typeAnnotation == string.Empty)
+ {
+ return "m_" + CamelCase(propertyName);
+ }
+
+ return "m_" + typeAnnotation + propertyName;
+ }
+}
diff --git a/Datamodel.NET/Format/Attribute.cs b/Datamodel.NET/Format/Attribute.cs
deleted file mode 100644
index 8673056..0000000
--- a/Datamodel.NET/Format/Attribute.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using System;
-using System.Numerics;
-
-namespace Datamodel.Format;
-
-///
-/// Subclass this attribute to define a custom attribute name convention.
-///
-[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
-public abstract class AttributeNamingConventionAttribute : System.Attribute
-{
- public abstract string GetAttributeName(string propertyName, Type propertyType);
-}
-
-///
-/// This class' property names are mostly lowercase.
-///
-public class LowercasePropertiesAttribute : AttributeNamingConventionAttribute
-{
- public override string GetAttributeName(string propertyName, Type _)
- => propertyName.ToLowerInvariant();
-}
-
-///
-/// This class' property names are mostly camelCase.
-///
-public class CamelCasePropertiesAttribute : AttributeNamingConventionAttribute
-{
- public override string GetAttributeName(string propertyName, Type _)
- => char.ToLowerInvariant(propertyName.AsSpan()[0]) + propertyName[1..];
-}
-
-///
-/// This class' property names are mostly m_hungarian.
-///
-public class HungarianPropertiesAttribute : CamelCasePropertiesAttribute
-{
- public override string GetAttributeName(string propertyName, Type propertyType)
- {
- var typeAnnotation = propertyType switch
- {
- _ when propertyType == typeof(int) => "n",
- _ when propertyType == typeof(float) => "fl",
- _ when propertyType == typeof(bool) => "b",
- _ when propertyType == typeof(Vector2) => "v",
- _ when propertyType == typeof(Vector3) => "v",
- _ when propertyType == typeof(Vector4) => "v",
- _ when propertyType == typeof(Matrix4x4) => "mat",
- _ => string.Empty,
- };
-
- if (typeAnnotation == string.Empty)
- {
- return "m_" + base.GetAttributeName(propertyName, propertyType);
- }
-
- return "m_" + typeAnnotation + propertyName;
- }
-}
-
-[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
-public sealed class DMProperty : System.Attribute
-{
- /// The name to use for serialization.
- /// Ignore serialization if property is on the default value.
- public DMProperty(string? name = null, bool optional = false)
- {
- Name = name;
- Optional = optional;
- }
-
- public string? Name { get; }
- public bool Optional { get; }
-}
diff --git a/Datamodel.NET/ICodec.cs b/Datamodel.NET/ICodec.cs
index 3ca6260..f34368b 100644
--- a/Datamodel.NET/ICodec.cs
+++ b/Datamodel.NET/ICodec.cs
@@ -1,11 +1,9 @@
-using System;
+using System;
using System.Linq;
using System.IO;
using System.Numerics;
-using System.Reflection;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
-using System.Data;
namespace Datamodel.Codecs
{
@@ -32,77 +30,104 @@ public interface ICodec
/// The format version of the Datamodel.
/// The input stream. Its position will always be 0. Do not dispose.
/// The deferred loading mode specified by the caller. Only relevant to implementers of
+ /// Constructs the subclass registered for a class name. Pass it to for every element.
///
- Datamodel Decode(string encoding, int encoding_version, string format, int format_version, Stream stream, DeferredMode defer_mode, ReflectionParams reflectionParams);
+ Datamodel Decode(string encoding, int encoding_version, string format, int format_version, Stream stream, DeferredMode defer_mode, ElementTypeResolver resolver);
}
///
- /// Parameters for reflection based deserialisation
- /// By default it will look for types in the calling assembly (the one which made this class)
+ /// Constructs subclasses by class name and describes their properties.
///
- /// If to use reflection or not.
- /// Additional types to consider when matching.
- /// Additional assemblies to look for types in.
- public class ReflectionParams(bool attemptReflection = true, List? additionalTypes = null, List? assembliesToSearch = null)
+ ///
+ /// The KeyValues2.ElementFactoryGenerator source generator emits an implementation into every assembly that declares Element subclasses
+ /// and registers it through when the assembly is initialised.
+ ///
+ public interface IElementFactory
{
- public bool AttemptReflection = attemptReflection;
+ ///
+ /// Constructs a new, unowned instance of the class with the given name in the given namespace, or returns null when there is none.
+ ///
+ Element? Create(string nameSpace, string className);
- public string Assembly = string.Empty;
- public string Namespace = string.Empty;
+ ///
+ /// Gets the schemas of every class this factory constructs.
+ ///
+ IReadOnlyList Schemas { get; }
+ }
+
+ ///
+ /// Options for loading a Datamodel through the subclasses of a namespace.
+ ///
+ public sealed class LoadOptions
+ {
+ ///
+ /// Gets or sets the namespace whose classes are used. Defaults to the namespace of the root type passed to .
+ ///
+ public string? Namespace { get; set; }
///
- /// Assembly of the root type passed to Load. Its generated is asked first.
+ /// Gets or sets the factory asked first. Defaults to the factory generated into the assembly of the root type.
+ /// The other registered factories are asked afterwards.
///
- public Assembly? RootAssembly;
+ public IElementFactory? Factory { get; set; }
}
///
- /// Resolves element class names to subclasses while decoding, through the
- /// classes the ElementFactoryGenerator emits into every assembly that references this library.
+ /// Resolves element class names to subclasses while decoding, through the registered instances.
///
///
- /// Every factory in the process is consulted, the one generated into the root type's own assembly first. Each factory only knows
- /// the assemblies its compilation referenced, and this library's own factory knows nothing, so stopping at the first one found
- /// would depend on assembly load order.
+ /// Every registered factory is consulted, the one generated into the root type's own assembly (or the one given in ) first.
///
public sealed class ElementTypeResolver
{
- private const string GeneratedFactoryTypeName = "ElementFactory";
+ ///
+ /// A resolver that constructs no subclasses, so every element is loaded as a plain .
+ ///
+ public static ElementTypeResolver Untyped { get; } = new(string.Empty, []);
- private readonly ReflectionParams reflectionParams;
- private readonly List factories = [];
+ readonly string Namespace;
+ readonly IElementFactory[] Factories;
- public ElementTypeResolver(ReflectionParams reflectionParams)
+ ElementTypeResolver(string nameSpace, IElementFactory[] factories)
{
- this.reflectionParams = reflectionParams;
+ Namespace = nameSpace;
+ Factories = factories;
+ }
- if (!reflectionParams.AttemptReflection)
+ ///
+ /// Creates a resolver for the classes in the namespace and assembly of .
+ ///
+ public static ElementTypeResolver For(Type rootType, LoadOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(rootType);
+
+ if (rootType == typeof(Element))
{
- return;
+ return Untyped;
}
- var factoryTypes = new List();
+ // the generated factory registers itself when its module is initialised, which is guaranteed to have happened
+ // for the caller's assembly but not for an assembly that only declares classes
+ RuntimeHelpers.RunModuleConstructor(rootType.Module.ModuleHandle);
- if (reflectionParams.RootAssembly?.GetType(GeneratedFactoryTypeName) is Type rootFactory)
- {
- factoryTypes.Add(rootFactory);
- }
+ var factories = new List();
+ var registered = Datamodel.ElementFactories;
- foreach (var factoryType in CodecUtilities.GetIElementFactoryClasses())
+ var first = options?.Factory ?? registered.FirstOrDefault(factory => factory.GetType().Assembly == rootType.Assembly);
+ if (first != null)
{
- if (!factoryTypes.Contains(factoryType))
- {
- factoryTypes.Add(factoryType);
- }
+ factories.Add(first);
}
- foreach (var factoryType in factoryTypes)
+ foreach (var factory in registered)
{
- if (Activator.CreateInstance(factoryType) is IElementFactory factory)
+ if (!factories.Contains(factory))
{
factories.Add(factory);
}
}
+
+ return new ElementTypeResolver(options?.Namespace ?? rootType.Namespace ?? string.Empty, [.. factories]);
}
///
@@ -110,9 +135,9 @@ public ElementTypeResolver(ReflectionParams reflectionParams)
///
public Element? Construct(string className)
{
- foreach (var factory in factories)
+ foreach (var factory in Factories)
{
- if (factory.GetClass(reflectionParams.Assembly, reflectionParams.Namespace, className) is Element element)
+ if (factory.Create(Namespace, className) is Element element)
{
return element;
}
@@ -122,7 +147,6 @@ public ElementTypeResolver(ReflectionParams reflectionParams)
}
}
-
///
/// Defines methods for the deferred loading of values.
///
@@ -173,7 +197,7 @@ public static class CodecUtilities
//public const string HeaderPattern_Proto2 = "";
///
- /// Creates a for the given Type with the given starting size.
+ /// Creates a for the given Type with the given starting size.
///
public static System.Collections.IList MakeList(Type t, int count)
{
@@ -212,7 +236,7 @@ public static System.Collections.IList MakeList(Type t, int count)
}
///
- /// Creates a for the given Type, copying items the given IEnumerable
+ /// Creates a for the given Type, copying items the given IEnumerable
///
public static System.Collections.IList MakeList(Type t, System.Collections.IEnumerable source)
{
@@ -282,33 +306,6 @@ public static bool TryConstructCustomElement(ElementTypeResolver resolver, Datam
return true;
}
-
- private static Type[]? elementFactoryClasses;
-
- ///
- /// Finds every implementation in the loaded assemblies. The result is cached after the first call.
- ///
- public static IEnumerable GetIElementFactoryClasses()
- {
- elementFactoryClasses ??= AppDomain.CurrentDomain.GetAssemblies()
- .SelectMany(assembly =>
- {
- try
- {
- return assembly.GetTypes();
- }
- catch (ReflectionTypeLoadException ex)
- {
- return ex.Types.OfType();
- }
- })
- .Where(type => type.IsClass &&
- !type.IsAbstract &&
- type.GetInterfaces().Contains(typeof(IElementFactory)))
- .ToArray();
-
- return elementFactoryClasses;
- }
}
///
@@ -353,39 +350,4 @@ class ElementAttributeCache
}
}
}
-
- [AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
- public sealed class CodecFormatAttribute : System.Attribute
- {
- ///
- /// Specifies a Datamodel encoding name and some versions that a class handles.
- ///
- /// The encoding name that the codec handles.
- /// The encoding version(s) that the codec handles.
- public CodecFormatAttribute(string name, params int[] versions)
- {
- Name = name;
- Versions = versions;
- }
-
- ///
- /// Specifies a Datamodel encoding name and version that a class handles.
- ///
- /// This constructor is CLS-compliant.
- /// The encoding name that the codec handles.
- /// An encoding version that the codec handles.
- public CodecFormatAttribute(string name, int version)
- {
- Name = name;
- Versions = [version];
- }
-
- public string Name { get; private set; }
- public int[] Versions { get; private set; }
- }
-
- public interface IElementFactory
- {
- public object? GetClass(string assembly, string nameSpace, string classname);
- }
}
diff --git a/Datamodel.NET/Properties/AssemblyInfo.cs b/Datamodel.NET/Properties/AssemblyInfo.cs
deleted file mode 100644
index b5a48cf..0000000
--- a/Datamodel.NET/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-using System.Security;
-
-[assembly: AssemblyTitle("Datamodel.NET")]
-[assembly: AssemblyDescription("Implements Valve Corporation's Datamodel structure and Datamodel Exchange file format")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("")]
-[assembly: AssemblyProduct("")]
-[assembly: AssemblyCopyright("Copyright © 2013 Tom Edwards and ValveResourceFormat contributors")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-
-[assembly: ComVisible(false)]
-[assembly: Guid("2df8c991-dfe3-4d62-9fae-705c73ca54db")]
-
-[assembly: AssemblyVersion("1.2.0.0")]
-
-[assembly: System.CLSCompliant(true)]
-
-[assembly: SecurityRules(SecurityRuleSet.Level2)]
-[assembly: AllowPartiallyTrustedCallers()]
diff --git a/ElementFactoryGenerator/ElementFactory.cs b/ElementFactoryGenerator/ElementFactory.cs
index bd4383d..80630be 100644
--- a/ElementFactoryGenerator/ElementFactory.cs
+++ b/ElementFactoryGenerator/ElementFactory.cs
@@ -1,4 +1,4 @@
-using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Generic;
@@ -6,9 +6,20 @@
using System.Linq;
using System.Text;
+///
+/// Emits an ElementFactory into every assembly that declares subclasses of Datamodel.Element.
+/// The factory constructs those classes by name and describes their properties, so that Datamodel.NET can load
+/// and save typed elements without reflection. It registers itself when the assembly is initialised.
+///
[Generator]
public class ElementFactoryGenerator : IIncrementalGenerator
{
+ /// The one library type the generator matches by name, since only the Format namespace is compiled into it.
+ const string ElementTypeName = "Datamodel.Element";
+
+ static readonly string NamingConventionTypeName = typeof(Datamodel.Format.AttributeNamingConventionAttribute).FullName!;
+ static readonly string PropertyAttributeTypeName = typeof(Datamodel.Format.DMProperty).FullName!;
+
private static readonly DiagnosticDescriptor AmbiguousElementClassName = new(
id: "DMX001",
title: "Ambiguous Datamodel element class name",
@@ -18,319 +29,516 @@ public class ElementFactoryGenerator : IIncrementalGenerator
isEnabledByDefault: true,
description: "Datamodel files identify elements by their simple class name, so two Element subclasses sharing a name in the same namespace cannot be told apart when deserialising.");
+ private static readonly DiagnosticDescriptor InaccessibleElementClass = new(
+ id: "DMX002",
+ title: "Datamodel element class is not accessible to the generated factory",
+ messageFormat: "Element class '{0}' is not visible outside its declaring type, so the generated ElementFactory can neither construct it nor bind its properties; make it internal or public",
+ category: "Datamodel",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor UnsupportedNamingConvention = new(
+ id: "DMX003",
+ title: "Naming convention attribute cannot be constructed by the generated factory",
+ messageFormat: "The naming convention attribute on '{0}' uses a constructor argument that the generated ElementFactory cannot reproduce; the property names are used unchanged",
+ category: "Datamodel",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var provider = context.SyntaxProvider.CreateSyntaxProvider(
-
- predicate: static (node, _) => node is ClassDeclarationSyntax,
- transform: static (ctx, _) => (ClassDeclarationSyntax)ctx.Node
-
- ).Where(m => m is not null);
+ predicate: static (node, _) => node is ClassDeclarationSyntax { BaseList: not null },
+ transform: static (ctx, _) => ctx.SemanticModel.GetDeclaredSymbol((ClassDeclarationSyntax)ctx.Node) as INamedTypeSymbol)
+ .Where(static symbol => symbol is not null && InheritsFrom(symbol, ElementTypeName));
var compilation = context.CompilationProvider.Combine(provider.Collect());
context.RegisterSourceOutput(compilation, Execute);
}
- private void Execute(SourceProductionContext context, (Compilation Left, ImmutableArray Right) tuple)
+ private static void Execute(SourceProductionContext context, (Compilation Left, ImmutableArray Right) tuple)
{
- StringBuilder elementFactory = new();
- var assemblyCases = new StringBuilder();
+ var (compilation, symbols) = tuple;
- var assemblies = new List();
+ // a partial class is reported once per declaration
+ var elementTypes = new List();
+ foreach (var symbol in symbols)
+ {
+ if (symbol is not null && !elementTypes.Contains(symbol, SymbolEqualityComparer.Default))
+ {
+ elementTypes.Add(symbol);
+ }
+ }
- // marked as generated so that analyzers and documentation warnings of the consuming project leave it alone,
- // and internal so that it does not become part of the consuming assembly's public surface
- elementFactory.Append(
- """"
- //
- #nullable enable
- #pragma warning disable
+ var classes = new List();
- [global::System.CodeDom.Compiler.GeneratedCode("KeyValues2.ElementFactoryGenerator", "0.2.1")]
- internal sealed class ElementFactory : Datamodel.Codecs.IElementFactory
+ foreach (var type in elementTypes.OrderBy(type => type.ToDisplayString()))
+ {
+ if (type.IsGenericType || type.ContainingType?.IsGenericType == true)
{
- public object? GetClass(string assembly, string nameSpace, string classname)
- {
+ continue;
+ }
- """");
+ if (!compilation.IsSymbolAccessibleWithin(type, compilation.Assembly))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(InaccessibleElementClass, type.Locations.FirstOrDefault() ?? Location.None, type.ToDisplayString()));
+ continue;
+ }
- var (compilation, classDeclList) = tuple;
+ classes.Add(new ElementClass(type));
+ }
- var runningAssembly = new FactoryAssembly(compilation.AssemblyName ?? string.Empty);
- foreach (var classDecl in classDeclList)
+ if (classes.Count == 0)
{
- var type = compilation.GetSemanticModel(classDecl.SyntaxTree).GetDeclaredSymbol(classDecl);
+ return;
+ }
- if (type is not null)
- {
- runningAssembly.AddType(type.ContainingNamespace.ToDisplayString(), type);
- }
+ var emitter = new Emitter(context, compilation, classes);
+ context.AddSource("ElementFactory.g.cs", emitter.Emit());
+ }
+
+ private static bool InheritsFrom(INamedTypeSymbol type, string fullBaseClassName)
+ {
+ var current = type.BaseType;
+ while (current != null)
+ {
+ if (current.ToDisplayString() == fullBaseClassName)
+ return true;
+ current = current.BaseType;
}
- assemblies.Add(runningAssembly);
+ return false;
+ }
- foreach (var assemblySymbol in compilation.SourceModule.ReferencedAssemblySymbols)
+ private sealed class ElementClass(INamedTypeSymbol type)
+ {
+ public INamedTypeSymbol Type { get; } = type;
+ public string Namespace { get; } = type.ContainingNamespace.IsGlobalNamespace ? string.Empty : type.ContainingNamespace.ToDisplayString();
+ public bool IsConstructible => !Type.IsAbstract && Type.InstanceConstructors.Any(ctor => ctor.Parameters.Length == 0 && ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal);
+
+ ///
+ /// The Element subclasses this class derives from, base class first, this class last.
+ ///
+ public IEnumerable Chain
{
- if (assemblySymbol.Kind == SymbolKind.NetModule)
+ get
{
- continue;
+ var chain = new List();
+ for (var current = Type; current != null && current.ToDisplayString() != ElementTypeName; current = current.BaseType)
+ {
+ chain.Add(current);
+ }
+
+ chain.Reverse();
+ return chain;
}
+ }
+ }
- var referencedAssembly = new FactoryAssembly(assemblySymbol.Name);
+ ///
+ /// Writes the factory source: the overview first (construction and the schema list), then one section per class
+ /// holding its property bindings and the accessors they need. A class in an inheritance chain gets one section, shared by its subclasses.
+ ///
+ private sealed class Emitter(SourceProductionContext context, Compilation compilation, List classes)
+ {
+ /// Types are written fully qualified but without the global:: prefix, which the generated namespace makes unnecessary.
+ static readonly SymbolDisplayFormat TypeFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted);
- var assemblyTypes = GetAllClassesFromAssembly(assemblySymbol);
- foreach (var assemblyType in assemblyTypes)
- {
- referencedAssembly.AddType(assemblyType.ContainingNamespace.ToDisplayString(), assemblyType);
- }
+ /// Names a section may not take, because the factory uses them itself or they start a qualified name it writes.
+ static readonly HashSet ReservedNames = ["ElementFactory", "Instance", "Register", "Schemas", "Create", "AllSchemas", "Element", "ElementSchema", "PropertyBinding", "IElementFactory", "Datamodel", "System", "KeyValues2"];
- assemblies.Add(referencedAssembly);
- }
+ readonly Dictionary SectionNames = new(SymbolEqualityComparer.Default);
- foreach (var assembly in assemblies)
+ public string Emit()
{
- var namespaceStringBuilder = new StringBuilder();
+ AssignSectionNames();
- namespaceStringBuilder.Append(
- """"
+ // the generated factory is stamped with the version of the library it was generated for, which is the one the consumer compiles against
+ var version = LibraryVersion(compilation.GetTypeByMetadataName(ElementTypeName)?.ContainingAssembly);
- switch (nameSpace)
- {
- """");
+ var source = new StringBuilder();
- var validNamespaces = 0;
- foreach (var nameSpace in assembly.Namespaces)
- {
- var typeseStringBuilder = new StringBuilder();
+ // marked as generated so that analyzers and documentation warnings of the consuming project leave it alone,
+ // and internal so that it does not become part of the consuming assembly's public surface
+ source.Append($$"""
+ //
+ // Generated by KeyValues2.ElementFactoryGenerator for Datamodel.NET {{version}}.
+ //
+ // Constructs the Element subclasses of this assembly by class name and lists the properties each of them stores as
+ // attributes, so that Datamodel.NET loads and saves them without reflection. Registers itself when the assembly is initialised.
+ #nullable enable
+ #pragma warning disable
- typeseStringBuilder.Append(
- """"
+ using System.Runtime.CompilerServices;
- switch (classname)
- {
- """");
- var validTypes = 0;
- var emittedTypeNames = new Dictionary();
- foreach (var type in nameSpace.Types)
+ namespace KeyValues2.Generated
{
- if (ValidateType(type, compilation))
+ using Element = Datamodel.Element;
+ using ElementSchema = Datamodel.ElementSchema;
+ using PropertyBinding = Datamodel.PropertyBinding;
+
+ [System.CodeDom.Compiler.GeneratedCode("KeyValues2.ElementFactoryGenerator", "{{version}}")]
+ internal sealed class ElementFactory : Datamodel.Codecs.IElementFactory
{
- // Datamodel files record only the simple class name, so a duplicate is
- // unresolvable: emit the first and tell the user the rest are unreachable.
- if (emittedTypeNames.TryGetValue(type.Name, out var existingType))
+ public static readonly ElementFactory Instance = new ElementFactory();
+
+ [ModuleInitializer]
+ internal static void Register()
{
- context.ReportDiagnostic(Diagnostic.Create(
- AmbiguousElementClassName,
- type.Locations.FirstOrDefault() ?? Location.None,
- type.Name,
- nameSpace.Name,
- existingType.ToDisplayString(),
- type.ToDisplayString()));
-
- continue;
+ Datamodel.Datamodel.RegisterElementFactory(Instance);
}
- emittedTypeNames.Add(type.Name, type);
- validTypes++;
+ public System.Collections.Generic.IReadOnlyList Schemas
+ {
+ get { return AllSchemas; }
+ }
- typeseStringBuilder.AppendLine(
- $""""
+ ///
+ /// Constructs the class with the given name in the given namespace, or returns null when this assembly has none.
+ ///
+ public Element? Create(string nameSpace, string className)
+ {
+ switch (nameSpace)
+ {
- case "{type.Name}":
- return new {type.ToDisplayString()}();
- """");
+ """);
+
+ foreach (var byNamespace in classes.Where(elementClass => elementClass.IsConstructible).GroupBy(elementClass => elementClass.Namespace).OrderBy(group => group.Key))
+ {
+ source.AppendLine($" case {Literal(byNamespace.Key)}:");
+ source.AppendLine(" switch (className)");
+ source.AppendLine(" {");
+
+ var emitted = new Dictionary();
+ foreach (var elementClass in byNamespace)
+ {
+ var type = elementClass.Type;
+
+ // Datamodel files record only the simple class name, so a duplicate is
+ // unresolvable: emit the first and tell the user the rest are unreachable.
+ if (emitted.TryGetValue(type.Name, out var existing))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(AmbiguousElementClassName, type.Locations.FirstOrDefault() ?? Location.None,
+ type.Name, byNamespace.Key, existing.ToDisplayString(), type.ToDisplayString()));
+ continue;
}
+ emitted.Add(type.Name, type);
+ source.AppendLine($" case {Literal(type.Name)}: return new {TypeName(type)}();");
}
- typeseStringBuilder.AppendLine(
- """"
- }
- """");
+ source.AppendLine(" }");
+ source.AppendLine(" break;");
+ }
- if (validTypes > 0)
- {
- validNamespaces++;
- namespaceStringBuilder.AppendLine(
- $""""
-
- case "{nameSpace.Name}":
- {typeseStringBuilder.ToString()}
- break;
- """");
+ source.Append("""
+ }
+
+ return null;
+ }
+
+ ///
+ /// The schema of every concrete class: the properties of its base classes first, then its own.
+ ///
+ static readonly ElementSchema[] AllSchemas = new ElementSchema[]
+ {
+
+ """);
+
+ foreach (var elementClass in classes.Where(elementClass => !elementClass.Type.IsAbstract))
+ {
+ var groups = string.Concat(elementClass.Chain.Select(type => $", {SectionNames[type]}.Properties"));
+ source.AppendLine($" new ElementSchema(typeof({TypeName(elementClass.Type)}), {Literal(elementClass.Type.Name)}{groups}),");
+ }
+
+ source.AppendLine(" };");
+
+ foreach (var type in classes.SelectMany(elementClass => elementClass.Chain).Distinct(SymbolEqualityComparer.Default).Cast().OrderBy(type => type.ToDisplayString()))
+ {
+ source.AppendLine();
+ EmitSection(source, type);
+ }
+
+ source.Append("""
+ }
}
+ """);
+
+ return source.ToString();
+ }
+
+ ///
+ /// The package version of the library, from its informational version attribute, falling back to the assembly version.
+ ///
+ static string LibraryVersion(IAssemblySymbol? library)
+ {
+ if (library is null)
+ {
+ return "unknown";
}
- namespaceStringBuilder.AppendLine(
- """"
- }
- """");
+ var informational = library.GetAttributes()
+ .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == typeof(System.Reflection.AssemblyInformationalVersionAttribute).FullName)
+ ?.ConstructorArguments.FirstOrDefault().Value as string;
- if (validNamespaces > 0)
+ if (!string.IsNullOrEmpty(informational))
{
- assemblyCases.AppendLine(
- $"""
- case "{assembly.Name}" :
- {namespaceStringBuilder.ToString()}
- break;
-
- """);
+ // without the source revision that the SDK appends after a plus sign
+ var plus = informational!.IndexOf('+');
+ return plus < 0 ? informational : informational.Substring(0, plus);
}
+ return library.Identity.Version.ToString(3);
}
- if (assemblyCases.Length > 0)
+ ///
+ /// Names each class's section after the class. A name that is reserved or shared by another class gets its namespace appended.
+ ///
+ void AssignSectionNames()
{
- elementFactory.AppendLine(
- """"
- switch (assembly)
- {
- """");
+ var types = classes.SelectMany(elementClass => elementClass.Chain).Distinct(SymbolEqualityComparer.Default).Cast().ToList();
+ var taken = new HashSet(ReservedNames);
- elementFactory.Append(assemblyCases.ToString());
+ // the first identifier of every qualified name the factory writes must not be shadowed by a section
+ foreach (var type in types)
+ {
+ var root = type.ContainingNamespace;
+ while (root is { IsGlobalNamespace: false, ContainingNamespace.IsGlobalNamespace: false })
+ root = root.ContainingNamespace;
+ if (root is { IsGlobalNamespace: false })
+ taken.Add(root.Name);
+ }
- elementFactory.AppendLine(
- """"
- }
- """");
+ foreach (var group in types.GroupBy(type => type.Name))
+ {
+ var unique = group.Count() == 1 && !taken.Contains(group.Key);
+
+ foreach (var type in group)
+ {
+ var name = unique ? type.Name : type.ToDisplayString().Replace('.', '_');
+ while (!taken.Add(name))
+ name += "_";
+
+ SectionNames[type] = name;
+ }
+ }
}
- elementFactory.AppendLine(
- """"
- return null;
+ ///
+ /// Writes the nested class holding the bindings of the properties declares.
+ ///
+ void EmitSection(StringBuilder source, INamedTypeSymbol type)
+ {
+ var naming = NamingConventionFor(type, out var namingField);
+ var kind = type.IsAbstract ? " (abstract, shared by its subclasses)" : string.Empty;
+
+ source.AppendLine($" // {type.ToDisplayString()}{kind}: {naming?.Description ?? "attribute names are the property names"}");
+ source.AppendLine($" static class {SectionNames[type]}");
+ source.AppendLine(" {");
+
+ if (namingField != null)
+ {
+ source.AppendLine($" {namingField}");
+ source.AppendLine();
+ }
+
+ var accessors = new StringBuilder();
+ var properties = type.GetMembers().OfType()
+ .Where(property => !property.IsStatic && !property.IsIndexer && property.DeclaredAccessibility == Accessibility.Public && property.GetMethod is not null && property.ExplicitInterfaceImplementations.Length == 0)
+ .ToList();
+
+ if (properties.Count == 0)
+ {
+ source.AppendLine(" public static readonly PropertyBinding[] Properties = new PropertyBinding[0];");
+ }
+ else
+ {
+ source.AppendLine(" public static readonly PropertyBinding[] Properties = new PropertyBinding[]");
+ source.AppendLine(" {");
+
+ foreach (var property in properties)
+ {
+ var elementType = TypeName(type);
+ var valueType = TypeName(property.Type);
+ var attributeName = AttributeNameFor(property, naming, valueType);
+ var getter = GetterFor(type, property, accessors);
+ var setter = SetterFor(type, property, accessors);
+
+ source.AppendLine($" PropertyBinding.Create<{elementType}, {valueType}>({Literal(property.Name)}, {attributeName}, {getter}, {setter}),");
}
- };
- """");
+ source.AppendLine(" };");
+ }
+
+ source.Append(accessors);
+ source.AppendLine(" }");
+ }
- context.AddSource("ElementFactory.g.cs", elementFactory.ToString());
- }
+ sealed class NamingConvention
+ {
+ /// One of the library's own attribute classes, applied by the generator, or null for a custom one applied at run time through the section's Naming field.
+ public System.Type? BuiltIn;
+ public string Description = string.Empty;
+ }
- private static bool ValidateType(INamedTypeSymbol type, Compilation compilation)
- {
- if (InheritsFromFullName(type, "Datamodel.Element"))
+ ///
+ /// Finds the naming convention attribute applied to . The library's own conventions are applied by the generator;
+ /// any other is constructed at run time in a field of the section, whose declaration is returned in .
+ ///
+ NamingConvention? NamingConventionFor(INamedTypeSymbol type, out string? field)
{
- // no nonsense please
- if (type.IsAbstract || type.IsVirtual || type.TypeParameters.Length > 0)
+ field = null;
+
+ var attribute = type.GetAttributes().FirstOrDefault(attr => attr.AttributeClass is not null && InheritsFrom(attr.AttributeClass, NamingConventionTypeName));
+ if (attribute?.AttributeClass is null)
{
- return false;
+ return null;
}
- if (type.DeclaredAccessibility != Accessibility.Internal && type.DeclaredAccessibility != Accessibility.Public)
+ // the library's own attributes and rules are compiled into this generator, so they are applied here with the same code that runs in the library
+ var attributeClassName = attribute.AttributeClass.ToDisplayString();
+ var builtIn = Datamodel.Format.NamingConventions.BuiltIn.FirstOrDefault(convention => convention.FullName == attributeClassName);
+ if (builtIn is not null)
{
- return false;
+ return new NamingConvention { BuiltIn = builtIn, Description = $"attribute names by {builtIn.Name}" };
}
- // only internal classes in execution assembly are fine
- if (type.DeclaredAccessibility == Accessibility.Internal)
+ var arguments = new List();
+ foreach (var argument in attribute.ConstructorArguments)
{
- if (!SymbolEqualityComparer.Default.Equals(compilation.Assembly, type.ContainingAssembly))
+ var literal = Literal(argument);
+ if (literal is null)
{
- return false;
+ context.ReportDiagnostic(Diagnostic.Create(UnsupportedNamingConvention, type.Locations.FirstOrDefault() ?? Location.None, type.ToDisplayString()));
+ return null;
}
+
+ arguments.Add(literal);
}
- return true;
- }
+ var initializers = new List();
+ foreach (var argument in attribute.NamedArguments)
+ {
+ var literal = Literal(argument.Value);
+ if (literal is null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(UnsupportedNamingConvention, type.Locations.FirstOrDefault() ?? Location.None, type.ToDisplayString()));
+ return null;
+ }
- return false;
- }
+ initializers.Add($"{argument.Key} = {literal}");
+ }
- private static IEnumerable GetAllClassesFromAssembly(IAssemblySymbol assembly)
- {
- return GetAllTypesFromNamespace(assembly.GlobalNamespace)
- .Where(t => t.TypeKind == TypeKind.Class);
- }
+ var initializer = initializers.Count > 0 ? $" {{ {string.Join(", ", initializers)} }}" : string.Empty;
+ field = $"static readonly {NamingConventionTypeName} Naming = new {TypeName(attribute.AttributeClass)}({string.Join(", ", arguments)}){initializer};";
- public static bool InheritsFromFullName(INamedTypeSymbol type, string fullBaseClassName)
- {
- var current = type.BaseType;
- while (current != null)
- {
- if (current.ToDisplayString() == fullBaseClassName)
- return true;
- current = current.BaseType;
+ return new NamingConvention { Description = $"attribute names by {attributeClassName} at run time" };
}
- return false;
- }
- private static IEnumerable GetAllTypesFromNamespace(INamespaceSymbol namespaceSymbol)
- {
- // Get all types directly in this namespace
- foreach (var type in namespaceSymbol.GetTypeMembers())
+ ///
+ /// The expression for the attribute name of a property: a literal when the generator can compute it, otherwise a call to the section's naming field.
+ ///
+ static string AttributeNameFor(IPropertySymbol property, NamingConvention? naming, string valueType)
{
- yield return type;
+ var attribute = property.GetAttributes().FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == PropertyAttributeTypeName);
+
+ if (attribute is not null && attribute.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is string explicitName)
+ {
+ return Literal(explicitName);
+ }
- // Get nested types recursively
- foreach (var nestedType in GetNestedTypes(type))
+ if (naming is null)
{
- yield return nestedType;
+ return Literal(property.Name);
}
+
+ if (naming.BuiltIn is null)
+ {
+ return $"Naming.GetAttributeName({Literal(property.Name)}, typeof({valueType}))";
+ }
+
+ var propertyTypeName = property.Type.WithNullableAnnotation(NullableAnnotation.None).ToDisplayString(RuntimeTypeNameFormat);
+ return Literal(Datamodel.Format.NamingConventions.Apply(naming.BuiltIn, property.Name, propertyTypeName));
}
- // Recursively process child namespaces
- foreach (var childNamespace in namespaceSymbol.GetNamespaceMembers())
+ /// Prints a type the way does for the types the naming conventions distinguish, such as System.Int32.
+ static readonly SymbolDisplayFormat RuntimeTypeNameFormat = TypeFormat.WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers);
+
+ string GetterFor(INamedTypeSymbol type, IPropertySymbol property, StringBuilder accessors)
{
- foreach (var type in GetAllTypesFromNamespace(childNamespace))
+ if (compilation.IsSymbolAccessibleWithin(property.GetMethod!, compilation.Assembly))
{
- yield return type;
+ return $"e => e.{property.Name}";
}
+
+ var accessor = $"Get_{property.Name}";
+ accessors.AppendLine();
+ accessors.AppendLine($" [UnsafeAccessor(UnsafeAccessorKind.Method, Name = \"get_{property.Name}\")]");
+ accessors.AppendLine($" static extern {TypeName(property.Type)} {accessor}({TypeName(type)} target);");
+
+ return $"e => {accessor}(e)";
}
- }
- private static IEnumerable GetNestedTypes(INamedTypeSymbol type)
- {
- foreach (var nestedType in type.GetTypeMembers())
+ string SetterFor(INamedTypeSymbol type, IPropertySymbol property, StringBuilder accessors)
{
- yield return nestedType;
+ var setMethod = property.SetMethod;
- // Recursively get nested types within nested types
- foreach (var deeplyNestedType in GetNestedTypes(nestedType))
+ if (setMethod is null)
{
- yield return deeplyNestedType;
+ return "null";
}
- }
- }
-
- private class FactoryAssembly(string name)
- {
- public string Name = name;
- public HashSet Namespaces = new();
+ if (!setMethod.IsInitOnly && compilation.IsSymbolAccessibleWithin(setMethod, compilation.Assembly))
+ {
+ return $"(e, v) => e.{property.Name} = v";
+ }
- public void AddType(string nameSpaceName, INamedTypeSymbol type)
- {
- NamespacesContainsNamespace(nameSpaceName, out FactoryNamespace? foundNameSpace);
+ var accessor = $"Set_{property.Name}";
+ accessors.AppendLine();
- if (foundNameSpace == null)
+ // an init accessor can only be called from an initializer, so an auto-property is assigned through its backing field
+ var backingField = type.GetMembers().OfType().FirstOrDefault(field => SymbolEqualityComparer.Default.Equals(field.AssociatedSymbol, property));
+ if (setMethod.IsInitOnly && backingField is not null)
{
- foundNameSpace = new FactoryNamespace(nameSpaceName);
- Namespaces.Add(foundNameSpace);
+ accessors.AppendLine($" [UnsafeAccessor(UnsafeAccessorKind.Field, Name = {Literal(backingField.Name)})]");
+ accessors.AppendLine($" static extern ref {TypeName(property.Type)} {accessor}({TypeName(type)} target);");
+
+ return $"(e, v) => {accessor}(e) = v";
}
- foundNameSpace.Types.Add(type);
+ accessors.AppendLine($" [UnsafeAccessor(UnsafeAccessorKind.Method, Name = \"set_{property.Name}\")]");
+ accessors.AppendLine($" static extern void {accessor}({TypeName(type)} target, {TypeName(property.Type)} value);");
+
+ return $"(e, v) => {accessor}(e, v)";
}
- private bool NamespacesContainsNamespace(string namespaceName, out FactoryNamespace? outNameSpace)
+ static string TypeName(ITypeSymbol type)
{
- foreach (var nameSpace in Namespaces)
- {
- if (nameSpace.Name == namespaceName)
- {
- outNameSpace = nameSpace;
- return true;
- }
- }
+ return type.WithNullableAnnotation(NullableAnnotation.None).ToDisplayString(TypeFormat);
+ }
- outNameSpace = null;
- return false;
+ static string Literal(string value)
+ {
+ return SymbolDisplay.FormatLiteral(value, quote: true);
}
- }
- private class FactoryNamespace(string name)
- {
- public string Name = name;
- public List Types = new();
+ ///
+ /// Formats an attribute argument as C# source, or returns null when it cannot be reproduced.
+ ///
+ static string? Literal(TypedConstant constant)
+ {
+ switch (constant.Kind)
+ {
+ case TypedConstantKind.Primitive:
+ return constant.Value is null ? "null" : SymbolDisplay.FormatPrimitive(constant.Value, quoteStrings: true, useHexadecimalNumbers: false);
+ case TypedConstantKind.Enum:
+ return constant.Type is null ? null : $"({TypeName(constant.Type)}){SymbolDisplay.FormatPrimitive(constant.Value!, quoteStrings: false, useHexadecimalNumbers: false)}";
+ case TypedConstantKind.Type:
+ return constant.Value is ITypeSymbol type ? $"typeof({TypeName(type)})" : null;
+ default:
+ return null;
+ }
+ }
}
}
diff --git a/ElementFactoryGenerator/ElementFactoryGenerator.csproj b/ElementFactoryGenerator/ElementFactoryGenerator.csproj
index 36250a2..d58c1f1 100644
--- a/ElementFactoryGenerator/ElementFactoryGenerator.csproj
+++ b/ElementFactoryGenerator/ElementFactoryGenerator.csproj
@@ -1,28 +1,22 @@
-
+
+
netstandard2.0
latest
- 0.2.1
enable
- true
- snupkg
true
- KeyValues2.ElementFactoryGenerator
- MIT
- Tom Edwards, ValveResourceFormat contributors
- Copyright (c) 2013 Tom Edwards and ValveResourceFormat contributors
- Source generator for reflection-based Element deserialisation in Datamodel.NET (KeyValues2).
- https://github.com/ValveResourceFormat/Datamodel.NET
+ false
-
-
+
+
-
+
+
diff --git a/ElementFactoryGenerator/README.md b/ElementFactoryGenerator/README.md
index 6ce7f24..85abe71 100644
--- a/ElementFactoryGenerator/README.md
+++ b/ElementFactoryGenerator/README.md
@@ -1 +1,3 @@
-Code generator for reflection based deserialisation in Datamodel.NET
\ No newline at end of file
+Source generator that lets Datamodel.NET load and save `Element` subclasses without reflection. It ships inside the KeyValues2 package, so referencing the package is all a project needs.
+
+For every subclass of `Datamodel.Element` in the assembly it emits an `ElementFactory` that constructs the class by name, lists its public properties with their attribute names, and registers itself with `Datamodel.RegisterElementFactory` when the assembly is initialised.
diff --git a/README.md b/README.md
index 3f549c5..f826d7f 100644
--- a/README.md
+++ b/README.md
@@ -49,8 +49,9 @@ Elements with no matching class are loaded as plain `Element`s.
How the classes are found:
-* The `KeyValues2.ElementFactoryGenerator` source generator emits an `ElementFactory` into every assembly that references this package.
-* Loading asks those factories, the one in the assembly of `T` first. No reflection over types happens at load time.
+* A source generator, shipped inside the package, emits an `ElementFactory` into every assembly that declares `Element` subclasses. It constructs the classes by name, lists their properties, and registers itself when the assembly is initialised.
+* Loading asks the registered factories, the one in the assembly of `T` first. Pass a `LoadOptions` to pick another namespace or factory.
+* The library uses no reflection and is compatible with trimming and Native AOT. Properties with `init` or non-public setters are assigned through `UnsafeAccessor`, so the classes need no special shape.
How a subclass maps onto the file:
@@ -58,6 +59,7 @@ How a subclass maps onto the file:
* Attributes of the file that no property claims are kept as plain attributes and written back unchanged.
* Every property is always written, like in Valve's datamodel. Loading an older file through a class with newer properties adds those with their default values.
* Assigning a file attribute to a property of an incompatible type throws an `InvalidDataException` naming the property, which usually means the class does not match the format.
+* A class must be `internal` or `public` for the generated factory to see it. A private nested class is loaded as a plain `Element` and the generator warns about it (DMX002).
## Serialization
diff --git a/Tests.VMAP/Tests.VMAP.csproj b/Tests.VMAP/Tests.VMAP.csproj
new file mode 100644
index 0000000..5302165
--- /dev/null
+++ b/Tests.VMAP/Tests.VMAP.csproj
@@ -0,0 +1,15 @@
+
+
+
+
+ net10.0
+ enable
+ false
+
+
+
+
+
+
+
+
diff --git a/Tests/ValveMap.cs b/Tests.VMAP/ValveMap.cs
similarity index 100%
rename from Tests/ValveMap.cs
rename to Tests.VMAP/ValveMap.cs
diff --git a/Tests/Properties/AssemblyInfo.cs b/Tests/Properties/AssemblyInfo.cs
deleted file mode 100644
index 34b6e15..0000000
--- a/Tests/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-
-// General Information about an assembly is controlled through the following
-// set of attributes. Change these attribute values to modify the information
-// associated with an assembly.
-[assembly: AssemblyTitle("Datamodel.NET Tests")]
-[assembly: AssemblyDescription("")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("")]
-[assembly: AssemblyProduct("Datamodel.NET Tests")]
-[assembly: AssemblyCopyright("Copyright © 2013 Tom Edwards and ValveResourceFormat contributors")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-
-// Setting ComVisible to false makes the types in this assembly not visible
-// to COM components. If you need to access a type in this assembly from
-// COM, set the ComVisible attribute to true on that type.
-[assembly: ComVisible(false)]
-
-// The following GUID is for the ID of the typelib if this project is exposed to COM
-[assembly: Guid("af3871d0-c62c-4d4d-8049-5ed52887baed")]
-
-// Version information for an assembly consists of the following four values:
-//
-// Major Version
-// Minor Version
-// Build Number
-// Revision
-//
-// You can specify all the values or you can default the Build and Revision Numbers
-// by using the '*' as shown below:
-// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.0.0")]
-[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/Tests/RoundTripTests.cs b/Tests/RoundTripTests.cs
index 7c94376..df6d6fa 100644
--- a/Tests/RoundTripTests.cs
+++ b/Tests/RoundTripTests.cs
@@ -4,7 +4,8 @@
using System.IO;
using System.Linq;
using System.Numerics;
-using NUnit.Framework;
+using System.Threading.Tasks;
+using TUnit.Assertions.Enums;
using Datamodel;
using Tests.VMAP;
using DM = Datamodel.Datamodel;
@@ -15,14 +16,13 @@ namespace Datamodel_Tests
/// Loading a file and saving it again must reproduce every element, every attribute and the prefix attributes,
/// whether the elements were deserialized as plain s or as typed subclasses.
///
- [TestFixture]
public class RoundTripTests
{
- static string Resource(string name) => Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", name);
+ static string Resource(string name) => Path.Combine(TestContext.TestDirectory!, "Resources", name);
// a map made for this purpose: every node class, all selection set kinds, nested prefabs and instances,
// subdivision, vertex paint, baked lighting, a thumbnail and asset references in the prefix
- static readonly string[] VmapFiles =
+ public static IEnumerable VmapFiles() =>
[
"roundtrip_test.vmap",
Path.Combine("prefabs", "roundtrip_test_prefab1.vmap"),
@@ -30,29 +30,31 @@ public class RoundTripTests
Path.Combine("prefabs", "roundtrip_test_prefab3.vmap"),
];
- [Test, TestCaseSource(nameof(VmapFiles))]
- public void Binary_Untyped(string file)
+ [Test]
+ [MethodDataSource(nameof(VmapFiles))]
+ public async Task Binary_Untyped(string file)
{
using var original = DM.Load(Resource(file), Datamodel.Codecs.DeferredMode.Disabled);
var saved = Save(original);
using var reloaded = DM.Load(saved);
- AssertEquivalent(original, reloaded, orderSensitive: true);
- Assert.That(reloaded.PrefixElementId, Is.EqualTo(original.PrefixElementId));
+ await AssertEquivalent(original, reloaded, orderSensitive: true);
+ await Assert.That(reloaded.PrefixElementId).IsEqualTo(original.PrefixElementId);
- Assert.That(Save(reloaded), Is.EqualTo(saved), "saving the reloaded datamodel must reproduce the same bytes");
+ await Assert.That(Save(reloaded)).IsEquivalentTo(saved, CollectionOrdering.Matching).Because("saving the reloaded datamodel must reproduce the same bytes");
}
- [Test, TestCaseSource(nameof(VmapFiles))]
- public void Binary_PrefixElementIsNotAnOrphan(string file)
+ [Test]
+ [MethodDataSource(nameof(VmapFiles))]
+ public async Task Binary_PrefixElementIsNotAnOrphan(string file)
{
using var dm = DM.Load(Resource(file), Datamodel.Codecs.DeferredMode.Disabled);
- Assert.That(dm.PrefixAttributes.Keys, Does.Contain("map_asset_references"));
+ await Assert.That(dm.PrefixAttributes.Keys).Contains("map_asset_references");
var reachable = new HashSet();
Visit(dm.Root);
- Assert.That(dm.AllElements.Count, Is.EqualTo(reachable.Count), "every element must be reachable from the root");
+ await Assert.That(dm.AllElements.Count).IsEqualTo(reachable.Count).Because("every element must be reachable from the root");
void Visit(Element? element)
{
@@ -70,26 +72,28 @@ void Visit(Element? element)
}
}
- [Test, TestCaseSource(nameof(VmapFiles))]
- public void Binary_Typed(string file)
+ [Test]
+ [MethodDataSource(nameof(VmapFiles))]
+ public async Task Binary_Typed(string file)
{
using var original = DM.Load(Resource(file), Datamodel.Codecs.DeferredMode.Disabled);
using var typed = DM.Load(Resource(file));
- Assert.That(typed.Root, Is.TypeOf());
+ await Assert.That(typed.Root).IsTypeOf();
// typed elements write their class properties first, in declaration order, so only the set of attributes is compared
- AssertEquivalent(original, typed, orderSensitive: false);
+ await AssertEquivalent(original, typed, orderSensitive: false);
var saved = Save(typed);
using var reloaded = DM.Load(saved);
- AssertEquivalent(original, reloaded, orderSensitive: false);
+ await AssertEquivalent(original, reloaded, orderSensitive: false);
- Assert.That(Save(reloaded), Is.EqualTo(saved));
+ await Assert.That(Save(reloaded)).IsEquivalentTo(saved, CollectionOrdering.Matching);
}
- [Test, TestCaseSource(nameof(VmapFiles))]
- public void KeyValues2_Untyped(string file)
+ [Test]
+ [MethodDataSource(nameof(VmapFiles))]
+ public async Task KeyValues2_Untyped(string file)
{
using var original = DM.Load(Resource(file), Datamodel.Codecs.DeferredMode.Disabled);
@@ -98,25 +102,17 @@ public void KeyValues2_Untyped(string file)
using var reloaded = DM.Load(text.ToArray());
- FloatTolerance = 1e-9;
- try
- {
- AssertEquivalent(original, reloaded, orderSensitive: true);
- }
- finally
- {
- FloatTolerance = 0;
- }
-
- Assert.That(reloaded.PrefixElementId, Is.EqualTo(original.PrefixElementId));
+ // keyvalues2 prints floats with ten decimals, so values that small lose precision in that encoding
+ await AssertEquivalent(original, reloaded, orderSensitive: true, floatTolerance: 1e-9);
+ await Assert.That(reloaded.PrefixElementId).IsEqualTo(original.PrefixElementId);
using var text2 = new MemoryStream();
reloaded.Save(text2, "keyvalues2", 4);
- Assert.That(text2.ToArray(), Is.EqualTo(text.ToArray()));
+ await Assert.That(text2.ToArray()).IsEquivalentTo(text.ToArray(), CollectionOrdering.Matching);
}
[Test]
- public void KeyValues2_MatchesReferenceLayout()
+ public async Task KeyValues2_MatchesReferenceLayout()
{
// tab indentation, one array item per line, inline elements followed by a blank line,
// elements referenced more than once written after the root, as Valve's serializer lays the text out
@@ -187,11 +183,11 @@ public void KeyValues2_MatchesReferenceLayout()
"",
]);
- Assert.That(Datamodel.Datamodel.TextEncoding.GetString(text.ToArray()), Is.EqualTo(expected));
+ await Assert.That(Datamodel.Datamodel.TextEncoding.GetString(text.ToArray())).IsEqualTo(expected);
}
[Test]
- public void KeyValues2_FloatFormat()
+ public async Task KeyValues2_FloatFormat()
{
using var dm = new DM("test", 1);
dm.Root = new Element(dm, "root");
@@ -203,13 +199,13 @@ public void KeyValues2_FloatFormat()
dm.Save(text, "keyvalues2", 4);
var lines = Datamodel.Datamodel.TextEncoding.GetString(text.ToArray()).Split('\n');
- Assert.That(lines, Does.Contain("\t\"position\" \"vector3\" \"-270.1130371094 -233.075378418 562.0910644531\""));
- Assert.That(lines, Does.Contain("\t\"whole\" \"float\" \"40\""));
- Assert.That(lines, Does.Contain("\t\"negative\" \"float\" \"-1\""));
+ await Assert.That(lines).Contains("\t\"position\" \"vector3\" \"-270.1130371094 -233.075378418 562.0910644531\"");
+ await Assert.That(lines).Contains("\t\"whole\" \"float\" \"40\"");
+ await Assert.That(lines).Contains("\t\"negative\" \"float\" \"-1\"");
}
[Test]
- public void Binary_PrefixAttributes()
+ public async Task Binary_PrefixAttributes()
{
using var dm = new DM("vmap", 29);
dm.PrefixAttributes["map_asset_references"] = new StringArray(["a.vmdl", "b.vmat"]);
@@ -220,21 +216,40 @@ public void Binary_PrefixAttributes()
using var reloaded = DM.Load(Save(dm));
- Assert.That((StringArray?)reloaded.PrefixAttributes["map_asset_references"], Is.EqualTo(new[] { "a.vmdl", "b.vmat" }));
- Assert.That((string?)reloaded.PrefixAttributes["thumbnail_format"], Is.EqualTo("jpg"));
- Assert.That((byte[]?)reloaded.PrefixAttributes["thumbnail"], Is.EqualTo(new byte[] { 1, 2, 3 }));
- Assert.That(reloaded.Root!.Get("hello"), Is.EqualTo("world"));
+ await Assert.That((StringArray?)reloaded.PrefixAttributes["map_asset_references"]).IsEquivalentTo(["a.vmdl", "b.vmat"], CollectionOrdering.Matching);
+ await Assert.That((string?)reloaded.PrefixAttributes["thumbnail_format"]).IsEqualTo("jpg");
+ await Assert.That((byte[]?)reloaded.PrefixAttributes["thumbnail"]).IsEquivalentTo(new byte[] { 1, 2, 3 }, CollectionOrdering.Matching);
+ await Assert.That(reloaded.Root!.Get("hello")).IsEqualTo("world");
+ }
+
+ [Test]
+ public async Task Typed_PropertyTypeMismatchIsReported()
+ {
+ using var dm = new DM("vmap", 29);
+ var mesh = new CMapMesh();
+
+ var exception = Assert.Throws(() => mesh["disableShadows"] = "3");
+ await Assert.That(exception.Message).Contains("disableShadows");
}
[Test]
- public void Typed_PropertyTypeMismatchIsReported()
+ public async Task Typed_ConvertsBetweenBoolIntAndFloat()
{
using var dm = new DM("vmap", 29);
var mesh = new CMapMesh();
- // disableShadows is an int in the file format
- var exception = Assert.Throws(() => mesh["disableShadows"] = true);
- Assert.That(exception!.Message, Does.Contain("disableShadows"));
+ // files written by older tools store some int attributes as bool, and Valve's datamodel converts between the scalar types
+ mesh["disableShadows"] = true;
+ await Assert.That(mesh.DisableShadows).IsEqualTo(1);
+
+ mesh["renderToCubemaps"] = 0;
+ await Assert.That(mesh.RenderToCubemaps).IsFalse();
+
+ mesh["smoothingAngle"] = 45;
+ await Assert.That(mesh.SmoothingAngle).IsEqualTo(45f);
+
+ mesh["renderAmt"] = 127.9f;
+ await Assert.That(mesh.RenderAmount).IsEqualTo(127);
}
static byte[] Save(DM dm)
@@ -244,99 +259,99 @@ static byte[] Save(DM dm)
return ms.ToArray();
}
- static void AssertEquivalent(DM expected, DM actual, bool orderSensitive)
+ static async Task AssertEquivalent(DM expected, DM actual, bool orderSensitive, double floatTolerance = 0)
{
- AssertAttributesEquivalent(expected.PrefixAttributes, actual.PrefixAttributes, "prefix", orderSensitive);
+ await AssertAttributesEquivalent(expected.PrefixAttributes, actual.PrefixAttributes, "prefix", orderSensitive, floatTolerance);
var expectedElements = expected.AllElements.ToDictionary(e => e.ID);
var actualElements = actual.AllElements.ToDictionary(e => e.ID);
- Assert.That(actualElements.Keys, Is.EquivalentTo(expectedElements.Keys), "element ids");
- Assert.That(actual.Root?.ID, Is.EqualTo(expected.Root?.ID), "root");
+ await Assert.That(actualElements.Keys).IsEquivalentTo(expectedElements.Keys).Because("element ids");
+ await Assert.That(actual.Root?.ID).IsEqualTo(expected.Root?.ID).Because("root");
foreach (var (id, expectedElement) in expectedElements)
{
var actualElement = actualElements[id];
- Assert.That(actualElement.ClassName, Is.EqualTo(expectedElement.ClassName), $"class of {id}");
- Assert.That(actualElement.Name, Is.EqualTo(expectedElement.Name), $"name of {id}");
- Assert.That(actualElement.Stub, Is.EqualTo(expectedElement.Stub), $"stub of {id}");
+ await Assert.That(actualElement.ClassName).IsEqualTo(expectedElement.ClassName).Because($"class of {id}");
+ await Assert.That(actualElement.Name).IsEqualTo(expectedElement.Name).Because($"name of {id}");
+ await Assert.That(actualElement.Stub).IsEqualTo(expectedElement.Stub).Because($"stub of {id}");
if (!expectedElement.Stub)
{
- AssertAttributesEquivalent(expectedElement, actualElement, $"{expectedElement.ClassName} {id}", orderSensitive);
+ await AssertAttributesEquivalent(expectedElement, actualElement, $"{expectedElement.ClassName} {id}", orderSensitive, floatTolerance);
}
}
}
- static void AssertAttributesEquivalent(AttributeList expected, AttributeList actual, string context, bool orderSensitive)
+ static async Task AssertAttributesEquivalent(AttributeList expected, AttributeList actual, string context, bool orderSensitive, double floatTolerance)
{
var expectedAttributes = expected.GetAllAttributesForSerialization().ToArray();
var actualAttributes = actual.GetAllAttributesForSerialization().ToArray();
- var expectedNames = expectedAttributes.Select(a => a.Key);
- var actualNames = actualAttributes.Select(a => a.Key);
+ var expectedNames = expectedAttributes.Select(a => a.Key).ToArray();
+ var actualNames = actualAttributes.Select(a => a.Key).ToArray();
if (orderSensitive)
{
- Assert.That(actualNames, Is.EqualTo(expectedNames), $"attribute names and order of {context}");
+ await Assert.That(actualNames).IsEquivalentTo(expectedNames, CollectionOrdering.Matching).Because($"attribute names and order of {context}");
}
else
{
// a typed element also writes class properties the source lacked, with their default values, like the real datamodel does
- Assert.That(actualNames, Is.SupersetOf(expectedNames), $"attribute names of {context}");
+ await Assert.That(expectedNames.Except(actualNames)).IsEmpty().Because($"attribute names of {context}");
}
var actualByName = actualAttributes.ToDictionary(a => a.Key, a => a.Value);
foreach (var (name, expectedValue) in expectedAttributes)
{
- AssertValueEquivalent(expectedValue, actualByName[name], $"{context}.{name}");
+ await AssertValueEquivalent(expectedValue, actualByName[name], $"{context}.{name}", floatTolerance);
}
}
- static void AssertValueEquivalent(object? expected, object? actual, string context)
+ static async Task AssertValueEquivalent(object? expected, object? actual, string context, double floatTolerance)
{
if (expected is null || actual is null)
{
- Assert.That(actual, Is.EqualTo(expected), context);
+ await Assert.That(actual).IsEqualTo(expected).Because(context);
return;
}
switch (expected)
{
case Element expectedElement:
- Assert.That(actual, Is.InstanceOf(), $"type of {context}");
- Assert.That(((Element)actual).ID, Is.EqualTo(expectedElement.ID), context);
+ await Assert.That(actual).IsAssignableTo().Because($"type of {context}");
+ await Assert.That(((Element)actual).ID).IsEqualTo(expectedElement.ID).Because(context);
break;
case byte[] expectedBytes:
- Assert.That(actual, Is.EqualTo(expectedBytes), context);
+ await Assert.That((byte[])actual).IsEquivalentTo(expectedBytes, CollectionOrdering.Matching).Because(context);
break;
case IList expectedList:
- Assert.That(actual.GetType(), Is.EqualTo(expected.GetType()), $"type of {context}");
+ await Assert.That(actual.GetType()).IsEqualTo(expected.GetType()).Because($"type of {context}");
var actualList = (IList)actual;
- Assert.That(actualList.Count, Is.EqualTo(expectedList.Count), $"count of {context}");
+ await Assert.That(actualList.Count).IsEqualTo(expectedList.Count).Because($"count of {context}");
for (var i = 0; i < expectedList.Count; i++)
{
- AssertValueEquivalent(expectedList[i], actualList[i], $"{context}[{i}]");
+ await AssertValueEquivalent(expectedList[i], actualList[i], $"{context}[{i}]", floatTolerance);
}
break;
default:
- Assert.That(actual.GetType(), Is.EqualTo(expected.GetType()), $"type of {context}");
+ await Assert.That(actual.GetType()).IsEqualTo(expected.GetType()).Because($"type of {context}");
- if (FloatTolerance > 0 && TryGetComponents(expected, out var expectedComponents) && TryGetComponents(actual, out var actualComponents))
+ if (floatTolerance > 0 && TryGetComponents(expected, out var expectedComponents) && TryGetComponents(actual, out var actualComponents))
{
- Assert.That(actualComponents, Is.EqualTo(expectedComponents).Within(FloatTolerance), context);
+ for (var i = 0; i < expectedComponents.Length; i++)
+ {
+ await Assert.That((double)actualComponents[i]).IsEqualTo(expectedComponents[i]).Within(floatTolerance).Because($"{context} component {i}");
+ }
break;
}
- Assert.That(actual, Is.EqualTo(expected), context);
+ await Assert.That(actual).IsEqualTo(expected).Because(context);
break;
}
}
- // keyvalues2 prints floats with ten decimals, so values that small lose precision in that encoding
- static double FloatTolerance;
-
static bool TryGetComponents(object value, out float[] components)
{
components = value switch
diff --git a/Tests/SchemaTests.cs b/Tests/SchemaTests.cs
new file mode 100644
index 0000000..07e313e
--- /dev/null
+++ b/Tests/SchemaTests.cs
@@ -0,0 +1,161 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Numerics;
+using System.Threading.Tasks;
+using TUnit.Assertions.Enums;
+using Datamodel;
+using Datamodel.Codecs;
+using Datamodel.Format;
+using Tests.VMAP;
+using DM = Datamodel.Datamodel;
+
+namespace Datamodel_Tests
+{
+ ///
+ /// A class exercising every property shape the generated factory has to bind without reflection.
+ ///
+ [CamelCaseProperties]
+ internal class SchemaTestElement : Element
+ {
+ public int InitOnly { get; init; }
+
+ public string PrivateSet { get; private set; } = string.Empty;
+
+ public IntArray ReadOnlyArray { get; } = [];
+
+ int custom;
+ public int Custom
+ {
+ get => custom;
+ init => custom = value * 2;
+ }
+
+ [DMProperty("renamed")]
+ public bool Renamed { get; set; }
+ }
+
+ ///
+ /// A class using the convention that depends on the property type.
+ ///
+ [HungarianProperties]
+ internal class HungarianTestElement : Element
+ {
+ public int Count { get; set; }
+ public float Scale { get; set; }
+ public bool Visible { get; set; }
+ public Vector3 Origin { get; set; }
+ public Matrix4x4 Transform { get; set; }
+ public string Name2 { get; set; } = string.Empty;
+ }
+
+ ///
+ /// The ElementFactory generated into this assembly registers itself and describes the properties of every Element subclass.
+ ///
+ public class SchemaTests
+ {
+ static string Resource(string name) => Path.Combine(TestContext.TestDirectory!, "Resources", name);
+
+ [Test]
+ public async Task Factory_IsRegisteredWhenTheAssemblyInitialises()
+ {
+ // every assembly with Element subclasses gets a factory: the schema assembly and this one
+ var schemaFactory = DM.ElementFactories.SingleOrDefault(f => f.GetType().Assembly == typeof(CMapMesh).Assembly);
+ var testFactory = DM.ElementFactories.SingleOrDefault(f => f.GetType().Assembly == typeof(SchemaTests).Assembly);
+
+ await Assert.That(schemaFactory).IsNotNull();
+ await Assert.That(schemaFactory!.Create("Tests.VMAP", "CMapMesh")).IsTypeOf();
+ await Assert.That(schemaFactory.Create("Tests.VMAP", "NoSuchClass")).IsNull();
+ await Assert.That(schemaFactory.Create("Other.Namespace", "CMapMesh")).IsNull();
+ await Assert.That(schemaFactory.Schemas.Select(schema => schema.ElementType)).Contains(typeof(CMapMesh));
+
+ await Assert.That(testFactory).IsNotNull();
+ await Assert.That(testFactory!.Create("Datamodel_Tests", "SchemaTestElement")).IsTypeOf();
+ await Assert.That(testFactory.Create("Tests.VMAP", "CMapMesh")).IsNull().Because("a factory only knows the classes of its own assembly");
+ }
+
+ [Test]
+ public async Task Schema_ListsPropertiesBaseClassFirstWithAttributeNames()
+ {
+ var schema = ElementSchema.For(typeof(CMapMesh));
+
+ await Assert.That(schema).IsNotSameReferenceAs(ElementSchema.Empty);
+ await Assert.That(schema.ClassName).IsEqualTo("CMapMesh");
+ await Assert.That(schema.ElementType).IsEqualTo(typeof(CMapMesh));
+
+ // MapNode's properties come before CMapMesh's own, camelCased by the naming convention
+ var names = schema.Properties.Select(property => property.AttributeName).ToList();
+ await Assert.That(names[0]).IsEqualTo("origin");
+ await Assert.That(names.IndexOf("children")).IsLessThan(names.IndexOf("disableShadows"));
+ await Assert.That(schema.GetProperty("disableShadows")!.PropertyType).IsEqualTo(typeof(int));
+
+ // a DMProperty name replaces the convention
+ var root = ElementSchema.For(typeof(CMapRootElement));
+ await Assert.That(root.GetProperty("visbility")!.PropertyName).IsEqualTo("Visibility");
+ await Assert.That(root.GetProperty("Visibility")).IsNull();
+
+ await Assert.That(ElementSchema.For(typeof(Element))).IsSameReferenceAs(ElementSchema.Empty);
+ }
+
+ [Test]
+ public async Task Schema_AppliesTheNamingConventionsAtBuildTime()
+ {
+ // the generator computes these with the same rules the attributes apply at run time
+ var names = ElementSchema.For(typeof(HungarianTestElement)).Properties.Select(property => property.AttributeName).ToList();
+ await Assert.That(names).IsEquivalentTo(["m_nCount", "m_flScale", "m_bVisible", "m_vOrigin", "m_matTransform", "m_name2"], CollectionOrdering.Matching);
+
+ var convention = new HungarianPropertiesAttribute();
+ await Assert.That(convention.GetAttributeName("Count", typeof(int))).IsEqualTo("m_nCount");
+ await Assert.That(convention.GetAttributeName("Name2", typeof(string))).IsEqualTo("m_name2");
+ }
+
+ [Test]
+ public async Task Schema_AssignsEveryPropertyShape()
+ {
+ var element = new SchemaTestElement();
+
+ await Assert.That(element.ClassName).IsEqualTo("SchemaTestElement");
+ await Assert.That(element.Schema.Properties.Select(property => property.AttributeName))
+ .IsEquivalentTo(["initOnly", "privateSet", "readOnlyArray", "custom", "renamed"], CollectionOrdering.Matching);
+
+ element["initOnly"] = 5;
+ element["privateSet"] = "set through the private setter";
+ element["readOnlyArray"] = new IntArray([1, 2, 3]);
+ element["custom"] = 4;
+ element["renamed"] = true;
+
+ await Assert.That(element.InitOnly).IsEqualTo(5);
+ await Assert.That(element.PrivateSet).IsEqualTo("set through the private setter");
+ await Assert.That(element.ReadOnlyArray).IsEquivalentTo([1, 2, 3], CollectionOrdering.Matching);
+ await Assert.That(element.Custom).IsEqualTo(8).Because("the init accessor's own logic runs");
+ await Assert.That(element.Renamed).IsTrue();
+
+ // the values read back through the indexer and are all written, nothing lands in the plain attribute list
+ await Assert.That(element["custom"]).IsEqualTo(8);
+ await Assert.That(element.Count).IsZero();
+ await Assert.That(element.GetAllAttributesForSerialization().Select(attr => attr.Key))
+ .IsEquivalentTo(["initOnly", "privateSet", "readOnlyArray", "custom", "renamed"], CollectionOrdering.Matching);
+
+ // a read-only array can only be filled while empty
+ Assert.Throws(() => element["readOnlyArray"] = new IntArray([4]));
+ }
+
+ [Test]
+ public async Task Load_UsesTheNamespaceOfTheRootTypeUnlessToldOtherwise()
+ {
+ using var typed = DM.Load(Resource("roundtrip_test.vmap"));
+ await Assert.That(typed.Root).IsTypeOf();
+
+ using var explicitNamespace = DM.Load(Resource("roundtrip_test.vmap"), new LoadOptions { Namespace = "Tests.VMAP" });
+ await Assert.That(explicitNamespace.Root).IsTypeOf();
+
+ // no class of the namespace matches, so the root stays a plain Element and cannot be the requested type
+ var exception = Assert.Throws(() => DM.Load(Resource("roundtrip_test.vmap"), new LoadOptions { Namespace = "Nowhere" }));
+ await Assert.That(exception.Message).Contains("CMapRootElement");
+
+ using var untyped = DM.Load(Resource("roundtrip_test.vmap"), DeferredMode.Disabled);
+ await Assert.That(untyped.Root!.GetType()).IsEqualTo(typeof(Element));
+ await Assert.That(untyped.AllElements.All(element => element.GetType() == typeof(Element))).IsTrue();
+ }
+ }
+}
diff --git a/Tests/Tests.cs b/Tests/Tests.cs
index 599a671..d4e8db7 100644
--- a/Tests/Tests.cs
+++ b/Tests/Tests.cs
@@ -1,35 +1,47 @@
-using System;
+using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.IO;
-using NUnit.Framework;
+using System.Threading.Tasks;
+using TUnit.Assertions.Enums;
using Datamodel;
using System.Numerics;
using DM = Datamodel.Datamodel;
using System.Globalization;
using Tests.VMAP;
+using ValveResourceFormat.IO;
namespace Datamodel_Tests
{
- public class DatamodelTests
+ // sadly we must now involve the french in order to test culture invariance
+ public static class TestCulture
{
- protected FileStream Binary_9_File = File.OpenRead(TestContext.CurrentContext.TestDirectory + "/Resources/overboss_run.dmx");
- protected FileStream Binary_5_File = File.OpenRead(TestContext.CurrentContext.TestDirectory + "/Resources/taunt05_b5.dmx");
- protected FileStream Binary_4_File = File.OpenRead(TestContext.CurrentContext.TestDirectory + "/Resources/binary4.dmx");
- protected FileStream KeyValues2_1_File = File.OpenRead(TestContext.CurrentContext.TestDirectory + "/Resources/taunt05.dmx");
+ [Before(TestSession)]
+ public static void UseDecimalComma()
+ {
+ var culture = new CultureInfo("fr-FR");
+ CultureInfo.DefaultThreadCurrentCulture = culture;
+ CultureInfo.CurrentCulture = culture;
+ }
+ }
- const string GameBin = @"D:/Steam/steamapps/common/Counter-Strike Global Offensive/game/bin/win64";
+ public class DatamodelTests
+ {
+ protected FileStream Binary_9_File = File.OpenRead(TestContext.TestDirectory + "/Resources/overboss_run.dmx");
+ protected FileStream Binary_5_File = File.OpenRead(TestContext.TestDirectory + "/Resources/taunt05_b5.dmx");
+ protected FileStream Binary_4_File = File.OpenRead(TestContext.TestDirectory + "/Resources/binary4.dmx");
+ protected FileStream KeyValues2_1_File = File.OpenRead(TestContext.TestDirectory + "/Resources/taunt05.dmx");
- static readonly string DmxConvertExe = Path.Combine(GameBin, "dmxconvert.exe");
- static readonly bool DmxConvertExe_Exists = File.Exists(DmxConvertExe);
+ /// dmxconvert.exe of any installed Source 2 game, used to validate what the library writes. Null when no game is installed.
+ static readonly string? DmxConvertExe = GameFolderLocator.FindAllSteamGames()
+ .Select(game => Path.Combine(game.GamePath, "game", "bin", "win64", "dmxconvert.exe"))
+ .FirstOrDefault(File.Exists);
+ static readonly bool DmxConvertExe_Exists = DmxConvertExe != null;
static DatamodelTests()
{
- CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("fr-FR");
- CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo("fr-FR");
-
var binary = new byte[16];
Random.Shared.NextBytes(binary);
var quat = Quaternion.Normalize(new Quaternion(1, 2, 3, 4)); // dmxconvert will normalise this if I don't!
@@ -59,15 +71,17 @@ static DatamodelTests()
}).ToList();
}
+ /// The name of the running test, used to keep the files each test writes apart.
+ protected static string TestName => TestContext.Current?.Metadata.TestName ?? "test";
protected static string OutPath
- => Path.Combine(TestContext.CurrentContext.TestDirectory, TestContext.CurrentContext.Test.Name);
+ => Path.Combine(TestContext.TestDirectory!, TestName);
protected static string DmxSavePath { get { return OutPath + ".dmx"; } }
protected static string DmxConvertPath { get { return OutPath + "_convert.dmx"; } }
- protected static string[] GetDmxFiles()
+ public static IEnumerable GetDmxFiles()
{
- var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources");
+ var path = Path.Combine(TestContext.TestDirectory!, "Resources");
return Enumerable.Concat(
Directory.GetFiles(path, "*.dmx"),
Directory.GetFiles(path, "*.vmap")
@@ -88,13 +102,13 @@ protected static DM MakeDatamodel()
return new DM("model", 1); // using "model" to keep dxmconvert happy
}
- protected static bool SaveAndConvert(DM datamodel, string encoding, int version)
+ protected static async Task SaveAndConvert(DM datamodel, string encoding, int version)
{
datamodel.Save(DmxSavePath, encoding, version);
if (!DmxConvertExe_Exists)
{
- Assert.Warn("dmxconvert.exe not available.");
+ Console.WriteLine("dmxconvert.exe not available.");
return false;
}
@@ -111,14 +125,14 @@ protected static bool SaveAndConvert(DM datamodel, string encoding, int version)
}
};
- Console.WriteLine($"Converting {TestContext.CurrentContext.Test.Name}.dmx to {encoding}");
+ Console.WriteLine($"Converting {TestName}.dmx to {encoding}");
dmxconvert.Start();
var err = dmxconvert.StandardOutput.ReadToEnd();
err += dmxconvert.StandardError.ReadToEnd();
dmxconvert.WaitForExit();
- Assert.That(dmxconvert.ExitCode, Is.Zero, $"dmxconvert failed to convert the file with error: {err}");
+ await Assert.That(dmxconvert.ExitCode).IsZero().Because($"dmxconvert failed to convert the file with error: {err}");
return true;
}
@@ -158,7 +172,7 @@ protected static List