diff --git a/Datamodel.NET/Arrays.cs b/Datamodel.NET/Arrays.cs index e51d6cd..b2dd2bf 100644 --- a/Datamodel.NET/Arrays.cs +++ b/Datamodel.NET/Arrays.cs @@ -82,15 +82,19 @@ public void CopyTo(T[] array, int offset) bool ICollection.IsReadOnly { get { return false; } } - public bool IsFixedSize => throw new NotImplementedException(); + public bool IsFixedSize => false; - public bool IsReadOnly => throw new NotImplementedException(); + public bool IsReadOnly => false; - public bool IsSynchronized => throw new NotImplementedException(); + public bool IsSynchronized => false; - public object SyncRoot => throw new NotImplementedException(); + public object SyncRoot => Inner; - object? IList.this[int index] { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + object? IList.this[int index] + { + get => this[index]; + set => this[index] = value is null ? throw new InvalidOperationException("Trying to set a null object") : (T)value; + } public bool Remove(T item) => Inner.Remove(item); diff --git a/Datamodel.NET/AttributeList.cs b/Datamodel.NET/AttributeList.cs index e5c912d..1f4582f 100644 --- a/Datamodel.NET/AttributeList.cs +++ b/Datamodel.NET/AttributeList.cs @@ -282,17 +282,10 @@ public virtual object? this[string name] { if (prop.CanWrite) { - // were actually fine with this being null, it will just set the value to null - // but need to check so the type check doesn't fail if it is null - if (value != null) + // null is fine, it will just set the value to null + if (value != null && !prop.PropertyType.IsInstanceOfType(value)) { - var valueType = value.GetType(); - - // types must be equal, or a superclass - if (prop.PropertyType != typeof(Element) && valueType.IsSubclassOf(prop.PropertyType)) - { - throw new InvalidDataException($"class property '{prop.Name}' with type '{prop.PropertyType}' does not match the type '{valueType}' of the value being set, this is likely a mismatch between the real class and the class from the datamodel"); - } + 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"); } prop.SetValue(this, value); @@ -308,16 +301,16 @@ public virtual object? this[string name] if (existingArray.Count == 0) { existingArray.AddRange(incomingArray); - return; } else { throw new InvalidOperationException($"Attribute '{name}' modifies property {prop.DeclaringType!.Name}.{prop.Name}, which is write only and can't be replaced."); } } - - - throw new InvalidDataException("Property of deserialisation class must be writeable, make sure it's public and has a public setter"); + 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"); + } } return; @@ -432,6 +425,9 @@ public int Count /// public object SyncRoot { get { return Attribute_ChangeLock; } } + /// + /// Enumerates every attribute to be written by a codec: class properties first, in declaration order, followed by the plain attributes in the order they were added. + /// public IEnumerable GetAllAttributesForSerialization() { foreach (var attr in GetPropertyBasedAttributes(useSerializationName: true)) diff --git a/Datamodel.NET/Codecs/Binary.cs b/Datamodel.NET/Codecs/Binary.cs index 8a33d8a..458123b 100644 --- a/Datamodel.NET/Codecs/Binary.cs +++ b/Datamodel.NET/Codecs/Binary.cs @@ -21,6 +21,11 @@ class Binary : IDeferredAttributeCodec static readonly Dictionary SupportedAttributes = []; BinaryReader? Reader; + /// + /// Elements in the order the stream declares them. Element references are indices into this list, which must not change for deferred loading. + /// + readonly List ElementIndex = []; + /// /// The number of Datamodel binary ticks in one second. Used to store TimeSpan values. /// @@ -48,7 +53,8 @@ static Binary() static byte TypeToId(Type type, int version) { - bool array = Datamodel.IsDatamodelArrayType(type); + // a byte[] is a "binary" blob, distinct from a "uint8_array" (Array) in encoding version 9 + bool array = type != typeof(byte[]) && Datamodel.IsDatamodelArrayType(type); var search_type = array ? Datamodel.GetArrayInnerType(type) : type; if (array && search_type == typeof(byte) && !SupportedAttributes[version].Contains(typeof(byte))) @@ -166,6 +172,19 @@ public StringDictionary(int encoding_version, BinaryWriter writer, Datamodel dm, Scraped = []; ScrapeElement(dm.Root); + + // the prefix attributes are also written as a regular element in version 9 + if (EncodingVersion >= 9 && dm.PrefixAttributes.Count > 0) + { + AddString(string.Empty); + AddString(PrefixElementClass); + foreach (var attr in dm.PrefixAttributes) + { + AddString(attr.Key); + if (attr.Value is string stringValue) + AddString(stringValue); + } + } } } @@ -322,7 +341,10 @@ public void Encode(Datamodel dm, string encoding, int encoding_version, Stream s return dm.AllElements[id] ?? new Element(dm, id); } - return dm.AllElements[index]; + if (index < 0 || index >= ElementIndex.Count) + throw new CodecException($"Element index {index} is out of range."); + + return ElementIndex[index]; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -375,8 +397,7 @@ private static Matrix4x4 ReadMatrix4x4(BinaryReader reader) public Datamodel Decode(string encoding, int encoding_version, string format, int format_version, Stream stream, DeferredMode defer_mode, ReflectionParams reflectionParams) { - var elementFactoryTypes = CodecUtilities.GetIElementFactoryClasses(); - var elementFactory = (IElementFactory)Activator.CreateInstance(elementFactoryTypes.First()); + var resolver = new ElementTypeResolver(reflectionParams); stream.Seek(0, SeekOrigin.Begin); while (true) @@ -416,16 +437,18 @@ public Datamodel Decode(string encoding, int encoding_version, string format, in var id_bits = Reader.ReadBytes(16); var id = new Guid(BitConverter.IsLittleEndian ? id_bits : id_bits.Reverse().ToArray()); - if (!CodecUtilities.TryConstructCustomElement(elementFactory, reflectionParams, dm, type, name, id, out _)) + if (!CodecUtilities.TryConstructCustomElement(resolver, dm, type, name, id, out var elem)) { // note: constructing an element, adds it to the datamodel.AllElements - _ = new Element(dm, name, id, type); + elem = new Element(dm, name, id, type); } + + ElementIndex.Add(elem!); } // read attributes (or not, if we're deferred) - foreach (var elem in dm.AllElements.ToArray()) + foreach (var elem in ElementIndex) { // assert if stub Debug.Assert(!elem.Stub); @@ -447,9 +470,24 @@ public Datamodel Decode(string encoding, int encoding_version, string format, in } } + // version 9 also stores the prefix attributes as an unreferenced element right after the root, fold it back in + if (EncodingVersion >= 9 && dm.PrefixAttributes.Count > 0 && dm.AllElements.Count > 1) + { + var duplicate = dm.AllElements[1]; + + if (duplicate != null && !duplicate.Stub && duplicate.ClassName == PrefixElementClass && duplicate.Name.Length == 0 + && duplicate.Keys.SequenceEqual(dm.PrefixAttributes.Keys)) + { + dm.PrefixElementId = duplicate.ID; + dm.AllElements.RemoveUnreferenced(duplicate); + } + } + return dm; } + const string PrefixElementClass = "DmElement"; + int EncodingVersion; public object? DeferredDecodeAttribute(Datamodel dm, long offset) @@ -553,7 +591,7 @@ void SkipAttribute(BinaryReader reader) readonly struct Encoder { readonly Dictionary ElementIndices; - readonly List ElementOrder; + readonly List ElementOrder; readonly BinaryWriter Writer; readonly StringDictionary StringDict; readonly Datamodel Datamodel; @@ -579,20 +617,30 @@ public void Encode() Writer.Write(string.Format(CodecUtilities.HeaderPattern, "binary", EncodingVersion, Datamodel.Format, Datamodel.FormatVersion) + "\n"); if (EncodingVersion >= 9) - Writer.Write(0); // Prefix elements + { + WritePrefixAttributes(); + } StringDict.WriteSelf(Writer); + var hasPrefixElement = EncodingVersion >= 9 && Datamodel.PrefixAttributes.Count > 0; + var elementCount = CountChildren(Datamodel.Root, []) + (hasPrefixElement ? 1 : 0); + Writer.Write(elementCount); + + var root = Datamodel.Root; + if (root != null && !root.Stub) { - var counter = new HashSet(); - var elementCount = CountChildren(Datamodel.Root, counter); + WriteIndexEntry(root, root.ClassName, root.Name, root.ID); - Writer.Write(elementCount); + // the prefix attributes are also stored as an unreferenced element right after the root + if (hasPrefixElement) + WriteIndexEntry(Datamodel.PrefixAttributes, PrefixElementClass, string.Empty, Datamodel.PrefixElementId); + + WriteIndexChildren(root); } - WriteIndex(Datamodel.Root); - foreach (var e in ElementOrder) - WriteBody(e); + foreach (var body in ElementOrder) + WriteBody(body); } int CountChildren(Element? elem, HashSet counter) @@ -625,16 +673,26 @@ int CountChildren(Element? elem, HashSet counter) void WriteIndex(Element? elem) { - if (elem is null || elem.Stub) return; + if (elem is null || elem.Stub || ElementIndices.ContainsKey(elem)) return; + + WriteIndexEntry(elem, elem.ClassName, elem.Name, elem.ID); + WriteIndexChildren(elem); + } - ElementIndices[elem] = ElementIndices.Count; - ElementOrder.Add(elem); + void WriteIndexEntry(AttributeList body, string className, string name, Guid id) + { + if (body is Element elem) + ElementIndices[elem] = ElementOrder.Count; + ElementOrder.Add(body); - StringDict.WriteString(elem.ClassName, Writer); - if (EncodingVersion >= 4) StringDict.WriteString(elem.Name, Writer); - else Writer.Write(elem.Name); - Writer.Write(elem.ID.ToByteArray()); + StringDict.WriteString(className, Writer); + if (EncodingVersion >= 4) StringDict.WriteString(name, Writer); + else Writer.Write(name); + Writer.Write(id.ToByteArray()); + } + void WriteIndexChildren(Element elem) + { foreach (var attr in Context.Attributes[elem]) { var child_elem = attr.Value as Element; @@ -656,31 +714,63 @@ void WriteIndex(Element? elem) } } - void WriteBody(Element elem) + /// + /// Prefix attributes are stored as a list of prefix elements, each a list of name/typed value pairs. + /// Only the first prefix element is read back, so everything is written into a single one. + /// + void WritePrefixAttributes() + { + var prefixAttributes = Datamodel.PrefixAttributes.Where(attr => attr.Value != null).ToArray(); + + if (prefixAttributes.Length == 0) + { + Writer.Write(0); + return; + } + + Writer.Write(1); + Writer.Write(prefixAttributes.Length); + + foreach (var attr in prefixAttributes) + { + Writer.Write(attr.Key); + WriteTypedValue(attr.Value, raw_string: true); + } + } + + void WriteBody(AttributeList elem) { - var attributesIterated = Context.Attributes[elem]; - //Writer.Write(elem.Count); + var attributesIterated = elem is Element element ? Context.Attributes[element] : elem.GetAllAttributesForSerialization().ToArray(); Writer.Write(attributesIterated.Length); foreach (var attr in attributesIterated) { StringDict.WriteString(attr.Key, Writer); - var attr_type = attr.Value == null ? typeof(Element) : attr.Value.GetType(); - var attr_type_id = TypeToId(attr_type, EncodingVersion); - Writer.Write(attr_type_id); + WriteTypedValue(attr.Value, raw_string: false); + } + } - if (attr.Value == null || !Datamodel.IsDatamodelArrayType(attr.Value.GetType())) - WriteAttribute(attr.Value, false); - else - { - var array = (System.Collections.IList)attr.Value; - Writer.Write(array.Count); - attr_type = Datamodel.GetArrayInnerType(array.GetType()); - foreach (var item in array) - WriteAttribute(item, true); - } + /// + /// Writes the type id of a value followed by the value itself, or by the item count and items for arrays. + /// + void WriteTypedValue(object? value, bool raw_string) + { + var attr_type = value == null ? typeof(Element) : value.GetType(); + var attr_type_id = TypeToId(attr_type, EncodingVersion); + Writer.Write(attr_type_id); + + if (value == null || value is byte[] || !Datamodel.IsDatamodelArrayType(attr_type)) + { + WriteAttribute(value, raw_string); + return; } + + var array = (System.Collections.IList)value; + Writer.Write(array.Count); + foreach (var item in array) + WriteAttribute(item, true); } + /// Whether the value is an array item or a prefix attribute, in which case strings are written inline rather than through the dictionary. void WriteAttribute(object? value, bool in_array) { if (value == null) diff --git a/Datamodel.NET/Codecs/KeyValues2.cs b/Datamodel.NET/Codecs/KeyValues2.cs index fb01f37..96eae02 100644 --- a/Datamodel.NET/Codecs/KeyValues2.cs +++ b/Datamodel.NET/Codecs/KeyValues2.cs @@ -49,26 +49,18 @@ static KeyValues2() } #region Encode + /// + /// Writes lines with tab indentation and LF line endings, as the reference serializer does. + /// class KV2Writer : IDisposable { - public int Indent - { - get { return indent_count; } - set - { - indent_count = value; - indent_string = Context.GetIndentation(value); - } - } - int indent_count = 0; - string indent_string = "\n"; + public int Indent { get; set; } + readonly TextWriter Output; - readonly SerializationContext Context; - public KV2Writer(Stream output, SerializationContext context) + public KV2Writer(Stream output) { Output = new StreamWriter(output, Datamodel.TextEncoding); - Context = context; } public void Dispose() @@ -76,52 +68,30 @@ public void Dispose() Output.Dispose(); } - static string Sanitise(string value) + public static string Sanitise(string value) { - return value.Replace("\"", "\\\""); + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); } - /// - /// Writes the string straight to the output steam, with no sanitisation. - /// - public void Write(string value) - { - Output.Write(value); - } + public static string Token(string value) => "\"" + Sanitise(value) + "\""; - public void WriteTokens(params string[] values) + public void WriteLine(string line) { - Output.Write('"' + string.Join("\" \"", values.Select(s => Sanitise(s))) + '"'); - } + for (var i = 0; i < Indent; i++) + Output.Write('\t'); - public void WriteLine() - { - Output.Write(indent_string); + Output.Write(line); + Output.Write('\n'); } - /// - /// Writes a new line followed by the given value - /// - public void WriteLine(string value) - { - WriteLine(); - Output.Write(value); - } - - public void WriteTokenLine(params string[] values) - { - Output.Write(indent_string); - WriteTokens(values); - } - - public void TrimEnd(int count) + public void WriteLine() { - if (count > 0) - { - Output.Flush(); - var stream = ((StreamWriter)Output).BaseStream; - stream.SetLength(stream.Length - count); - } + Output.Write('\n'); } public void Flush() @@ -130,8 +100,8 @@ public void Flush() } } - // Multi-referenced elements are written out as a separate block at the end of the file. - // In-line only the id is written. + // Elements referenced more than once are written as separate blocks after the root and referred to by id. + // Elements referenced once are written inline. Dictionary ReferenceCount = []; SerializationContext Context = new(); @@ -167,209 +137,178 @@ void CountReferences(Element? elem) } } - void WriteAttribute(string name, int encodingVersion, Type type, object value, bool in_array, KV2Writer writer) + static string FormatFloat(float value) { - bool is_element = type == typeof(Element) || type.IsSubclassOf(typeof(Element)); + // ten decimals with trailing zeros dropped, as the reference serializer prints them + return ((double)value).ToString("0.##########", CultureInfo.InvariantCulture); + } - Type? inner_type = null; - if (!in_array) - { - // TODO: subclass check in this method like above - and in all other places with == typeof(Element) - inner_type = Datamodel.GetArrayInnerType(type); + static string FormatFloats(params float[] values) + { + return string.Join(" ", values.Select(FormatFloat)); + } - if (inner_type == typeof(byte) && type == typeof(byte[])) - inner_type = null; // serialize as binary at all times + static string FormatValue(object value) + { + return value switch + { + string stringValue => stringValue, + bool boolValue => boolValue ? "1" : "0", + int intValue => intValue.ToString(CultureInfo.InvariantCulture), + float floatValue => FormatFloat(floatValue), + byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), + ulong ulongValue => "0x" + ulongValue.ToString("x", CultureInfo.InvariantCulture), + byte[] binaryValue => Convert.ToHexString(binaryValue), + TimeSpan timeValue => FormatFloat((float)timeValue.TotalSeconds), + Color colorValue => FormattableString.Invariant($"{colorValue.R} {colorValue.G} {colorValue.B} {colorValue.A}"), + Vector2 v => FormatFloats(v.X, v.Y), + Vector3 v => FormatFloats(v.X, v.Y, v.Z), + Vector4 v => FormatFloats(v.X, v.Y, v.Z, v.W), + Quaternion q => FormatFloats(q.X, q.Y, q.Z, q.W), + QAngle a => FormatFloats(a.Pitch, a.Yaw, a.Roll), + Matrix4x4 m => FormatFloats(m.M11, m.M12, m.M13, m.M14, m.M21, m.M22, m.M23, m.M24, m.M31, m.M32, m.M33, m.M34, m.M41, m.M42, m.M43, m.M44), + _ => throw new CodecException($"Cannot serialize a value of type {value.GetType().Name} to KeyValues2"), + }; + } - /* - if (inner_type == typeof(byte) && !ValidAttributes[EncodingVersion].Contains(typeof(byte))) - inner_type = null; // fall back on the "binary" type in older KV2 versions - */ + void WriteAttribute(string name, int encodingVersion, object? value, KV2Writer writer) + { + var nameToken = KV2Writer.Token(name); + + if (value is null || value is Element) + { + WriteElementAttribute(nameToken, encodingVersion, (Element?)value, writer); + return; } - // Elements are supported by all. - if (!is_element && !ValidAttributes[encodingVersion].Contains(inner_type ?? type)) - throw new CodecException(type.Name + " is not valid in KeyValues2 " + encodingVersion); + var type = value.GetType(); - if (inner_type != null) - { - is_element = inner_type == typeof(Element); + // a byte[] is always serialized as "binary", never as a uint8_array + var innerType = type == typeof(byte[]) ? null : Datamodel.GetArrayInnerType(type); - writer.WriteTokenLine(name, TypeNames[inner_type] + "_array"); + if (innerType != null) + { + if (!ValidAttributes[encodingVersion].Contains(innerType)) + throw new CodecException(innerType.Name + " is not valid in KeyValues2 " + encodingVersion); - if (((System.Collections.IList)value).Count == 0) - { - writer.Write(" [ ]"); - return; - } + WriteArrayAttribute(nameToken, encodingVersion, innerType, (IList)value, writer); + return; + } - if (is_element) writer.WriteLine("["); - else writer.Write(" ["); + if (!ValidAttributes[encodingVersion].Contains(type)) + throw new CodecException(type.Name + " is not valid in KeyValues2 " + encodingVersion); - writer.Indent++; - foreach (var array_value in (System.Collections.IList)value) - WriteAttribute(string.Empty, encodingVersion, inner_type, array_value, true, writer); - writer.Indent--; - writer.TrimEnd(1); // remove trailing comma + writer.WriteLine($"{nameToken} {KV2Writer.Token(TypeNames[type])} {KV2Writer.Token(FormatValue(value))}"); + } - if (inner_type == typeof(Element)) writer.WriteLine("]"); - else writer.Write(" ]"); + void WriteElementAttribute(string nameToken, int encodingVersion, Element? elem, KV2Writer writer) + { + if (elem is null || ShouldBeReferenced(elem)) + { + writer.WriteLine($"{nameToken} \"element\" \"{(elem is null ? string.Empty : elem.ID.ToString())}\""); return; } - if (is_element) + writer.WriteLine($"{nameToken} {KV2Writer.Token(elem.ClassName)}"); + WriteElementBody(elem, encodingVersion, writer); + writer.WriteLine("}"); + + // the reference serializer leaves a blank line after an inline element + writer.WriteLine(); + } + + void WriteArrayAttribute(string nameToken, int encodingVersion, Type innerType, IList array, KV2Writer writer) + { + writer.WriteLine($"{nameToken} {KV2Writer.Token(TypeNames[innerType] + "_array")} "); + writer.WriteLine("["); + writer.Indent++; + + for (var i = 0; i < array.Count; i++) { - var elem = (Element)value; - var id = elem.ID.ToString(); + var separator = i == array.Count - 1 ? string.Empty : ","; + var item = array[i]; - if (in_array) + if (innerType == typeof(Element)) { - if (ShouldBeReferenced(elem)) - { - writer.WriteTokenLine("element", id); - } - else - { - writer.WriteLine(); - WriteElement(elem, encodingVersion, writer); - } + var elem = (Element?)item; - writer.Write(","); - } - else - { - if (ShouldBeReferenced(elem)) + if (elem is null || ShouldBeReferenced(elem)) { - writer.WriteTokenLine(name, "element", id); + writer.WriteLine($"\"element\" \"{(elem is null ? string.Empty : elem.ID.ToString())}\"{separator}"); } else { - writer.WriteLine($"\"{name}\" "); - WriteElement(elem, encodingVersion, writer); + writer.WriteLine(KV2Writer.Token(elem.ClassName)); + WriteElementBody(elem, encodingVersion, writer); + writer.WriteLine("}" + separator); } } - } - else - { - if (type == typeof(bool)) - value = (bool)value ? 1 : 0; - else if (type == typeof(float)) - value = FormattableString.Invariant($"{(float)value}"); - else if (type == typeof(byte[])) - value = Convert.ToHexString((byte[])value).Replace("-", string.Empty, false, CultureInfo.InvariantCulture); - else if (type == typeof(TimeSpan)) - value = ((TimeSpan)value).TotalSeconds.ToString(CultureInfo.InvariantCulture); - else if (type == typeof(Color)) - { - var castValue = (Color)value; - value = FormattableString.Invariant($"{castValue.R} {castValue.G} {castValue.B} {castValue.A}"); - } - else if (value is ulong ulong_value) - value = $"0x{ulong_value.ToString("x", CultureInfo.InvariantCulture)}"; - else if (type == typeof(Vector2)) - { - var castValue = (Vector2)value; - value = FormattableString.Invariant($"{castValue.X} {castValue.Y}"); - } - else if (type == typeof(Vector3)) - { - var castValue = (Vector3)value; - value = FormattableString.Invariant($"{castValue.X} {castValue.Y} {castValue.Z}"); - } - else if (type == typeof(Vector4)) - { - var castValue = (Vector4)value; - value = FormattableString.Invariant($"{castValue.X} {castValue.Y} {castValue.Z} {castValue.W}"); - } - else if (type == typeof(Quaternion)) - { - var castValue = (Quaternion)value; - value = FormattableString.Invariant($"{castValue.X} {castValue.Y} {castValue.Z} {castValue.W}"); - } - else if (type == typeof(Matrix4x4)) - { - var castValue = (Matrix4x4)value; - value = - FormattableString.Invariant($"{castValue.M11} {castValue.M12} {castValue.M13} {castValue.M14}") + - FormattableString.Invariant($" {castValue.M21} {castValue.M22} {castValue.M23} {castValue.M24}") + - FormattableString.Invariant($" {castValue.M31} {castValue.M32} {castValue.M33} {castValue.M34}") + - FormattableString.Invariant($" {castValue.M41} {castValue.M42} {castValue.M43} {castValue.M44}"); - } - else if (value is QAngle castValue) + else { - value = FormattableString.Invariant($"{castValue.Pitch} {castValue.Yaw} {castValue.Roll}"); + writer.WriteLine(KV2Writer.Token(FormatValue(item!)) + separator); } - - if (in_array) - writer.Write(FormattableString.Invariant($" \"{value}\",")); - else - writer.WriteTokenLine(name, TypeNames[type], FormattableString.Invariant($"{value}")); } + writer.Indent--; + writer.WriteLine("]"); } - private bool ShouldBeReferenced(Element? elem) + private bool ShouldBeReferenced(Element elem) { - if (elem is null) - { - return false; - } - - return SupportsReferenceIds && (elem == null || ReferenceCount.TryGetValue(elem, out var refCount) && refCount > 1); + return SupportsReferenceIds && ReferenceCount.TryGetValue(elem, out var refCount) && refCount > 1; } - void WriteElement(Element element, int encodingVersion, KV2Writer writer) + /// + /// Writes the opening brace, id, name and attributes of an element. The caller writes the class name before and the closing brace after. + /// + void WriteElementBody(Element element, int encodingVersion, KV2Writer writer) { if (TypeNames.ContainsValue(element.ClassName)) throw new CodecException($"Element {element.ID} uses reserved type name \"{element.ClassName}\""); - writer.WriteTokens(element.ClassName); + writer.WriteLine("{"); writer.Indent++; if (SupportsReferenceIds) - writer.WriteTokenLine("id", "elementid", element.ID.ToString()); + writer.WriteLine($"\"id\" \"elementid\" \"{element.ID}\""); - // Skip empty names right now. if (!string.IsNullOrEmpty(element.Name)) - { - writer.WriteTokenLine("name", "string", element.Name); - } + writer.WriteLine($"\"name\" \"string\" {KV2Writer.Token(element.Name)}"); foreach (var attr in Context.Attributes[element]) - { - if (attr.Value != null) - WriteAttribute(attr.Key, encodingVersion, attr.Value.GetType(), attr.Value, false, writer); - } + WriteAttribute(attr.Key, encodingVersion, attr.Value, writer); writer.Indent--; + } + + void WriteElement(Element element, int encodingVersion, KV2Writer writer) + { + writer.WriteLine(KV2Writer.Token(element.ClassName)); + WriteElementBody(element, encodingVersion, writer); writer.WriteLine("}"); } public void Encode(Datamodel dm, string encoding, int encodingVersion, Stream stream) { Context = new SerializationContext(); - var writer = new KV2Writer(stream, Context); + var writer = new KV2Writer(stream); SupportsReferenceIds = encoding != "keyvalues2_noids"; - writer.Write(String.Format(CodecUtilities.HeaderPattern, encoding, encodingVersion, dm.Format, dm.FormatVersion)); - writer.WriteLine(); + writer.WriteLine(string.Format(CodecUtilities.HeaderPattern, encoding, encodingVersion, dm.Format, dm.FormatVersion)); ReferenceCount = []; if (encodingVersion >= 4 && dm.PrefixAttributes.Count > 0) { - writer.WriteTokens("$prefix_element$"); + writer.WriteLine("\"$prefix_element$\""); writer.WriteLine("{"); writer.Indent++; - writer.WriteTokenLine("id", "elementid", Guid.NewGuid().ToString()); + writer.WriteLine($"\"id\" \"elementid\" \"{dm.PrefixElementId}\""); foreach (var attr in dm.PrefixAttributes) - if (attr.Value != null) - { - WriteAttribute(attr.Key, encodingVersion, attr.Value.GetType(), attr.Value, false, writer); - } + WriteAttribute(attr.Key, encodingVersion, attr.Value, writer); writer.Indent--; writer.WriteLine("}"); - writer.WriteLine(); } if (SupportsReferenceIds) @@ -378,8 +317,8 @@ public void Encode(Datamodel dm, string encoding, int encodingVersion, Stream st if (dm.Root != null) { WriteElement(dm.Root, encodingVersion, writer); + writer.WriteLine(); } - writer.WriteLine(); if (SupportsReferenceIds) { @@ -387,7 +326,7 @@ public void Encode(Datamodel dm, string encoding, int encodingVersion, Stream st { if (pair.Key == dm.Root) continue; - writer.WriteLine(); + WriteElement(pair.Key, encodingVersion, writer); writer.WriteLine(); } @@ -405,7 +344,9 @@ private class IntermediateData // we can go trough these and actually create the attributes // and add the elements to lists public Dictionary> PropertiesToAdd = []; - public Dictionary> ListRefs = []; + + // array items referenced by id keep their slot (filled with null while parsing) and are resolved in place afterwards + public List<(IList List, int Index, Guid Id)> ListRefs = []; public void HandleElementProp(Element? element, string attrName, Guid id) { @@ -426,17 +367,10 @@ public void HandleElementProp(Element? element, string attrName, Guid id) } - public void HandleListRefs(IList list, Guid id) + public void HandleListRefs(ElementArray list, Guid id) { - ListRefs.TryGetValue(list, out var guidList); - - if (guidList == null) - { - guidList = []; - ListRefs.Add(list, guidList); - } - - guidList.Add(id); + list.Add(null!); + ListRefs.Add((list, list.Count - 1, id)); } } @@ -454,7 +388,13 @@ string Decode_NextToken(StreamReader reader) var c = (char)read; if (escaped) { - TokenBuilder.Append(c); + TokenBuilder.Append(c switch + { + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + _ => c, + }); escaped = false; continue; } @@ -469,6 +409,7 @@ string Decode_NextToken(StreamReader reader) case '\r': case '\n': Line++; + if (in_block) TokenBuilder.Append(c); break; case '{': case '}': @@ -484,7 +425,7 @@ string Decode_NextToken(StreamReader reader) } } - Element? Decode_ParseElement(IElementFactory elementFactory, string class_name, ReflectionParams reflectionParams, StreamReader reader, Datamodel dataModel, IntermediateData intermediateData) + Element? Decode_ParseElement(ElementTypeResolver resolver, string class_name, StreamReader reader, Datamodel dataModel, IntermediateData intermediateData) { string elem_class = class_name ?? Decode_NextToken(reader); string elem_name = string.Empty; @@ -508,9 +449,13 @@ string Decode_NextToken(StreamReader reader) var id = new Guid(elem_id); if (elem_class != "$prefix_element$") { - CodecUtilities.TryConstructCustomElement(elementFactory, reflectionParams, dataModel, elem_class, elem_name, id, out elem); + CodecUtilities.TryConstructCustomElement(resolver, dataModel, elem_class, elem_name, id, out elem); elem ??= new Element(dataModel, elem_name, id, elem_class); } + else + { + dataModel.PrefixElementId = id; + } continue; } @@ -527,6 +472,9 @@ string Decode_NextToken(StreamReader reader) { var id_s = Decode_NextToken(reader); + // the attribute keeps its position, it is filled in once every element has been parsed; an empty id is a null reference + elem?.Add(attr_name, null); + if (!string.IsNullOrEmpty(id_s)) { intermediateData.HandleElementProp(elem, attr_name, new Guid(id_s)); @@ -537,7 +485,7 @@ string Decode_NextToken(StreamReader reader) object? attr_value = null; if (attr_type == null) - attr_value = Decode_ParseElement(elementFactory, attr_type_s, reflectionParams, reader, dataModel, intermediateData); + attr_value = Decode_ParseElement(resolver, attr_type_s, reader, dataModel, intermediateData); else if (attr_type_s.EndsWith("_array")) { var array = CodecUtilities.MakeList(attr_type, 5); // assume 5 items @@ -556,23 +504,27 @@ string Decode_NextToken(StreamReader reader) if (!string.IsNullOrEmpty(id_s)) { - intermediateData.HandleListRefs(array, new Guid(id_s)); + intermediateData.HandleListRefs((ElementArray)array, new Guid(id_s)); + } + else + { + ((ElementArray)array).Add(null!); } } // inline Element else if (attr_type == typeof(Element)) { - array.Add(Decode_ParseElement(elementFactory, next, reflectionParams, reader, dataModel, intermediateData)); + array.Add(Decode_ParseElement(resolver, next, reader, dataModel, intermediateData)); } // normal value else { - array.Add(Decode_ParseValue(elementFactory, attr_type, next, reflectionParams, reader, dataModel, intermediateData)); + array.Add(Decode_ParseValue(resolver, attr_type, next, reader, dataModel, intermediateData)); } } } else - attr_value = Decode_ParseValue(elementFactory, attr_type, Decode_NextToken(reader), reflectionParams, reader, dataModel, intermediateData); + attr_value = Decode_ParseValue(resolver, attr_type, Decode_NextToken(reader), reader, dataModel, intermediateData); if (elem != null) elem.Add(attr_name, attr_value); @@ -582,7 +534,7 @@ string Decode_NextToken(StreamReader reader) return elem; } - object? Decode_ParseValue(IElementFactory elementFactory, Type type, string value, ReflectionParams reflectionParams, StreamReader reader, Datamodel dataModel, IntermediateData intermediateData) + object? Decode_ParseValue(ElementTypeResolver resolver, Type type, string value, StreamReader reader, Datamodel dataModel, IntermediateData intermediateData) { if (type == typeof(string)) return value; @@ -590,7 +542,7 @@ string Decode_NextToken(StreamReader reader) value = value.Trim(); if (type == typeof(Element)) - return Decode_ParseElement(elementFactory, value, reflectionParams, reader, dataModel, intermediateData); + return Decode_ParseElement(resolver, value, reader, dataModel, intermediateData); if (type == typeof(int)) return int.Parse(value, CultureInfo.InvariantCulture); else if (type == typeof(float)) @@ -660,8 +612,7 @@ string Decode_NextToken(StreamReader reader) public Datamodel Decode(string encoding, int encoding_version, string format, int format_version, Stream stream, DeferredMode defer_mode, ReflectionParams reflectionParams) { - var elementFactoryTypes = CodecUtilities.GetIElementFactoryClasses(); - var elementFactory = (IElementFactory)Activator.CreateInstance(elementFactoryTypes.First()); + var resolver = new ElementTypeResolver(reflectionParams); var dataModel = new Datamodel(format, format_version); @@ -684,7 +635,7 @@ public Datamodel Decode(string encoding, int encoding_version, string format, in { break; } try - { Decode_ParseElement(elementFactory, next, reflectionParams, reader, dataModel, intermediateData); } + { Decode_ParseElement(resolver, next, reader, dataModel, intermediateData); } catch (Exception err) { throw new CodecException($"KeyValues2 decode failed on line {Line}:\n\n{err.Message}", err); } } @@ -698,13 +649,9 @@ public Datamodel Decode(string encoding, int encoding_version, string format, in } - foreach (var list in intermediateData.ListRefs) + foreach (var (list, index, id) in intermediateData.ListRefs) { - foreach (var id in list.Value) - { - var elemToAdd = dataModel.AllElements[id]; - list.Key.Add(elemToAdd); - } + list[index] = dataModel.AllElements[id]; } return dataModel; diff --git a/Datamodel.NET/Datamodel.ElementList.cs b/Datamodel.NET/Datamodel.ElementList.cs index 97fd548..ee612e2 100644 --- a/Datamodel.NET/Datamodel.ElementList.cs +++ b/Datamodel.NET/Datamodel.ElementList.cs @@ -203,6 +203,30 @@ public bool Remove(Element item, RemoveMode mode) finally { ChangeLock.ExitUpgradeableReadLock(); } } + /// + /// Removes an which the caller knows is not referenced by any other Element, without scanning the Datamodel. + /// + internal void RemoveUnreferenced(Element item) + { + ChangeLock.EnterWriteLock(); + try + { + if (!store.Contains(item.ID)) + { + return; + } + + store.Remove(item.ID); + item.Owner = null; + } + finally + { + ChangeLock.ExitWriteLock(); + } + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item)); + } + /// /// Removes unreferenced Elements from the Datamodel. /// diff --git a/Datamodel.NET/Datamodel.NET.csproj b/Datamodel.NET/Datamodel.NET.csproj index d91195a..48ce6e3 100644 --- a/Datamodel.NET/Datamodel.NET.csproj +++ b/Datamodel.NET/Datamodel.NET.csproj @@ -4,7 +4,7 @@ Library Datamodel KeyValues2 - 0.10 + 1.0-beta enable MIT README.md @@ -57,7 +57,7 @@ - + - + diff --git a/Datamodel.NET/Datamodel.cs b/Datamodel.NET/Datamodel.cs index b23d53e..4e993b7 100644 --- a/Datamodel.NET/Datamodel.cs +++ b/Datamodel.NET/Datamodel.cs @@ -335,6 +335,7 @@ private static Datamodel Load_Internal(Stream stream, DeferredMode defer_mode reflectionParams.Namespace = templateType.Namespace!; } + reflectionParams.RootAssembly ??= templateType.Assembly; stream.Seek(0, SeekOrigin.Begin); var header = string.Empty; @@ -575,6 +576,12 @@ public AttributeList PrefixAttributes get; protected set; } + /// + /// Gets or sets the ID under which are stored when the encoding also writes them as an element, + /// as "keyvalues2" and "binary" version 9 do. Preserved across a load and save cycle. + /// + public Guid PrefixElementId { get; set; } = Guid.NewGuid(); + /// /// Gets all Elements owned by this Datamodel. Only Elements which are referenced by the Root element or one of its children are actually considered part of the Datamodel. /// diff --git a/Datamodel.NET/ICodec.cs b/Datamodel.NET/ICodec.cs index aeee3f2..3ca6260 100644 --- a/Datamodel.NET/ICodec.cs +++ b/Datamodel.NET/ICodec.cs @@ -49,6 +49,77 @@ public class ReflectionParams(bool attemptReflection = true, List? additio public string Assembly = string.Empty; public string Namespace = string.Empty; + + /// + /// Assembly of the root type passed to Load. Its generated is asked first. + /// + public Assembly? RootAssembly; + } + + /// + /// Resolves element class names to subclasses while decoding, through the + /// classes the ElementFactoryGenerator emits into every assembly that references this library. + /// + /// + /// 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. + /// + public sealed class ElementTypeResolver + { + private const string GeneratedFactoryTypeName = "ElementFactory"; + + private readonly ReflectionParams reflectionParams; + private readonly List factories = []; + + public ElementTypeResolver(ReflectionParams reflectionParams) + { + this.reflectionParams = reflectionParams; + + if (!reflectionParams.AttemptReflection) + { + return; + } + + var factoryTypes = new List(); + + if (reflectionParams.RootAssembly?.GetType(GeneratedFactoryTypeName) is Type rootFactory) + { + factoryTypes.Add(rootFactory); + } + + foreach (var factoryType in CodecUtilities.GetIElementFactoryClasses()) + { + if (!factoryTypes.Contains(factoryType)) + { + factoryTypes.Add(factoryType); + } + } + + foreach (var factoryType in factoryTypes) + { + if (Activator.CreateInstance(factoryType) is IElementFactory factory) + { + factories.Add(factory); + } + } + } + + /// + /// Constructs a new, unowned instance of the class registered for the given element class name, or null when no factory knows it. + /// + public Element? Construct(string className) + { + foreach (var factory in factories) + { + if (factory.GetClass(reflectionParams.Assembly, reflectionParams.Namespace, className) is Element element) + { + return element; + } + } + + return null; + } } @@ -191,9 +262,13 @@ public static void AddDeferredAttribute(Element elem, string key, long offset) elem.Add(key, offset); } - public static bool TryConstructCustomElement(IElementFactory elementFactory, ReflectionParams reflectionParams, Datamodel dataModel, string elem_class, string elem_name, Guid elem_id, out Element? elem) + /// + /// Constructs an element of the subclass registered for and adds it to the Datamodel. + /// + /// False when no subclass is registered for the class name, in which case a plain should be used. + public static bool TryConstructCustomElement(ElementTypeResolver resolver, Datamodel dataModel, string elem_class, string elem_name, Guid elem_id, out Element? elem) { - elem = (Element?)elementFactory.GetClass(reflectionParams.Assembly, reflectionParams.Namespace, elem_class); + elem = resolver.Construct(elem_class); if (elem is null) { @@ -201,16 +276,21 @@ public static bool TryConstructCustomElement(IElementFactory elementFactory, Ref } elem.ID = elem_id; - elem.Owner = dataModel; elem.Name = elem_name; elem.ClassName = elem_class; + elem.Owner = dataModel; return true; } - public static IEnumerable GetIElementFactoryClasses() + private static Type[]? elementFactoryClasses; + + /// + /// Finds every implementation in the loaded assemblies. The result is cached after the first call. + /// + public static IEnumerable GetIElementFactoryClasses() { - return AppDomain.CurrentDomain.GetAssemblies() + elementFactoryClasses ??= AppDomain.CurrentDomain.GetAssemblies() .SelectMany(assembly => { try @@ -219,12 +299,15 @@ public static bool TryConstructCustomElement(IElementFactory elementFactory, Ref } catch (ReflectionTypeLoadException ex) { - return ex.Types.Where(t => t != null); + return ex.Types.OfType(); } }) .Where(type => type.IsClass && !type.IsAbstract && - type.GetInterfaces().Contains(typeof(IElementFactory))); + type.GetInterfaces().Contains(typeof(IElementFactory))) + .ToArray(); + + return elementFactoryClasses; } } diff --git a/ElementFactoryGenerator/ElementFactory.cs b/ElementFactoryGenerator/ElementFactory.cs index 8abe6c8..bd4383d 100644 --- a/ElementFactoryGenerator/ElementFactory.cs +++ b/ElementFactoryGenerator/ElementFactory.cs @@ -39,11 +39,16 @@ private void Execute(SourceProductionContext context, (Compilation Left, Immutab var assemblies = new List(); + // 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 - public class ElementFactory : Datamodel.Codecs.IElementFactory + [global::System.CodeDom.Compiler.GeneratedCode("KeyValues2.ElementFactoryGenerator", "0.2.1")] + internal sealed class ElementFactory : Datamodel.Codecs.IElementFactory { public object? GetClass(string assembly, string nameSpace, string classname) { diff --git a/ElementFactoryGenerator/ElementFactoryGenerator.csproj b/ElementFactoryGenerator/ElementFactoryGenerator.csproj index 127b336..36250a2 100644 --- a/ElementFactoryGenerator/ElementFactoryGenerator.csproj +++ b/ElementFactoryGenerator/ElementFactoryGenerator.csproj @@ -3,7 +3,7 @@ netstandard2.0 latest - 0.2 + 0.2.1 enable true snupkg diff --git a/README.md b/README.md index 2ca213f..3f549c5 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,25 @@ class CMapRootElement : Element * Binary codec supports just-in-time attribute loading * Write your own codecs with the `ICodec` interface * Serialize and deserialize support for Datamodel.Element subclasses +* Prefix attributes (such as the `map_asset_references` of a vmap) survive a load and save cycle in both encodings +* Output laid out like Valve's own serializers: `binary` 9 stores the prefix attributes as an element right after the root, `keyvalues2` uses tab indentation and one array item per line + +## Typed elements + +`Datamodel.Load` gives every element whose class name matches a subclass of `Element` in the namespace of `T` that subclass. +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. + +How a subclass maps onto the file: + +* Every public property is an attribute. The attribute name is the property name, adjusted by `[LowercaseProperties]`, `[CamelCaseProperties]` or `[DMProperty]`. +* 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. ## Serialization diff --git a/Tests/Resources/prefabs/roundtrip_test_prefab1.vmap b/Tests/Resources/prefabs/roundtrip_test_prefab1.vmap new file mode 100644 index 0000000..7394402 Binary files /dev/null and b/Tests/Resources/prefabs/roundtrip_test_prefab1.vmap differ diff --git a/Tests/Resources/prefabs/roundtrip_test_prefab2.vmap b/Tests/Resources/prefabs/roundtrip_test_prefab2.vmap new file mode 100644 index 0000000..3368ff6 Binary files /dev/null and b/Tests/Resources/prefabs/roundtrip_test_prefab2.vmap differ diff --git a/Tests/Resources/prefabs/roundtrip_test_prefab3.vmap b/Tests/Resources/prefabs/roundtrip_test_prefab3.vmap new file mode 100644 index 0000000..9952ca9 Binary files /dev/null and b/Tests/Resources/prefabs/roundtrip_test_prefab3.vmap differ diff --git a/Tests/Resources/roundtrip_test.vmap b/Tests/Resources/roundtrip_test.vmap new file mode 100644 index 0000000..055dd75 Binary files /dev/null and b/Tests/Resources/roundtrip_test.vmap differ diff --git a/Tests/RoundTripTests.cs b/Tests/RoundTripTests.cs new file mode 100644 index 0000000..7c94376 --- /dev/null +++ b/Tests/RoundTripTests.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using NUnit.Framework; +using Datamodel; +using Tests.VMAP; +using DM = Datamodel.Datamodel; + +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); + + // 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 = + [ + "roundtrip_test.vmap", + Path.Combine("prefabs", "roundtrip_test_prefab1.vmap"), + Path.Combine("prefabs", "roundtrip_test_prefab2.vmap"), + Path.Combine("prefabs", "roundtrip_test_prefab3.vmap"), + ]; + + [Test, TestCaseSource(nameof(VmapFiles))] + public void 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)); + + Assert.That(Save(reloaded), Is.EqualTo(saved), "saving the reloaded datamodel must reproduce the same bytes"); + } + + [Test, TestCaseSource(nameof(VmapFiles))] + public void 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")); + + var reachable = new HashSet(); + Visit(dm.Root); + Assert.That(dm.AllElements.Count, Is.EqualTo(reachable.Count), "every element must be reachable from the root"); + + void Visit(Element? element) + { + if (element == null || !reachable.Add(element)) + return; + + foreach (var attr in element) + { + if (attr.Value is Element child) + Visit(child); + else if (attr.Value is IEnumerable children) + foreach (var arrayChild in children) + Visit(arrayChild); + } + } + } + + [Test, TestCaseSource(nameof(VmapFiles))] + public void 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()); + + // typed elements write their class properties first, in declaration order, so only the set of attributes is compared + AssertEquivalent(original, typed, orderSensitive: false); + + var saved = Save(typed); + using var reloaded = DM.Load(saved); + AssertEquivalent(original, reloaded, orderSensitive: false); + + Assert.That(Save(reloaded), Is.EqualTo(saved)); + } + + [Test, TestCaseSource(nameof(VmapFiles))] + public void KeyValues2_Untyped(string file) + { + using var original = DM.Load(Resource(file), Datamodel.Codecs.DeferredMode.Disabled); + + using var text = new MemoryStream(); + original.Save(text, "keyvalues2", 4); + + 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)); + + using var text2 = new MemoryStream(); + reloaded.Save(text2, "keyvalues2", 4); + Assert.That(text2.ToArray(), Is.EqualTo(text.ToArray())); + } + + [Test] + public void 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 + using var dm = new DM("test", 1); + dm.PrefixElementId = new Guid("00000000-0000-0000-0000-000000000001"); + dm.PrefixAttributes["refs"] = new StringArray(["a", "b"]); + + var root = new Element(dm, "root", new Guid("00000000-0000-0000-0000-000000000002"), "DmeRoot"); + var child = new Element(dm, string.Empty, new Guid("00000000-0000-0000-0000-000000000003"), "DmeChild"); + var shared = new Element(dm, string.Empty, new Guid("00000000-0000-0000-0000-000000000004"), "DmeShared"); + var item = new Element(dm, string.Empty, new Guid("00000000-0000-0000-0000-000000000005"), "DmeItem"); + dm.Root = root; + + child["value"] = 1; + shared["flag"] = true; + root["child"] = child; + root["shared"] = shared; + root["list"] = new ElementArray([shared, item]); + root["empty"] = new IntArray(); + root["nothing"] = null; + + using var text = new MemoryStream(); + dm.Save(text, "keyvalues2", 4); + + var expected = string.Join("\n", + [ + "", + "\"$prefix_element$\"", + "{", + "\t\"id\" \"elementid\" \"00000000-0000-0000-0000-000000000001\"", + "\t\"refs\" \"string_array\" ", + "\t[", + "\t\t\"a\",", + "\t\t\"b\"", + "\t]", + "}", + "\"DmeRoot\"", + "{", + "\t\"id\" \"elementid\" \"00000000-0000-0000-0000-000000000002\"", + "\t\"name\" \"string\" \"root\"", + "\t\"child\" \"DmeChild\"", + "\t{", + "\t\t\"id\" \"elementid\" \"00000000-0000-0000-0000-000000000003\"", + "\t\t\"value\" \"int\" \"1\"", + "\t}", + "", + "\t\"shared\" \"element\" \"00000000-0000-0000-0000-000000000004\"", + "\t\"list\" \"element_array\" ", + "\t[", + "\t\t\"element\" \"00000000-0000-0000-0000-000000000004\",", + "\t\t\"DmeItem\"", + "\t\t{", + "\t\t\t\"id\" \"elementid\" \"00000000-0000-0000-0000-000000000005\"", + "\t\t}", + "\t]", + "\t\"empty\" \"int_array\" ", + "\t[", + "\t]", + "\t\"nothing\" \"element\" \"\"", + "}", + "", + "\"DmeShared\"", + "{", + "\t\"id\" \"elementid\" \"00000000-0000-0000-0000-000000000004\"", + "\t\"flag\" \"bool\" \"1\"", + "}", + "", + "", + ]); + + Assert.That(Datamodel.Datamodel.TextEncoding.GetString(text.ToArray()), Is.EqualTo(expected)); + } + + [Test] + public void KeyValues2_FloatFormat() + { + using var dm = new DM("test", 1); + dm.Root = new Element(dm, "root"); + dm.Root["position"] = new Vector3(-270.11304f, -233.07538f, 562.09106f); + dm.Root["whole"] = 40f; + dm.Root["negative"] = -1f; + + using var text = new MemoryStream(); + 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\"")); + } + + [Test] + public void Binary_PrefixAttributes() + { + using var dm = new DM("vmap", 29); + dm.PrefixAttributes["map_asset_references"] = new StringArray(["a.vmdl", "b.vmat"]); + dm.PrefixAttributes["thumbnail_format"] = "jpg"; + dm.PrefixAttributes["thumbnail"] = new byte[] { 1, 2, 3 }; + dm.Root = new Element(dm, "root"); + dm.Root["hello"] = "world"; + + 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")); + } + + [Test] + public void Typed_PropertyTypeMismatchIsReported() + { + 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")); + } + + static byte[] Save(DM dm) + { + using var ms = new MemoryStream(); + dm.Save(ms, "binary", 9); + return ms.ToArray(); + } + + static void AssertEquivalent(DM expected, DM actual, bool orderSensitive) + { + AssertAttributesEquivalent(expected.PrefixAttributes, actual.PrefixAttributes, "prefix", orderSensitive); + + 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"); + + 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}"); + + if (!expectedElement.Stub) + { + AssertAttributesEquivalent(expectedElement, actualElement, $"{expectedElement.ClassName} {id}", orderSensitive); + } + } + } + + static void AssertAttributesEquivalent(AttributeList expected, AttributeList actual, string context, bool orderSensitive) + { + var expectedAttributes = expected.GetAllAttributesForSerialization().ToArray(); + var actualAttributes = actual.GetAllAttributesForSerialization().ToArray(); + + var expectedNames = expectedAttributes.Select(a => a.Key); + var actualNames = actualAttributes.Select(a => a.Key); + + if (orderSensitive) + { + Assert.That(actualNames, Is.EqualTo(expectedNames), $"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}"); + } + + var actualByName = actualAttributes.ToDictionary(a => a.Key, a => a.Value); + + foreach (var (name, expectedValue) in expectedAttributes) + { + AssertValueEquivalent(expectedValue, actualByName[name], $"{context}.{name}"); + } + } + + static void AssertValueEquivalent(object? expected, object? actual, string context) + { + if (expected is null || actual is null) + { + Assert.That(actual, Is.EqualTo(expected), 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); + break; + case byte[] expectedBytes: + Assert.That(actual, Is.EqualTo(expectedBytes), context); + break; + case IList expectedList: + Assert.That(actual.GetType(), Is.EqualTo(expected.GetType()), $"type of {context}"); + var actualList = (IList)actual; + Assert.That(actualList.Count, Is.EqualTo(expectedList.Count), $"count of {context}"); + for (var i = 0; i < expectedList.Count; i++) + { + AssertValueEquivalent(expectedList[i], actualList[i], $"{context}[{i}]"); + } + break; + default: + Assert.That(actual.GetType(), Is.EqualTo(expected.GetType()), $"type of {context}"); + + if (FloatTolerance > 0 && TryGetComponents(expected, out var expectedComponents) && TryGetComponents(actual, out var actualComponents)) + { + Assert.That(actualComponents, Is.EqualTo(expectedComponents).Within(FloatTolerance), context); + break; + } + + Assert.That(actual, Is.EqualTo(expected), 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 + { + float f => [f], + Vector2 v => [v.X, v.Y], + Vector3 v => [v.X, v.Y, v.Z], + Vector4 v => [v.X, v.Y, v.Z, v.W], + Quaternion q => [q.X, q.Y, q.Z, q.W], + QAngle a => [a.Pitch, a.Yaw, a.Roll], + _ => [], + }; + + return components.Length > 0; + } + } +} diff --git a/Tests/Tests.cs b/Tests/Tests.cs index 7dd127d..599a671 100644 --- a/Tests/Tests.cs +++ b/Tests/Tests.cs @@ -362,34 +362,29 @@ private static void Validate_Vmap_Reflection(Datamodel.Datamodel unserialisedVma CMapRootElement root = (CMapRootElement)unserialisedVmap.Root; - Assert.AreEqual(typeof(CMapWorld), root.world.GetType()); + Assert.AreEqual(typeof(CMapWorld), root.World.GetType()); - var world = root.world; + var world = root.World; - var props = world.children.Where(i => i.ClassName == "CMapEntity").OfType().ToList(); + var props = world.GetChildren().ToList(); + Assert.That(props, Is.Not.Empty); + Assert.That(props[0].GetEntityClassName(), Is.Not.Null); - var prop = props[0]; - var propclass = prop.ClassName; - var proptype = prop.GetType(); - var entityprop = prop; - - - var propProperties = props[0].EntityProperties; - var classname = propProperties.Get("classname"); - - var meshes = world.children.Where(i => i.ClassName == "CMapMesh").OfType().ToList(); + var meshes = world.GetChildren().ToList(); var mesh = meshes[0]; - var vertexData = mesh.meshData.vertexData; + var vertexData = mesh.MeshData.VertexData; - Assert.AreEqual(vertexData.size, 8); - Assert.AreEqual(vertexData.streams[0]["semanticName"], "position"); + Assert.AreEqual(vertexData.Size, 8); + Assert.AreEqual(vertexData.Streams[0]["semanticName"], "position"); - var typedPolygonMeshData = (CDmePolygonMeshDataStream)vertexData.streams[0]; - Assert.AreEqual(typedPolygonMeshData.semanticName, "position"); + var typedPolygonMeshData = (CDmePolygonMeshDataStream)vertexData.Streams[0]; + Assert.AreEqual(typedPolygonMeshData.SemanticName, "position"); - var typedPolygonMeshDataStream = typedPolygonMeshData.data as Vector3Array; + var typedPolygonMeshDataStream = typedPolygonMeshData.Data as Vector3Array; Assert.IsNotNull(typedPolygonMeshDataStream); + Assert.That(vertexData.GetStreamData("position"), Is.SameAs(typedPolygonMeshDataStream)); + Assert.That(mesh.MeshData.FaceVertexData.GetStreamData("normal"), Is.Not.Null); Assert.That(unserialisedVmap.PrefixAttributes["map_asset_references"], Is.Not.Empty); @@ -409,7 +404,6 @@ private static void Validate_Vmap_Reflection(Datamodel.Datamodel unserialisedVma Assert.That(elem, Is.Not.TypeOf(), $"Found object {elem.ID} {elem.ClassName} that is still an Element type."); } - } [Test] diff --git a/Tests/ValveMap.cs b/Tests/ValveMap.cs index 9037024..349e977 100644 --- a/Tests/ValveMap.cs +++ b/Tests/ValveMap.cs @@ -1,389 +1,1914 @@ -using Datamodel.Format; +using System; +using System.Collections; +using System.Collections.Generic; using System.Numerics; +using System.Diagnostics.CodeAnalysis; +using Datamodel.Format; using DMElement = Datamodel.Element; namespace Tests.VMAP; -#nullable enable +/// +/// Shared justification for helpers that must be methods: every public property of an element class is written to the file as an attribute. +/// +internal static class ValveMapSchema +{ + public const string SerializedPropertiesJustification = "Public properties of an element are serialized as attributes"; +} /// /// Valve Map (VMAP) format version 29. /// -internal class CMapRootElement : DMElement +/// +/// Every class in this file maps one to one onto an element class of the file format, so a map can be loaded +/// through the ValveMapFile class of ValveResourceFormat, inspected and edited through these properties, and written back. +/// Attributes of a file that no property claims are kept on the element and written back unchanged. +/// +[LowercaseProperties] +public class CMapRootElement : DMElement { - public bool isprefab { get; set; } - public int editorbuild { get; set; } = 8600; - public int editorversion { get; set; } = 400; - public bool showgrid { get; set; } = true; - public int snaprotationangle { get; set; } = 15; - public float gridspacing { get; set; } = 64; - public bool show3dgrid { get; set; } = true; + /// + /// Whether this file is a prefab rather than a standalone map. + /// + public bool IsPrefab { get; set; } + + /// + /// Hammer build number that wrote the file. + /// + public int EditorBuild { get; set; } = 8600; + + /// + /// Map format version. + /// + public int EditorVersion { get; set; } = 400; + + /// + /// Whether the 2D grid is drawn. + /// + public bool ShowGrid { get; set; } = true; + + /// + /// Rotation snap in degrees. + /// + public int SnapRotationAngle { get; set; } = 15; + + /// + /// Translation snap in world units. + /// + public float GridSpacing { get; set; } = 64; + + /// + /// Whether the 3D grid is drawn. + /// + public bool Show3DGrid { get; set; } = true; + + /// + /// Path to the item file this map uses, if any. + /// [DMProperty(name: "itemFile")] - public string Itemfile { get; set; } = string.Empty; - public CStoredCamera defaultcamera { get; set; } = []; + public string ItemFile { get; set; } = string.Empty; + + /// + /// Camera Hammer opens the map with. + /// + public CStoredCamera DefaultCamera { get; init; } = []; + + /// + /// Saved cameras. + /// [DMProperty(name: "3dcameras")] - public CStoredCameras Cameras { get; set; } = []; - public CMapWorld world { get; set; } = []; + public CStoredCameras Cameras { get; init; } = []; + + /// + /// Root of the map tree. + /// + public CMapWorld World { get; init; } = []; + + /// + /// Per node hidden state. Hammer writes this attribute with the misspelled name. + /// [DMProperty(name: "visbility")] - public CVisibilityMgr Visibility { get; set; } = []; + public CVisibilityMgr Visibility { get; init; } = []; + + /// + /// Map variables and their values. + /// [DMProperty(name: "mapVariables")] - public CMapVariableSet MapVariables { get; set; } = []; + public CMapVariableSet MapVariables { get; init; } = []; + + /// + /// Root of the selection set tree. + /// [DMProperty(name: "rootSelectionSet")] - public CMapSelectionSet RootSelectionSet { get; set; } = []; + public CMapSelectionSet RootSelectionSet { get; init; } = []; + + /// + /// Mesh snapshots the map references. + /// [DMProperty(name: "m_ReferencedMeshSnapshots")] - public Datamodel.ElementArray ReferencedMeshSnapshots { get; set; } = []; + public Datamodel.ElementArray ReferencedMeshSnapshots { get; init; } = []; + + /// + /// Whether the cordon is active. + /// [DMProperty(name: "m_bIsCordoning")] public bool IsCordoning { get; set; } + + /// + /// Whether cordon bounds are drawn. + /// [DMProperty(name: "m_bCordonsVisible")] public bool CordonsVisible { get; set; } + + /// + /// Per node instance data. + /// [DMProperty(name: "nodeInstanceData")] - public Datamodel.ElementArray NodeInstanceData { get; set; } = []; + public Datamodel.ElementArray NodeInstanceData { get; init; } = []; +} + +/// +/// A saved 3D viewport camera. +/// +[LowercaseProperties] +public class CStoredCamera : DMElement +{ + /// + /// Where the camera sits. + /// + public Vector3 Position { get; set; } = new Vector3(0, -1000, 1000); + + /// + /// What the camera points at. + /// + public Vector3 LookAt { get; set; } +} + +/// +/// The saved cameras of a map, and which one is active. +/// +[LowercaseProperties] +public class CStoredCameras : DMElement +{ + /// + /// Index into , -1 when none is active. + /// + [DMProperty(name: "activecamera")] + public int ActiveCameraIndex { get; set; } = -1; + + /// + /// List of elements. + /// + public Datamodel.ElementArray Cameras { get; init; } = []; +} + +/// +/// Base of everything that appears in the map tree: a transform, an id, and child nodes. +/// +[CamelCaseProperties] +public abstract class MapNode : DMElement +{ + /// + /// Position of the node, relative to its parent. + /// + public Vector3 Origin { get; set; } + + /// + /// Rotation of the node, relative to its parent. + /// + public Datamodel.QAngle Angles { get; set; } + + /// + /// Scale of the node, relative to its parent. + /// + public Vector3 Scales { get; set; } = new Vector3(1, 1, 1); + + /// + /// Id of the node within the map, referenced by and selection sets. + /// + public int NodeID { get; set; } + + /// + /// Id the node keeps across prefab and instance boundaries. + /// + public ulong ReferenceID { get; set; } + + /// + /// Child nodes parented to this one. + /// + public Datamodel.ElementArray Children { get; init; } = []; + + /// + /// Whether the node is stripped at compile time. + /// + public bool EditorOnly { get; set; } + + /// + /// Whether the node is hidden in Hammer. + /// + [DMProperty(name: "force_hidden")] + public bool ForceHidden { get; set; } + + /// + /// Whether Hammer refuses to move the node. + /// + public bool TransformLocked { get; set; } + + /// + /// Entity keys driven by a map variable, parallel to . + /// + public Datamodel.StringArray VariableTargetKeys { get; init; } = []; + + /// + /// Map variables driving . + /// + public Datamodel.StringArray VariableNames { get; init; } = []; + + /// + /// Pins the node's transform to another node, a plain "DmElement" holding the properties of . + /// + public DMElement TransformPin { get; init; } = new CMapTransformPin(); + + /// + /// Name of the custom vis group the node belongs to, empty for none. + /// + public string CustomVisGroup { get; set; } = string.Empty; + + /// + /// Seed Hammer uses for anything random about this node, such as smart prop evaluation. + /// + public int RandomSeed { get; set; } + + /// + /// Enumerates the child nodes of the given type, in order. + /// + /// Node class to filter by. + public IEnumerable GetChildren() where T : MapNode + { + foreach (var child in Children) + { + if (child is T typed) + { + yield return typed; + } + } + } +} + +/// +/// References another map file and places its contents at this node. +/// +[CamelCaseProperties] +public class CMapPrefab : MapNode +{ + /// + /// Output plugs of the prefab, one per entity IO connection. + /// + public DmePlugList RelayPlugData { get; init; } = []; + + /// + /// List of elements, one per entity IO connection. + /// + public Datamodel.ElementArray ConnectionsData { get; init; } = []; + + /// + /// The loaded contents of the prefab, null in a saved file. + /// + public DMElement? Target { get; init; } + + /// + /// Map variables of the prefab this node overrides, parallel to . + /// + public Datamodel.StringArray VariableOverrideNames { get; init; } = []; + + /// + /// Values for . + /// + public Datamodel.StringArray VariableOverrideValues { get; init; } = []; + + /// + /// Path to the map file this prefab pulls in. + /// + public string TargetMapPath { get; set; } = string.Empty; + + /// + /// Name given to the prefab instance. + /// + public string TargetName { get; set; } = string.Empty; + + /// + /// Whether entity names inside the prefab are prefixed to keep them unique. + /// + public bool FixupEntityNames { get; set; } = true; + + /// + /// Whether is used as the prefix for entity names instead of a generated one. + /// + public bool UseTargetNameAsPrefix { get; set; } + + /// + /// Whether the prefab still loads when it sits inside another prefab. + /// + public bool LoadIfNested { get; set; } = true; + + /// + /// Whether the prefab becomes an entity at runtime. + /// + public bool PrefabRuntimeEntity { get; set; } + + /// + /// Whether the prefab is spawned at runtime instead of merged at compile time. + /// + public bool LoadAtRuntime { get; set; } + + /// + /// Tint applied to everything in the prefab. + /// + public Datamodel.Color TintColor { get; set; } = new Datamodel.Color(255, 255, 255, 255); + + /// + /// Whether the prefab contents are left out of visibility computation. + /// + [DMProperty(name: "visexclude")] + public bool VisExclude { get; set; } +} + +/// +/// Base of every map node that carries entity key values and entity IO. +/// +[CamelCaseProperties] +public abstract class BaseEntity : MapNode +{ + /// + /// Output plugs this entity fires through, one per entity IO connection. + /// + public DmePlugList RelayPlugData { get; init; } = []; + + /// + /// List of elements, one per entity IO connection. + /// + public Datamodel.ElementArray ConnectionsData { get; init; } = []; + + /// + /// The entity key values, including "classname". + /// + [DMProperty(name: "entity_properties")] + public EditGameClassProps EntityProperties { get; init; } = []; + + /// + /// The "classname" key value, or null when the entity has none. + /// + public string? GetEntityClassName() => EntityProperties.TryGetValue("classname", out var value) ? value as string : null; + + /// + /// Sets one entity key value and returns this entity. + /// + /// Key to set. + /// Value to set it to. + public BaseEntity WithProperty(string name, string value) + { + EntityProperties[name] = value; + return this; + } + + /// + /// Sets several entity key values and returns this entity. + /// + /// Key value pairs to set. + public BaseEntity WithProperties(params (string name, string value)[] properties) + { + foreach (var (name, value) in properties) + { + EntityProperties[name] = value; + } + + return this; + } + + /// + /// Sets the "classname" key value and returns this entity. + /// + /// Entity class name. + public BaseEntity WithClassName(string className) + => WithProperty("classname", className); +} + +/// +/// The output plugs of an entity, stored as parallel arrays. +/// +[CamelCaseProperties] +public class DmePlugList : DMElement +{ + /// + /// Plug names. + /// + public Datamodel.StringArray Names { get; init; } = []; + + /// + /// Data type of each plug. + /// + public Datamodel.IntArray DataTypes { get; init; } = []; + + /// + /// Kind of each plug, input or output. + /// + public Datamodel.IntArray PlugTypes { get; init; } = []; + + /// + /// Description of each plug. + /// + public Datamodel.StringArray Descriptions { get; init; } = []; } +/// +/// One entity IO connection: an output firing an input on a target. +/// +[CamelCaseProperties] +public class DmeConnectionData : DMElement +{ + /// + /// Output that fires, for example "OnTrigger". + /// + public string OutputName { get; set; } = string.Empty; + + /// + /// How resolves to entities. + /// + public int TargetType { get; set; } + + /// + /// Entities the output fires at. + /// + public string TargetName { get; set; } = string.Empty; + + /// + /// Input fired on the target, for example "Enable". + /// + public string InputName { get; set; } = string.Empty; + + /// + /// Parameter passed to the input, overriding the output's own. + /// + public string OverrideParam { get; set; } = string.Empty; + + /// + /// Delay before the input fires, in seconds. + /// + public float Delay { get; set; } + + /// + /// How often the connection may fire, -1 for unlimited. + /// + public int TimesToFire { get; set; } = -1; +} + +/// +/// A string->string dictionary. This stores entity KeyValues. +/// +public class EditGameClassProps : DMElement +{ +} + +/// +/// The world entity. +/// +[CamelCaseProperties] +public class CMapWorld : BaseEntity +{ + /// + /// Next free decal id, handed out as decals are placed. + /// + public int NextDecalID { get; set; } + + /// + /// Whether entity names are prefixed to keep them unique across prefabs. + /// + public bool FixupEntityNames { get; set; } = true; + + /// + /// What the map is for, "standard" for a playable map. + /// + public string MapUsageType { get; set; } = "standard"; + + /// + /// Initializes a new instance of the class with classname "worldspawn". + /// + public CMapWorld() + { + EntityProperties["classname"] = "worldspawn"; + } +} + +/// +/// Per node hidden state, as two parallel arrays. +/// +[CamelCaseProperties] +public class CVisibilityMgr : MapNode +{ + /// + /// The nodes whose visibility is tracked. + /// + public Datamodel.ElementArray Nodes { get; init; } = []; + + /// + /// Hidden flags, one per entry of . 0 is visible, 1 hidden through a selection set, higher values quick hidden. + /// + public Datamodel.IntArray HiddenFlags { get; init; } = []; + + /// + /// Returns the hidden flags of a node, 0 when the node is visible or not tracked. + /// + /// Node to look up. + public int GetHiddenFlags(DMElement node) + { + var count = Math.Min(Nodes.Count, HiddenFlags.Count); + + for (var i = 0; i < count; i++) + { + if (Nodes[i]?.ID == node.ID) + { + return HiddenFlags[i]; + } + } + + return 0; + } + + /// + /// Whether a node is hidden in Hammer. + /// + /// Node to look up. + public bool IsHidden(DMElement node) => GetHiddenFlags(node) != 0; +} + +/// +/// Map variables, stored as parallel arrays of name, value, type and type parameters. +/// +[CamelCaseProperties] +public class CMapVariableSet : DMElement +{ + /// + /// Variable names. + /// + public Datamodel.StringArray VariableNames { get; init; } = []; + + /// + /// Variable values. + /// + public Datamodel.StringArray VariableValues { get; init; } = []; + + /// + /// Variable type names. + /// + public Datamodel.StringArray VariableTypeNames { get; init; } = []; + + /// + /// Parameters of the variable types, such as the options of a choice. + /// + public Datamodel.StringArray VariableTypeParameters { get; init; } = []; + + /// + /// Groups the choice variables are presented in. + /// + [DMProperty(name: "m_ChoiceGroups")] + public Datamodel.ElementArray ChoiceGroups { get; init; } = []; + + /// + /// Group each variable is shown under, parallel to . + /// + public Datamodel.StringArray VariableGroupNames { get; init; } = []; + + /// + /// Display order of the variables and choice groups. + /// + public Datamodel.IntArray VariableAndChoiceOrder { get; init; } = []; +} + +/// +/// A group of map variables presented as one choice. +/// +public class CMapVariableChoiceGroup : DMElement +{ + /// + /// Names of the variables the choice drives. + /// + [DMProperty(name: "m_ChoiceVariables")] + public Datamodel.StringArray ChoiceVariables { get; init; } = []; + + /// + /// The choices, each a plain element holding the values of . + /// + [DMProperty(name: "m_Choices")] + public Datamodel.ElementArray Choices { get; init; } = []; + + /// + /// Name of the active choice, empty for none. + /// + [DMProperty(name: "m_ActiveValue")] + public string ActiveValue { get; set; } = string.Empty; + + /// + /// Name shown for the group. + /// + [DMProperty(name: "m_GroupName")] + public string GroupName { get; set; } = string.Empty; +} + +/// +/// A named selection of map nodes, as shown in Hammer's selection set tree. +/// +[CamelCaseProperties] +public class CMapSelectionSet : DMElement +{ + /// + /// Nested selection sets. + /// + public Datamodel.ElementArray Children { get; init; } = []; + + /// + /// Name shown in Hammer. + /// + public string SelectionSetName { get; set; } = string.Empty; + + /// + /// What this set selects: a for whole nodes, or a + /// , or for mesh components. + /// + public DMElement SelectionSetData { get; init; } = new CObjectSelectionSetDataElement(); + + /// + /// The selection data when this set selects whole nodes, otherwise null. + /// + [SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = ValveMapSchema.SerializedPropertiesJustification)] + public CObjectSelectionSetDataElement? GetObjectSelection() => SelectionSetData as CObjectSelectionSetDataElement; + + /// + /// The selection data when this set selects faces, otherwise null. + /// + [SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = ValveMapSchema.SerializedPropertiesJustification)] + public CFaceSelectionSetDataElement? GetFaceSelection() => SelectionSetData as CFaceSelectionSetDataElement; + + /// + /// The nodes this set selects. + /// + /// Thrown when this is a face selection set. + public Datamodel.ElementArray GetSelectedObjects() + => GetObjectSelection()?.SelectedObjects ?? throw new InvalidOperationException($"Selection set '{SelectionSetName}' does not select objects."); + + /// + /// Enumerates this set and every set nested under it, depth first. + /// + public IEnumerable EnumerateSelectionSets() + { + yield return this; + + foreach (var child in Children) + { + if (child is not CMapSelectionSet childSet) + { + continue; + } + + foreach (var nested in childSet.EnumerateSelectionSets()) + { + yield return nested; + } + } + } + + /// + /// Initializes a new instance of the class. + /// + public CMapSelectionSet() { } + + /// + /// Initializes a new instance of the class with a name. + /// + /// Name shown in Hammer. + public CMapSelectionSet(string name) + { + SelectionSetName = name; + } +} + +/// +/// The map nodes a selects. +/// +[CamelCaseProperties] +public class CObjectSelectionSetDataElement : DMElement +{ + /// + /// The selected nodes. + /// + public Datamodel.ElementArray SelectedObjects { get; init; } = []; +} + +/// +/// The mesh faces a selects. +/// +[CamelCaseProperties] +public class CFaceSelectionSetDataElement : DMElement +{ + /// + /// The nodes that own the selected faces. + /// + public Datamodel.ElementArray Meshes { get; init; } = []; + + /// + /// Face indices into the meshes of . + /// + public Datamodel.IntArray Faces { get; init; } = []; +} + +/// +/// The mesh edges a selects. +/// +[CamelCaseProperties] +public class CEdgeSelectionSetDataElement : DMElement +{ + /// + /// Half edge indices into the meshes of . + /// + public Datamodel.IntArray Edges { get; init; } = []; + + /// + /// The nodes that own the selected edges. + /// + public Datamodel.ElementArray Meshes { get; init; } = []; +} + +/// +/// The mesh vertices a selects. +/// +[CamelCaseProperties] +public class CVertexSelectionSetDataElement : DMElement +{ + /// + /// Vertex indices into the meshes of . + /// + public Datamodel.IntArray Vertices { get; init; } = []; + + /// + /// The nodes that own the selected vertices. + /// + public Datamodel.ElementArray Meshes { get; init; } = []; +} + +/// +/// A point or brush entity placed in the map. +/// +[CamelCaseProperties] +public class CMapEntity : BaseEntity +{ + /// + /// Surface normal the entity was dropped onto when it was placed. + /// + public Vector3 HitNormal { get; set; } + + /// + /// Whether the entity was generated by a tool rather than placed by hand. + /// + public bool IsProceduralEntity { get; set; } + + /// + /// Returns the vertex paint data of a prop entity, or null when it has none. + /// Hammer only writes the "extra_vertex_data" attribute on painted props, so it is not a class property. + /// + [SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = ValveMapSchema.SerializedPropertiesJustification)] + public CDmExtraVertexData? GetExtraVertexData() + => TryGetValue("extra_vertex_data", out var value) ? value as CDmExtraVertexData : null; +} + +/// +/// Places another map group into the map with its own transform and tint. +/// +[CamelCaseProperties] +public class CMapInstance : MapNode +{ + /// + /// Output plugs of the instance, one per entity IO connection. + /// + public DmePlugList RelayPlugData { get; init; } = []; + + /// + /// List of elements, one per entity IO connection. + /// + public Datamodel.ElementArray ConnectionsData { get; init; } = []; + + /// + /// A target to instance. With custom tint and transform. + /// + public DMElement? Target { get; init; } + + /// + /// The instanced group, or null when is unset or not a group. + /// + [SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = ValveMapSchema.SerializedPropertiesJustification)] + public CMapGroup? GetTargetGroup() => Target as CMapGroup; + + /// + /// Tint applied to everything in the instance. + /// + public Datamodel.Color TintColor { get; set; } = new Datamodel.Color(255, 255, 255, 255); + + /// + /// Whether the instance contents are left out of visibility computation. + /// + [DMProperty(name: "visexclude")] + public bool VisExclude { get; set; } +} + +/// +/// Groups child nodes under one selectable node. Also the target of a . +/// +[CamelCaseProperties] +public class CMapGroup : MapNode +{ + /// + /// How the group deforms its children when it is scaled or sheared. + /// + public int DeformationMode { get; set; } +} + +/// +/// A named world layer, which is a map group that compiles into its own layer. +/// +[CamelCaseProperties] +public class CMapWorldLayer : CMapGroup +{ + /// + /// Name of the layer. + /// + public string WorldLayerName { get; set; } = string.Empty; +} + +/// +/// A mesh authored in Hammer, with its render, lighting and physics settings. +/// +[CamelCaseProperties] +public class CMapMesh : MapNode +{ + /// + /// Cubemap this mesh samples, empty to pick automatically. + /// + public string CubeMapName { get; set; } = string.Empty; + + /// + /// Light group this mesh belongs to. + /// + public string LightGroup { get; set; } = string.Empty; + + /// + /// Whether the mesh is left out of visibility computation. + /// + [DMProperty(name: "visexclude")] + public bool VisExclude { get; set; } + + /// + /// Whether the mesh renders in the dynamic pass. + /// + [DMProperty(name: "renderwithdynamic")] + public bool RenderWithDynamic { get; set; } + + /// + /// Whether height displacement is skipped for this mesh. + /// + public bool DisableHeightDisplacement { get; set; } + + /// + /// Distance at which the mesh starts fading out, -1 to never fade. + /// + [DMProperty(name: "fademindist")] + public float FadeMinDist { get; set; } = -1; + + /// + /// Distance at which the mesh is fully faded out. + /// + [DMProperty(name: "fademaxdist")] + public float FadeMaxDist { get; set; } + + /// + /// Whether the mesh takes part in baked lighting. + /// + [DMProperty(name: "bakelighting")] + public bool BakeLighting { get; set; } = true; + + /// + /// Whether light probes are precomputed around the mesh. + /// + [DMProperty(name: "precomputelightprobes")] + public bool PrecomputeLightProbes { get; set; } = true; + + /// + /// Whether the mesh appears in cubemap renders. + /// + public bool RenderToCubemaps { get; set; } = true; + + /// + /// Shadow casting mode, 0 to cast shadows. + /// + public int DisableShadows { get; set; } + + /// + /// Angle below which adjacent faces are shaded smooth, in degrees. + /// + public float SmoothingAngle { get; set; } = 40f; + + /// + /// Tint applied to the mesh. + /// + public Datamodel.Color TintColor { get; set; } = new Datamodel.Color(255, 255, 255, 255); + + /// + /// Render alpha, 0 to 255. + /// + [DMProperty(name: "renderAmt")] + public int RenderAmount { get; set; } = 255; + + /// + /// Physics model to build for the mesh. + /// + public string PhysicsType { get; set; } = "default"; + + /// + /// Collision group of the mesh. + /// + public string PhysicsGroup { get; set; } = string.Empty; + + /// + /// Collision categories the mesh counts as. + /// + public string PhysicsInteractsAs { get; set; } = string.Empty; + + /// + /// Collision categories the mesh collides with. + /// + public string PhysicsInteractsWith { get; set; } = string.Empty; + + /// + /// Collision categories the mesh never collides with. + /// + public string PhysicsInteractsExclude { get; set; } = string.Empty; + + /// + /// The geometry itself. + /// + public CDmePolygonMesh MeshData { get; init; } = []; + + /// + /// Whether the mesh occludes what is behind it. + /// + public bool UseAsOccluder { get; set; } + + /// + /// Whether overrides the default simplification. + /// + public bool PhysicsSimplificationOverride { get; set; } + + /// + /// Error the physics simplification is allowed to introduce. + /// + public float PhysicsSimplificationError { get; set; } + + /// + /// Whether emissive materials on the mesh light the scene when baking. + /// + public bool EmissiveLightingEnabled { get; set; } = true; + + /// + /// Multiplier on the emissive light the mesh contributes when baking. + /// + public float EmissiveLightingBoost { get; set; } = 1f; + + /// + /// Whether the mesh only exists to affect baked lighting and is not rendered. + /// + public bool LightingDummy { get; set; } + + /// + /// Whether both sides of the mesh receive baked lighting. + /// + public bool BakeLightDoubleSided { get; set; } + + /// + /// Whether the compiler must not merge this mesh with others. + /// + [DMProperty(name: "disablemerging")] + public bool DisableMerging { get; set; } + + /// + /// Whether the compiler keeps the vertices as authored instead of optimizing them. + /// + [DMProperty(name: "keep_vertices")] + public bool KeepVertices { get; set; } + + /// + /// Collision property overriding the one of the materials, empty for none. + /// + public string PhysicsCollisionProperty { get; set; } = string.Empty; + + /// + /// Detail layers whose geometry is included in this mesh's physics. + /// + public Datamodel.ElementArray PhysicsIncludedDetailLayers { get; init; } = []; + + /// + /// Detail layers whose geometry is left out of this mesh's physics. + /// + public Datamodel.ElementArray PhysicsMissingDetailLayers { get; init; } = []; +} + +/// +/// A decal which uses its own hammer editable mesh to project onto geometry. +/// +[CamelCaseProperties] +public class CMapStaticOverlay : CMapMesh +{ + /// + /// Node ids of the nodes the overlay projects onto. + /// + public Datamodel.IntArray ProjectionTargets { get; init; } = []; + + /// + /// Order the overlay is drawn in where overlays stack, higher on top. + /// + public int RenderOrder { get; set; } + + /// + /// Whether the overlay is left out at low quality settings. + /// + public bool DisabledInLowQuality { get; set; } + + /// + /// Whether the overlay shades with the normals of the surface under it rather than its own. + /// + public bool UseBaseNormals { get; set; } + + /// + /// How far from its mesh the overlay projects. + /// + public float ProjectionFar { get; set; } = 128f; + + /// + /// Adjustments applied to the decal material, a plain "DmElement" holding the properties of . + /// + [DMProperty(name: "MaterialAdjustmentParamsStruct")] + public DMElement MaterialAdjustmentParamsStruct { get; init; } = new CMapOverlayMaterialAdjustmentParams(); + + /// + /// Whether the overlay also lands on faces turned away from it. + /// + public bool ProjectOnBackFaces { get; set; } + + /// + /// Angle from the projection direction beyond which a face counts as facing away, in degrees. + /// + public float BackFacingAngle { get; set; } = 90f; + + /// + /// What the overlay projects onto: everything (0), world geometry (1), models (2) or its + /// (3). + /// + public int ProjectionMode { get; set; } +} + +/// +/// The material adjustments of a . +/// +public class CMapOverlayMaterialAdjustmentParams : DMElement +{ + /// + /// Initializes a new instance of the class with Hammer's defaults. + /// + public CMapOverlayMaterialAdjustmentParams() + { + ClassName = "DmElement"; + Name = "MaterialAdjustmentParamsStruct"; + } + + /// Colour brightness adjustment, 0.5 for none. + public float ColorBrightness { get; set; } = 0.5f; + + /// Colour contrast adjustment, 0.5 for none. + public float ColorContrast { get; set; } = 0.5f; + + /// Opacity of the colour. + public float ColorAlpha { get; set; } = 1f; + + /// Roughness brightness adjustment, 0.5 for none. + public float RoughnessBrightness { get; set; } = 0.5f; + + /// Roughness contrast adjustment, 0.5 for none. + public float RoughnessContrast { get; set; } = 0.5f; + + /// Opacity of the shading. + public float ShadingAlpha { get; set; } = 1f; + + /// Strength of the decal's normal map. + public float NormalIntensity { get; set; } = 0.75f; + + /// Whether the decal's roughness and metalness replace the surface's. + public bool RoughnessMetalnessOverride { get; set; } + + /// Whether the decal's normals blend over the surface's. + public bool NormalBlendOverride { get; set; } = true; +} + +/// +/// Hammer's editable mesh, stored as a half edge mesh with parallel index arrays and data streams. +/// +[CamelCaseProperties] +public class CDmePolygonMesh : DMElement +{ + /// + /// Index to one of the edges stemming from this vertex. + /// + public Datamodel.IntArray VertexEdgeIndices { get; init; } = []; + + /// + /// Index to the streams. + /// + public Datamodel.IntArray VertexDataIndices { get; init; } = []; + + /// + /// The destination vertex of this edge. + /// + public Datamodel.IntArray EdgeVertexIndices { get; init; } = []; + + /// + /// Index to the opposite/twin edge. + /// + public Datamodel.IntArray EdgeOppositeIndices { get; init; } = []; + + /// + /// Index to the next edge in the loop, in counter-clockwise order. + /// + public Datamodel.IntArray EdgeNextIndices { get; init; } = []; + + /// + /// Per half-edge index to the adjacent face. -1 if void (open edge). + /// + public Datamodel.IntArray EdgeFaceIndices { get; init; } = []; + + /// + /// Per half-edge index to the streams. + /// + public Datamodel.IntArray EdgeDataIndices { get; init; } = []; + + /// + /// Per half-edge index to the streams. + /// + public Datamodel.IntArray EdgeVertexDataIndices { get; init; } = []; + + /// + /// Per face index to one of the *inner* edges encapsulating this face. + /// + public Datamodel.IntArray FaceEdgeIndices { get; init; } = []; + + /// + /// Per face index to the streams. + /// + public Datamodel.IntArray FaceDataIndices { get; init; } = []; + + /// + /// List of material names. Indexed by the 'meshindex' stream. + /// + public Datamodel.StringArray Materials { get; init; } = []; + + /// + /// Stores vertex positions. + /// + public CDmePolygonMeshDataArray VertexData { get; init; } = []; + + /// + /// Stores vertex uv, normal, tangent, etc. Two per vertex (for each half?). + /// + public CDmePolygonMeshDataArray FaceVertexData { get; init; } = []; + + /// + /// Stores edge data such as soft or hard normals. + /// + public CDmePolygonMeshDataArray EdgeData { get; init; } = []; + + /// + /// Stores face data such as texture scale, UV offset, material, lightmap bias. + /// + public CDmePolygonMeshDataArray FaceData { get; init; } = []; + + /// + /// Stores the subdivision level of each half-edge. + /// + public CDmePolygonMeshSubdivisionData SubdivisionData { get; init; } = []; + + /// + /// Returns the number of faces in the mesh. + /// + [SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = ValveMapSchema.SerializedPropertiesJustification)] + public int GetFaceCount() => FaceEdgeIndices.Count; + + /// + /// Enumerates the half edges around a face, starting at its entry and following . + /// + /// Index of the face. + public IEnumerable GetFaceHalfEdges(int faceIndex) + { + var firstEdge = FaceEdgeIndices[faceIndex]; + var edge = firstEdge; + + do + { + yield return edge; + edge = EdgeNextIndices[edge]; + } + while (edge != firstEdge); + } + + /// + /// Enumerates the vertex indices around a face, in winding order. Index these into the streams through . + /// + /// Index of the face. + public IEnumerable GetFaceVertices(int faceIndex) + { + foreach (var edge in GetFaceHalfEdges(faceIndex)) + { + yield return EdgeVertexIndices[edge]; + } + } +} + +/// +/// A set of parallel data streams attached to one mesh component (vertices, half edges, or faces). +/// +[CamelCaseProperties] +public class CDmePolygonMeshDataArray : DMElement +{ + /// + /// Number of entries each stream in holds. + /// + public int Size { get; set; } + + /// + /// Array of . + /// + public Datamodel.ElementArray Streams { get; init; } = []; + + /// + /// Finds the stream with the given semantic name and index, for example "position" 0. + /// + /// Semantic name of the stream. + /// Channel of the semantic. + /// The stream, or null when there is none. + public CDmePolygonMeshDataStream? GetStream(string semanticName, int semanticIndex = 0) + { + foreach (var element in Streams) + { + if (element is CDmePolygonMeshDataStream stream && stream.SemanticIndex == semanticIndex && stream.SemanticName == semanticName) + { + return stream; + } + } + + return null; + } + + /// + /// Returns the data of the stream with the given semantic name and index as a typed array, or null when there is no such stream or its data has another type. + /// + /// Element type of the stream, int, Vector2, Vector3 or Vector4. + /// Semantic name of the stream. + /// Channel of the semantic. + public Datamodel.Array? GetStreamData(string semanticName, int semanticIndex = 0) + => GetStream(semanticName, semanticIndex)?.Data as Datamodel.Array; +} + +/// +/// Subdivision state of a . +/// +[CamelCaseProperties] +public class CDmePolygonMeshSubdivisionData : DMElement +{ + /// + /// Subdivision level per half edge. + /// + public Datamodel.IntArray SubdivisionLevels { get; init; } = []; + + /// + /// Array of . + /// + public Datamodel.ElementArray Streams { get; init; } = []; +} + +/// +/// One named data stream of a , such as position, uv, or material index. +/// +[CamelCaseProperties] +public class CDmePolygonMeshDataStream : DMElement +{ + /// + /// Name Hammer knows this stream by, for example "position" or "texcoord". + /// + public string StandardAttributeName { get; set; } = string.Empty; + + /// + /// Name the stream binds to in the shader, for example "position" or "normal". + /// + public string SemanticName { get; set; } = string.Empty; + + /// + /// Channel of this stream fills. + /// + public int SemanticIndex { get; set; } + + /// + /// Slot this stream occupies in the vertex buffer. + /// + public int VertexBufferLocation { get; set; } + + /// + /// Flags describing how the stream is stored. + /// + public int DataStateFlags { get; set; } + + /// + /// Subdivision stream this one mirrors, or null. + /// + public DMElement? SubdivisionBinding { get; init; } + + /// + /// An int, vector2, vector3, or vector4 array: , , + /// or . + /// + public IList? Data { get; init; } +} + +/// +/// Pins a node's transform to another node, stored as a plain "DmElement" named "transformPin". +/// +[CamelCaseProperties] +public class CMapTransformPin : DMElement +{ + /// + /// Initializes a new instance of the class with Hammer's defaults. + /// + public CMapTransformPin() + { + ClassName = "DmElement"; + Name = "transformPin"; + } + + /// + /// Name of the node this one is pinned to, empty when not pinned. + /// + public string ReferenceName { get; set; } = string.Empty; + + /// + /// Reference id of the node this one is pinned to, 0 when not pinned. + /// + public ulong TargetReferenceID { get; set; } + + /// + /// Offset kept from the pinned node. + /// + public Vector3 OffsetOrigin { get; set; } + + /// + /// Rotation kept relative to the pinned node. + /// + public Datamodel.QAngle OffsetAngles { get; set; } + + /// + /// Whether the rotation follows the pinned node too. + /// + public bool PinAngles { get; set; } = true; + + /// + /// Whether moving this node also moves the pinned node. + /// + public bool TwoWay { get; set; } +} + +/// +/// A spline of children, the base of cables and particle paths. +/// +[CamelCaseProperties] +public class CMapPath : CMapEntity +{ + /// + /// How the spline interpolates between its nodes. + /// + public int InterpolationType { get; set; } + + /// + /// Whether the last node connects back to the first. + /// + public bool ClosedLoop { get; set; } + + /// + /// Distance between the points a particle snapshot samples along the path. + /// + public float ParticleSnapshotSpacing { get; set; } = 16f; +} + +/// +/// One control point of a . +/// +[CamelCaseProperties] +public class CMapPathNode : CMapEntity +{ + /// + /// Tangent of the spline entering this node. + /// + public Vector3 InTangent { get; set; } + + /// + /// Tangent of the spline leaving this node. + /// + public Vector3 OutTangent { get; set; } -internal class CStoredCamera : DMElement -{ - public Vector3 position { get; set; } = new Vector3(0, -1000, 1000); - public Vector3 lookat { get; set; } -} + /// + /// How is computed. + /// + public int InTangentType { get; set; } = 1; + /// + /// How is computed. + /// + public int OutTangentType { get; set; } = 1; +} -internal class CStoredCameras : DMElement +/// +/// A cable rendered as a tube swept along a . +/// +[CamelCaseProperties] +public class CMapCable : CMapPath { - [DMProperty(name: "activecamera")] - public int ActiveCameraIndex { get; set; } = -1; - public Datamodel.ElementArray cameras { get; set; } = []; -} + /// + /// Material of the cable. + /// + public string MaterialName { get; set; } = string.Empty; + /// + /// Tint applied to the cable. + /// + public Datamodel.Color TintColor { get; set; } = new Datamodel.Color(255, 255, 255, 255); -internal abstract class MapNode : DMElement -{ - public Vector3 origin { get; set; } - public Datamodel.QAngle angles { get; set; } - public Vector3 scales { get; set; } = new Vector3(1, 1, 1); + /// + /// Name of the entity the cable takes its lighting from, empty for none. + /// + public string LightingOriginName { get; set; } = string.Empty; - public int nodeID { get; set; } - public ulong referenceID { get; set; } + /// + /// Number of sides of the tube. + /// + public int NumSides { get; set; } = 4; - public Datamodel.ElementArray children { get; set; } = []; + /// + /// Distance between the rings of the tube along the path. + /// + public float TessellationSpacing { get; set; } = 16f; - public bool editorOnly { get; set; } - [DMProperty(name: "force_hidden")] - public bool ForceHidden { get; set; } - public bool transformLocked { get; set; } - public Datamodel.StringArray variableTargetKeys { get; set; } = []; - public Datamodel.StringArray variableNames { get; set; } = []; -} + /// + /// Radius of the tube. + /// + public float Radius { get; set; } = 0.5f; -internal class CMapPrefab : MapNode -{ - public bool fixupEntityNames { get; set; } = true; - public bool loadAtRuntime { get; set; } - public bool loadIfNested { get; set; } = true; - public string targetMapPath { get; set; } = string.Empty; - public string targetName { get; set; } = string.Empty; -} + /// + /// Whether the tube faces inwards. + /// + public bool FlipFaces { get; set; } + /// + /// Whether the texture runs along the path (0) or around it (1). + /// + public int TextureOrientation { get; set; } -internal abstract class BaseEntity : MapNode -{ - public DmePlugList relayPlugData { get; set; } = []; - public Datamodel.ElementArray connectionsData { get; set; } = []; - [DMProperty(name: "entity_properties")] - public EditGameClassProps EntityProperties { get; set; } = []; + /// + /// Texture repeats per unit along the path. + /// + public float TextureScale { get; set; } = 0.25f; - public BaseEntity WithProperty(string name, string value) - { - EntityProperties[name] = value; - return this; - } + /// + /// Texture repeats around the circumference. + /// + public float TextureRepeatsCircumference { get; set; } = 1f; - public BaseEntity WithProperties(params (string name, string value)[] properties) - { - foreach (var (name, value) in properties) - { - EntityProperties[name] = value; - } + /// + /// Texture offset along the path. + /// + public float TextureOffsetAlongPath { get; set; } - return this; - } + /// + /// Texture offset around the circumference. + /// + public float TextureOffsetCircumference { get; set; } - public BaseEntity WithClassName(string className) - => WithProperty("classname", className); -} + /// + /// Whether the cable gets physics geometry. + /// + public bool CollisionEnabled { get; set; } + /// + /// Error the physics simplification is allowed to introduce. + /// + public float PhysicsSimplificationError { get; set; } = 2f; -internal class DmePlugList : DMElement -{ - public Datamodel.StringArray names { get; set; } = []; - public Datamodel.IntArray dataTypes { get; set; } = []; - public Datamodel.IntArray plugTypes { get; set; } = []; - public Datamodel.StringArray descriptions { get; set; } = []; + /// + /// Whether the cable occludes what is behind it. + /// + public bool VisOccluder { get; set; } } - -internal class DmeConnectionData : DMElement +/// +/// The cordon box, whose transform is the box: is its centre and its size. +/// +public class CMapCordon : MapNode { - public string outputName { get; set; } = string.Empty; - public int targetType { get; set; } - public string targetName { get; set; } = string.Empty; - public string inputName { get; set; } = string.Empty; - public string overrideParam { get; set; } = string.Empty; - public float delay { get; set; } - public int timesToFire { get; set; } = -1; + /// + /// Initializes a new instance of the class named as Hammer does. + /// + public CMapCordon() + { + Name = "cordon"; + } } /// -/// A string->string dictionary. This stores entity KeyValues. +/// Node holding the navigation mesh generation settings of the map. /// -internal class EditGameClassProps : DMElement +[CamelCaseProperties] +public class CMapNavData : MapNode { + /// + /// The settings. + /// + public CDmeNavData NavData { get; init; } = []; } /// -/// The world entity. +/// Navigation mesh generation settings. Per agent hull values are parallel arrays with entries. /// - -internal class CMapWorld : BaseEntity +[CamelCaseProperties] +public class CDmeNavData : DMElement { - public int nextDecalID { get; set; } - public bool fixupEntityNames { get; set; } = true; - public string mapUsageType { get; set; } = "standard"; - - public CMapWorld() + /// + /// Initializes a new instance of the class named as Hammer does. + /// + public CDmeNavData() { - EntityProperties["classname"] = "worldspawn"; + Name = "navData"; } -} + /// Whether the project defaults override the settings stored here. + public bool SettingsUseProjectDefaults { get; set; } = true; -internal class CVisibilityMgr : MapNode -{ - public Datamodel.ElementArray nodes { get; set; } = []; - public Datamodel.IntArray hiddenFlags { get; set; } = []; -} + /// Size of a navigation tile, in units. + public float SettingsTileSize { get; set; } = 128f; + /// Size of a voxel cell, in units. + public float SettingsCellSize { get; set; } = 1.5f; -internal class CMapVariableSet : DMElement -{ - public Datamodel.StringArray variableNames { get; set; } = []; - public Datamodel.StringArray variableValues { get; set; } = []; - public Datamodel.StringArray variableTypeNames { get; set; } = []; - public Datamodel.StringArray variableTypeParameters { get; set; } = []; - [DMProperty(name: "m_ChoiceGroups")] - public Datamodel.ElementArray ChoiceGroups { get; set; } = []; -} + /// Height of a voxel cell, in units. + public float SettingsCellHeight { get; set; } = 2f; + /// Smallest region kept, in cells. + public int SettingsRegionMinSize { get; set; } = 8; -[CamelCaseProperties] -internal class CMapSelectionSet : DMElement -{ - public Datamodel.ElementArray Children { get; } = []; - public string SelectionSetName { get; set; } = string.Empty; - public DMElement SelectionSetData { get; set; } = []; + /// Regions smaller than this are merged, in cells. + public int SettingsRegionMergeSize { get; set; } = 20; - public CMapSelectionSet() { } - public CMapSelectionSet(string name) - { - SelectionSetName = name; - } -} + /// Sampling distance of the detail mesh. + public float SettingsDetailSampleDist { get; set; } = 120f; + /// Error the detail mesh is allowed to introduce. + public float SettingsDetailSampleMaxError { get; set; } = 2f; -internal class CObjectSelectionSetDataElement : DMElement -{ - public Datamodel.ElementArray selectedObjects { get; set; } = []; -} + /// Maximum vertices per navigation polygon. + public int SettingsVertsPerPoly { get; set; } = 4; -internal class CFaceSelectionSetDataElement : DMElement -{ - public Datamodel.IntArray faces { get; set; } = []; - public Datamodel.ElementArray meshes { get; set; } = []; -} + /// Longest polygon edge, in cells. + public int SettingsEdgeMaxLen { get; set; } = 1200; + /// Error an edge is allowed to deviate from the geometry. + public float SettingsEdgeMaxError { get; set; } = 45f; -internal class CMapEntity : BaseEntity -{ - public Vector3 hitNormal { get; set; } - public bool isProceduralEntity { get; set; } -} + /// Areas on edges smaller than this are removed, -1 to keep them. + public float SettingsSmallAreaOnEdgeRemovalSize { get; set; } = -1f; + /// Name of the agent hull preset, empty for none. + public string SettingsAgentHullPreset { get; set; } = string.Empty; -[LowercaseProperties] -internal class CMapInstance : BaseEntity + /// Path of the vdata overriding the agent hulls, empty for none. + public string SettingsAgentHullsVDataOverride { get; set; } = string.Empty; + + /// Number of agent hulls. + public int SettingsAgentNumHulls { get; set; } = 1; + + /// Whether each hull is generated. + public Datamodel.BoolArray SettingsAgentEnabled { get; init; } = [true]; + + /// Radius of each hull. + public Datamodel.FloatArray SettingsAgentRadius { get; init; } = [15f]; + + /// Height of each hull. + public Datamodel.FloatArray SettingsAgentHeight { get; init; } = [71f]; + + /// Whether each hull has a crouching height. + public Datamodel.BoolArray SettingsAgentShortHeightEnabled { get; init; } = [false]; + + /// Crouching height of each hull. + public Datamodel.FloatArray SettingsAgentShortHeight { get; init; } = [35.5f]; + + /// Whether each hull has a crawling height. + public Datamodel.BoolArray SettingsAgentCrawlEnabled { get; init; } = [false]; + + /// Crawling height of each hull. + public Datamodel.FloatArray SettingsAgentCrawlHeight { get; init; } = [17.5f]; + + /// Highest step each hull can climb. + public Datamodel.FloatArray SettingsAgentMaxClimb { get; init; } = [17.5f]; + + /// Steepest slope each hull can walk, in degrees. + public Datamodel.IntArray SettingsAgentMaxSlope { get; init; } = [50]; + + /// Furthest each hull can jump down. + public Datamodel.FloatArray SettingsAgentMaxJumpDownDist { get; init; } = [240f]; + + /// Furthest each hull can jump horizontally. + public Datamodel.FloatArray SettingsAgentMaxJumpHorizDistBase { get; init; } = [64f]; + + /// Highest each hull can jump up. + public Datamodel.FloatArray SettingsAgentMaxJumpUpDist { get; init; } = [0f]; + + /// Cells eroded from the border for each hull, -1 for the default. + public Datamodel.IntArray SettingsAgentBorderErosion { get; init; } = [-1]; +} + +/// +/// A smart prop placed in the map, evaluated by Hammer into the props it stands for. +/// +[CamelCaseProperties] +public class CMapSmartProp : MapNode { /// - /// A target to instance. With custom tint and transform. + /// Nodes the smart prop shapes itself around, each wrapped in a plain element with a "value" attribute. /// - public CMapGroup? Target { get; set; } - public Datamodel.Color TintColor { get; set; } = new Datamodel.Color(255, 255, 255, 255); -} + public Datamodel.ElementArray ShapeReferences { get; init; } = []; -internal class CMapGroup : MapNode -{ -} + /// + /// Path of the smart prop definition. + /// + public string SmartPropFilename { get; set; } = string.Empty; + /// + /// Tint applied to the evaluated props. + /// + public Datamodel.Color TintColor { get; set; } = new Datamodel.Color(255, 255, 255, 255); -internal class CMapWorldLayer : CMapGroup -{ - public string worldLayerName { get; set; } = string.Empty; -} + /// + /// Whether Hammer keeps the current evaluation instead of re-evaluating on changes. + /// + public bool EvaluationLocked { get; set; } + /// + /// Whether the evaluation is constrained to the prefab the smart prop sits in. + /// + public bool ConstrainToPrefab { get; set; } -internal class CMapMesh : MapNode -{ - public string cubeMapName { get; set; } = string.Empty; - public string lightGroup { get; set; } = string.Empty; - [DMProperty(name: "visexclude")] - public bool VisExclude { get; set; } - [DMProperty(name: "renderwithdynamic")] - public bool RenderWithDynamic { get; set; } - public bool disableHeightDisplacement { get; set; } - [DMProperty(name: "fademindist")] - public float FadeMinDist { get; set; } = -1; - [DMProperty(name: "fademaxdist")] - public float FadeMaxDist { get; set; } - [DMProperty(name: "bakelighting")] - public bool BakeLighting { get; set; } = true; - [DMProperty(name: "precomputelightprobes")] - public bool PrecomputeLightProbes { get; set; } = true; - public bool renderToCubemaps { get; set; } = true; - public int disableShadows { get; set; } - public float smoothingAngle { get; set; } = 40f; - public Datamodel.Color tintColor { get; set; } = new Datamodel.Color(255, 255, 255, 255); - [DMProperty(name: "renderAmt")] - public int RenderAmount { get; set; } = 255; - public string physicsType { get; set; } = "default"; - public string physicsGroup { get; set; } = string.Empty; - public string physicsInteractsAs { get; set; } = string.Empty; - public string physicsInteractWsith { get; set; } = string.Empty; - public string physicsInteractsExclude { get; set; } = string.Empty; - public CDmePolygonMesh meshData { get; set; } = []; - public bool useAsOccluder { get; set; } - public bool physicsSimplificationOverride { get; set; } - public float physicsSimplificationError { get; set; } -} + /// + /// Render alpha, 0 to 255. + /// + public int Alpha { get; set; } = 255; + /// + /// Distance beyond which the props are culled, 0 for never. + /// + public float CullDistance { get; set; } -internal class CDmePolygonMesh : MapNode -{ /// - /// Index to one of the edges stemming from this vertex. + /// Distance at which the props start fading out, -1 to never fade. /// - public Datamodel.IntArray vertexEdgeIndices { get; set; } = []; + public float FadeStartDistance { get; set; } = -1f; /// - /// Index to the streams. + /// Name of the entity the props take their lighting from, empty for none. /// - public Datamodel.IntArray vertexDataIndices { get; set; } = []; + public string LightingOriginName { get; set; } = string.Empty; /// - /// The destination vertex of this edge. + /// Shadow casting mode, 0 to cast shadows. /// - public Datamodel.IntArray edgeVertexIndices { get; set; } = []; + public int DisableShadows { get; set; } /// - /// Index to the opposite/twin edge. + /// How the props take part in baked lighting, -1 for the default. Hammer stores this attribute with the misspelled name. /// - public Datamodel.IntArray edgeOppositeIndices { get; set; } = []; + [DMProperty(name: "bakedLigthtingMode")] + public int BakedLightingMode { get; set; } = -1; /// - /// Index to the next edge in the loop, in counter-clockwise order. + /// Lightmap resolution bias of the props. /// - public Datamodel.IntArray edgeNextIndices { get; set; } = []; + public int LightmapScaleBias { get; set; } /// - /// Per half-edge index to the adjacent face. -1 if void (open edge). + /// Whether both sides of the props receive baked lighting. /// - public Datamodel.IntArray edgeFaceIndices { get; set; } = []; + public bool BakeLightingDoubleSided { get; set; } /// - /// Per half-edge index to the streams. + /// Whether emissive materials on the props light the scene when baking. /// - public Datamodel.IntArray edgeDataIndices { get; set; } = []; + public bool EmissiveLightingEnabled { get; set; } = true; /// - /// Per half-edge index to the streams. + /// Multiplier on the emissive light the props contribute when baking. /// - public Datamodel.IntArray edgeVertexDataIndices { get; set; } = []; + public float EmissiveLightingBoost { get; set; } = 1f; /// - /// Per face index to one of the *inner* edges encapsulating this face. + /// Collision mode of the props, -1 for the default. /// - public Datamodel.IntArray faceEdgeIndices { get; set; } = []; + public int CollisionMode { get; set; } = -1; /// - /// Per face index to the streams. + /// Collision property overriding the one of the props' materials, empty for none. /// - public Datamodel.IntArray faceDataIndices { get; set; } = []; + public string CollisionPropertyOverride { get; set; } = string.Empty; /// - /// List of material names. Indexed by the 'meshindex' stream. + /// Whether the props occlude what is behind them. /// - public Datamodel.StringArray materials { get; set; } = []; + public bool IsVisOccluder { get; set; } /// - /// Stores vertex positions. + /// Whether the props appear in cubemap renders. /// - public CDmePolygonMeshDataArray vertexData { get; set; } = []; + public bool RenderToCubeMaps { get; set; } = true; /// - /// Stores vertex uv, normal, tangent, etc. Two per vertex (for each half?). + /// Whether the props are left out at low quality settings. /// - public CDmePolygonMeshDataArray faceVertexData { get; set; } = []; + public bool DisabledInLowQuality { get; set; } /// - /// Stores edge data such as soft or hard normals. + /// Whether the props are baked into the world geometry. /// - public CDmePolygonMeshDataArray edgeData { get; set; } = []; + public bool BakeToWorld { get; set; } /// - /// Stores face data such as texture scale, UV offset, material, lightmap bias. + /// Whether the compiler must not merge the props with others. /// - public CDmePolygonMeshDataArray faceData { get; set; } = []; + public bool DisableMerging { get; set; } - public CDmePolygonMeshSubdivisionData subdivisionData { get; set; } = []; + /// + /// Whether the props render in the dynamic pass. + /// + public bool RenderWithDynamic { get; set; } + + /// + /// The evaluated state of the smart prop, a plain "DmElement" named "nodeData" holding its parameters and configuration. + /// + public DMElement NodeData { get; init; } = new DMElement { ClassName = "DmElement", Name = "nodeData" }; } +/// +/// Baked per vertex lighting of one node, stored in the root's and named after the node id. +/// +[CamelCaseProperties] +public class CDmeNodeInstanceData : DMElement +{ + /// + /// Baked light colour per vertex. + /// + public Datamodel.ColorArray VertexLightingData { get; init; } = []; + + /// + /// Position of each baked vertex. + /// + public Datamodel.Vector3Array VertexLightingPositions { get; init; } = []; + + /// + /// Normal of each baked vertex. + /// + public Datamodel.Vector3Array VertexLightingNormals { get; init; } = []; +} -internal class CDmePolygonMeshDataArray : DMElement +/// +/// The render geometry of a model the map references, kept so that vertex paint can be applied to it. +/// +public class CDmeReferencedMeshSnapshot : DMElement { - public int size { get; set; } /// - /// Array of . + /// Path of the model. + /// + [DMProperty(name: "m_MeshResourceName")] + public string MeshResourceName { get; set; } = string.Empty; + + /// + /// List of elements, one per draw call of the model. /// - public Datamodel.ElementArray streams { get; set; } = []; + [DMProperty(name: "m_DrawCalls")] + public Datamodel.ElementArray DrawCalls { get; init; } = []; } +/// +/// The vertices of one draw call of a . +/// +public class CDmeDrawCallSnapshot : DMElement +{ + /// + /// Vertex positions. + /// + [DMProperty(name: "m_Positions")] + public Datamodel.Vector3Array Positions { get; init; } = []; + + /// + /// Vertex normals. + /// + [DMProperty(name: "m_Normals")] + public Datamodel.Vector3Array Normals { get; init; } = []; + + /// + /// Vertex texture coordinates. + /// + [DMProperty(name: "m_Texcoords")] + public Datamodel.Vector2Array Texcoords { get; init; } = []; + + /// + /// Hash of the draw call, used to match it to the compiled model. + /// + [DMProperty(name: "m_nHash")] + public int Hash { get; set; } + + /// + /// Material of the draw call. + /// + [DMProperty(name: "m_Material")] + public string Material { get; set; } = string.Empty; +} -internal class CDmePolygonMeshSubdivisionData : DMElement +/// +/// Binds a to the subdivision data that drives it. +/// +[CamelCaseProperties] +public class CDmePolygonMeshSubdivisiondataBinding : DMElement { - public Datamodel.IntArray subdivisionLevels { get; set; } = []; /// - /// Array of . + /// Initializes a new instance of the class named as Hammer does. + /// + public CDmePolygonMeshSubdivisiondataBinding() + { + Name = "subdivisionBinding"; + } + + /// + /// Mesh component the target stream belongs to, -1 for none. + /// + public int TargetDataType { get; set; } = -1; + + /// + /// Index of the target stream within its component, -1 for none. + /// + public int TargetStreamIndex { get; set; } = -1; + + /// + /// Where the subdivided values come from. /// - public Datamodel.ElementArray streams { get; set; } = []; + public int StreamSourceType { get; set; } } -internal class CDmePolygonMeshDataStream : DMElement +/// +/// Vertex paint applied to a prop entity, stored on the entity as "extra_vertex_data". +/// +public class CDmExtraVertexData : DMElement { - public string standardAttributeName { get; set; } = string.Empty; - public string semanticName { get; set; } = string.Empty; - public int semanticIndex { get; set; } - public int vertexBufferLocation { get; set; } - public int dataStateFlags { get; set; } - public DMElement? subdivisionBinding { get; set; } /// - /// An int, vector2, vector3, or vector4 array. + /// List of elements, one per painted draw call. /// - public System.Collections.IList? data { get; set; } + [DMProperty(name: "m_ExtraStreams")] + public Datamodel.ElementArray ExtraStreams { get; init; } = []; } -/// -/// Note: The deserializer does not support generic types, but the serializer does. -/// -/// Int, Vector2, Vector3, or Vector4 -internal class CDmePolygonMeshDataStream : CDmePolygonMeshDataStream +/// +/// Vertex paint of one draw call of a prop. +/// +public class CDmExtraVertexStream : DMElement { - public new required Datamodel.Array data { get; set; } + /// + /// Index of the draw call within the mesh. + /// + [DMProperty(name: "m_nDrawCallIndex")] + public int DrawCallIndex { get; set; } + + /// + /// Index of the mesh within the model. + /// + [DMProperty(name: "m_nMeshIndex")] + public int MeshIndex { get; set; } + + /// + /// A "DmeVertexData" element holding the painted streams, such as "VertexPaintTintColor" and "PerVertexLighting", each with an "Indices" array. + /// + [DMProperty(name: "m_pVertexData")] + public DMElement? VertexData { get; init; } }