diff --git a/Il2CppInspector.sln b/Il2CppInspector.sln index 71d053d..1008734 100644 --- a/Il2CppInspector.sln +++ b/Il2CppInspector.sln @@ -40,6 +40,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\build.yml = .github\workflows\build.yml EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VersionedSerialization", "VersionedSerialization\VersionedSerialization.csproj", "{803C3421-1907-4114-8B6B-F5E1789FD6A6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VersionedSerialization.Generator", "VersionedSerialization.Generator\VersionedSerialization.Generator.csproj", "{6FF1F0C0-374A-4B7E-B173-697605679AF6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -66,6 +70,14 @@ Global {A24D77DA-8A64-4AD3-956A-677A96F20373}.Debug|Any CPU.Build.0 = Debug|Any CPU {A24D77DA-8A64-4AD3-956A-677A96F20373}.Release|Any CPU.ActiveCfg = Release|Any CPU {A24D77DA-8A64-4AD3-956A-677A96F20373}.Release|Any CPU.Build.0 = Release|Any CPU + {803C3421-1907-4114-8B6B-F5E1789FD6A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {803C3421-1907-4114-8B6B-F5E1789FD6A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {803C3421-1907-4114-8B6B-F5E1789FD6A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {803C3421-1907-4114-8B6B-F5E1789FD6A6}.Release|Any CPU.Build.0 = Release|Any CPU + {6FF1F0C0-374A-4B7E-B173-697605679AF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6FF1F0C0-374A-4B7E-B173-697605679AF6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6FF1F0C0-374A-4B7E-B173-697605679AF6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6FF1F0C0-374A-4B7E-B173-697605679AF6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/VersionedSerialization.Generator/Analyzer/InvalidVersionAnalyzer.cs b/VersionedSerialization.Generator/Analyzer/InvalidVersionAnalyzer.cs new file mode 100644 index 0000000..5bb7d8c --- /dev/null +++ b/VersionedSerialization.Generator/Analyzer/InvalidVersionAnalyzer.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using VersionedSerialization.Generator.Utils; + +namespace VersionedSerialization.Generator.Analyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class InvalidVersionAnalyzer : DiagnosticAnalyzer +{ + private const string Identifier = "VS0001"; + + private const string Category = "Usage"; + private const string Title = "Invalid version string in attribute"; + private const string MessageFormat = "Invalid version string"; + private const string Description = + "The version needs to be specified in the following format: .. Tags are not supported."; + + private static readonly DiagnosticDescriptor Descriptor = new(Identifier, Title, MessageFormat, + Category, DiagnosticSeverity.Error, true, Description); + + public override ImmutableArray SupportedDiagnostics => [Descriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeSyntax, SyntaxKind.PropertyDeclaration, SyntaxKind.FieldDeclaration); + } + + private static void AnalyzeSyntax(SyntaxNodeAnalysisContext context) + { + if (context.ContainingSymbol == null) + return; + + var compilation = context.Compilation; + var versionConditionAttribute = compilation.GetTypeByMetadataName(Constants.VersionConditionAttribute); + + foreach (var attribute in context.ContainingSymbol.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, versionConditionAttribute)) + { + if (attribute.ApplicationSyntaxReference == null) + continue; + + foreach (var argument in attribute.NamedArguments) + { + var name = argument.Key; + if (name is Constants.LessThan or Constants.GreaterThan or Constants.EqualTo) + { + var value = (string)argument.Value.Value!; + + if (!StructVersion.TryParse(value, out var ver) || ver.Tag != null) + { + var span = attribute.ApplicationSyntaxReference.Span; + var location = attribute.ApplicationSyntaxReference.SyntaxTree.GetLocation(span); + var diagnostic = Diagnostic.Create(Descriptor, location); + context.ReportDiagnostic(diagnostic); + } + } + } + } + } + } +} \ No newline at end of file diff --git a/VersionedSerialization.Generator/AnalyzerReleases.Shipped.md b/VersionedSerialization.Generator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..60b59dd --- /dev/null +++ b/VersionedSerialization.Generator/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/VersionedSerialization.Generator/AnalyzerReleases.Unshipped.md b/VersionedSerialization.Generator/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..2a7d0cf --- /dev/null +++ b/VersionedSerialization.Generator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +VS0001 | Usage | Error | InvalidVersionAnalyzer \ No newline at end of file diff --git a/VersionedSerialization.Generator/Models/ObjectSerializationInfo.cs b/VersionedSerialization.Generator/Models/ObjectSerializationInfo.cs new file mode 100644 index 0000000..bb2f002 --- /dev/null +++ b/VersionedSerialization.Generator/Models/ObjectSerializationInfo.cs @@ -0,0 +1,13 @@ +using VersionedSerialization.Generator.Utils; + +namespace VersionedSerialization.Generator.Models; + +public sealed record ObjectSerializationInfo( + string Namespace, + string Name, + bool HasBaseType, + bool IsStruct, + bool ShouldGenerateSizeMethod, + bool CanGenerateSizeMethod, + ImmutableEquatableArray Properties +); \ No newline at end of file diff --git a/VersionedSerialization.Generator/Models/PropertySerializationInfo.cs b/VersionedSerialization.Generator/Models/PropertySerializationInfo.cs new file mode 100644 index 0000000..fb4a961 --- /dev/null +++ b/VersionedSerialization.Generator/Models/PropertySerializationInfo.cs @@ -0,0 +1,11 @@ +using VersionedSerialization.Generator.Utils; + +namespace VersionedSerialization.Generator.Models; + +public sealed record PropertySerializationInfo( + string Name, + string ReadMethod, + string SizeExpression, + int Alignment, + ImmutableEquatableArray VersionConditions +); \ No newline at end of file diff --git a/VersionedSerialization.Generator/Models/PropertyType.cs b/VersionedSerialization.Generator/Models/PropertyType.cs new file mode 100644 index 0000000..3e855d0 --- /dev/null +++ b/VersionedSerialization.Generator/Models/PropertyType.cs @@ -0,0 +1,48 @@ +using System; + +namespace VersionedSerialization.Generator.Models; + +public enum PropertyType +{ + Unsupported = -1, + None, + Boolean, + UInt8, + UInt16, + UInt32, + UInt64, + Int8, + Int16, + Int32, + Int64, + String, +} + +public static class PropertyTypeExtensions +{ + public static string GetTypeName(this PropertyType type) + => type switch + { + PropertyType.Unsupported => nameof(PropertyType.Unsupported), + PropertyType.None => nameof(PropertyType.None), + PropertyType.UInt8 => nameof(Byte), + PropertyType.Int8 => nameof(SByte), + PropertyType.Boolean => nameof(PropertyType.Boolean), + PropertyType.UInt16 => nameof(PropertyType.UInt16), + PropertyType.UInt32 => nameof(PropertyType.UInt32), + PropertyType.UInt64 => nameof(PropertyType.UInt64), + PropertyType.Int16 => nameof(PropertyType.Int16), + PropertyType.Int32 => nameof(PropertyType.Int32), + PropertyType.Int64 => nameof(PropertyType.Int64), + PropertyType.String => nameof(String), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + + public static bool IsSeperateMethod(this PropertyType type) + => type switch + { + PropertyType.Boolean => true, + PropertyType.String => true, + _ => false + }; +} \ No newline at end of file diff --git a/VersionedSerialization.Generator/Models/VersionCondition.cs b/VersionedSerialization.Generator/Models/VersionCondition.cs new file mode 100644 index 0000000..a7983df --- /dev/null +++ b/VersionedSerialization.Generator/Models/VersionCondition.cs @@ -0,0 +1,3 @@ +namespace VersionedSerialization.Generator.Models; + +public sealed record VersionCondition(StructVersion? LessThan, StructVersion? GreaterThan, StructVersion? EqualTo, string? IncludingTag, string? ExcludingTag); \ No newline at end of file diff --git a/VersionedSerialization.Generator/ObjectSerializationGenerator.cs b/VersionedSerialization.Generator/ObjectSerializationGenerator.cs new file mode 100644 index 0000000..5ab1c96 --- /dev/null +++ b/VersionedSerialization.Generator/ObjectSerializationGenerator.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using VersionedSerialization.Generator.Models; +using VersionedSerialization.Generator.Utils; + +namespace VersionedSerialization.Generator +{ + [Generator] + public sealed class ObjectSerializationGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) + { + //Debugger.Launch(); + + var valueProvider = context.SyntaxProvider + .ForAttributeWithMetadataName(Constants.VersionedStructAttribute, + static (node, _) => node is ClassDeclarationSyntax or StructDeclarationSyntax, + static (context, _) => (ContextClass: (TypeDeclarationSyntax)context.TargetNode, context.SemanticModel)) + .Combine(context.CompilationProvider) + .Select(static (tuple, cancellationToken) => ParseSerializationInfo(tuple.Left.ContextClass, tuple.Left.SemanticModel, tuple.Right, cancellationToken)) + .WithTrackingName(nameof(ObjectSerializationGenerator)); + + context.RegisterSourceOutput(valueProvider, EmitCode); + } + + private static void EmitCode(SourceProductionContext sourceProductionContext, ObjectSerializationInfo info) + { + var generator = new CodeGenerator(); + generator.AppendLine("#nullable restore"); + generator.AppendLine("using VersionedSerialization;"); + generator.AppendLine(); + + generator.AppendLine($"namespace {info.Namespace};"); + + var versions = new HashSet(); + foreach (var condition in info.Properties.SelectMany(static x => x.VersionConditions)) + { + if (condition.LessThan.HasValue) + versions.Add(condition.LessThan.Value); + + if (condition.GreaterThan.HasValue) + versions.Add(condition.GreaterThan.Value); + + if (condition.EqualTo.HasValue) + versions.Add(condition.EqualTo.Value); + } + + if (versions.Count > 0) + { + generator.EnterScope("file static class Versions"); + + foreach (var version in versions) + { + generator.AppendLine($"public static readonly StructVersion {GetVersionIdentifier(version)} = \"{version}\";"); + } + + generator.LeaveScope(); + } + + generator.EnterScope($"public partial {(info.IsStruct ? "struct" : "class")} {info.Name} : IReadable"); + GenerateReadMethod(generator, info); + generator.AppendLine(); + GenerateSizeMethod(generator, info); + generator.LeaveScope(); + + sourceProductionContext.AddSource($"{info.Namespace}.{info.Name}.g.cs", generator.ToString()); + } + + private static void GenerateSizeMethod(CodeGenerator generator, ObjectSerializationInfo info) + { + generator.EnterScope("public static int Size(in StructVersion version = default, bool is32Bit = false)"); + + if (!info.CanGenerateSizeMethod) + { + generator.AppendLine("throw new InvalidOperationException(\"No size can be calculated for this struct.\");"); + } + else + { + generator.AppendLine("var size = 0;"); + if (info.HasBaseType) + generator.AppendLine("size += base.Size(in version, is32Bit);"); + + foreach (var property in info.Properties) + { + if (property.VersionConditions.Length > 0) + GenerateVersionCondition(property.VersionConditions, generator); + + generator.EnterScope(); + + generator.AppendLine($"size += {property.SizeExpression};"); + + if (property.Alignment != 0) + generator.AppendLine($"size += size % {property.Alignment} == 0 ? 0 : {property.Alignment} - (size % {property.Alignment});"); + + generator.LeaveScope(); + } + + generator.AppendLine("return size;"); + } + + generator.LeaveScope(); + } + + private static void GenerateReadMethod(CodeGenerator generator, ObjectSerializationInfo info) + { + generator.EnterScope("public void Read(ref TReader reader, in StructVersion version = default) where TReader : IReader, allows ref struct"); + + if (info.HasBaseType) + generator.AppendLine("base.Read(ref reader, in version);"); + + foreach (var property in info.Properties) + { + if (property.VersionConditions.Length > 0) + GenerateVersionCondition(property.VersionConditions, generator); + + generator.EnterScope(); + generator.AppendLine($"this.{property.Name} = {property.ReadMethod}"); + + if (property.Alignment != 0) + generator.AppendLine($"reader.Align({property.Alignment});"); + + generator.LeaveScope(); + } + + generator.LeaveScope(); + } + + private static string GetVersionIdentifier(StructVersion version) + => $"V{version.Major}_{version.Minor}{(version.Tag == null ? "" : $"_{version.Tag}")}"; + + private static void GenerateVersionCondition(ImmutableEquatableArray conditions, + CodeGenerator generator) + { + generator.AppendLine("if ("); + generator.IncreaseIndentation(); + + for (var i = 0; i < conditions.Length; i++) + { + generator.AppendLine("(true"); + + var condition = conditions[i]; + if (condition.LessThan.HasValue) + generator.AppendLine($"&& Versions.{GetVersionIdentifier(condition.LessThan.Value)} >= version"); + + if (condition.GreaterThan.HasValue) + generator.AppendLine($"&& version >= Versions.{GetVersionIdentifier(condition.GreaterThan.Value)}"); + + if (condition.EqualTo.HasValue) + generator.AppendLine($"&& version == Versions.{GetVersionIdentifier(condition.EqualTo.Value)}"); + + if (condition.IncludingTag != null) + generator.AppendLine($"&& version.Tag == \"{condition.IncludingTag}\""); + + if (condition.ExcludingTag != null) + generator.AppendLine($"&& version.Tag != \"{condition.ExcludingTag}\""); + + generator.AppendLine(")"); + + if (i != conditions.Length - 1) + generator.AppendLine("||"); + } + + generator.DecreaseIndentation(); + generator.AppendLine(")"); + } + + private static ObjectSerializationInfo ParseSerializationInfo(TypeDeclarationSyntax contextClass, + SemanticModel model, Compilation compilation, + CancellationToken cancellationToken) + { + var classSymbol = model.GetDeclaredSymbol(contextClass, cancellationToken) ?? throw new InvalidOperationException(); + + var alignedAttribute = compilation.GetTypeByMetadataName(Constants.AlignedAttribute); + var versionConditionAttribute = compilation.GetTypeByMetadataName(Constants.VersionConditionAttribute); + var customSerializationAttribute = compilation.GetTypeByMetadataName(Constants.CustomSerializationAttribute); + + var canGenerateSizeMethod = true; + + var properties = new List(); + foreach (var member in classSymbol.GetMembers()) + { + if (member.IsStatic + || member is IFieldSymbol { AssociatedSymbol: not null } + || member is IPropertySymbol { SetMethod: null }) + continue; + + var alignment = 0; + var versionConditions = new List(); + + ITypeSymbol type; + switch (member) + { + case IFieldSymbol field: + type = field.Type; + break; + case IPropertySymbol property: + type = property.Type; + break; + default: + continue; + } + + var typeInfo = ParseType(type); + + canGenerateSizeMethod &= typeInfo.Type != PropertyType.String; + + string readMethod; + if (typeInfo.Type == PropertyType.None) + { + readMethod = $"reader.ReadObject<{typeInfo.ComplexTypeName}>(in version);"; + } + else + { + readMethod = typeInfo.Type.IsSeperateMethod() + ? $"reader.Read{typeInfo.Type.GetTypeName()}();" + : $"reader.Read<{typeInfo.Type.GetTypeName()}>();"; + + if (typeInfo.ComplexTypeName != "") + readMethod = $"({typeInfo.ComplexTypeName}){readMethod}"; + } + + string sizeExpression; + if (typeInfo.Type == PropertyType.None) + { + sizeExpression = $"{typeInfo.ComplexTypeName}.Size(in version, is32Bit)"; + } + else + { + sizeExpression = $"sizeof({typeInfo.Type.GetTypeName()})"; + } + + foreach (var attribute in member.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, alignedAttribute)) + { + alignment = (int)attribute.ConstructorArguments[0].Value!; + } + else if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, versionConditionAttribute)) + { + StructVersion? lessThan = null, + moreThan = null, + equalTo = null; + + string? includingTag = null, + excludingTag = null; + + foreach (var argument in attribute.NamedArguments) + { + switch (argument.Key) + { + case Constants.LessThan: + lessThan = (StructVersion)(string)argument.Value.Value!; + break; + case Constants.GreaterThan: + moreThan = (StructVersion)(string)argument.Value.Value!; + break; + case Constants.EqualTo: + equalTo = (StructVersion)(string)argument.Value.Value!; + break; + case Constants.IncludingTag: + includingTag = (string)argument.Value.Value!; + break; + case Constants.ExcludingTag: + excludingTag = (string)argument.Value.Value!; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + versionConditions.Add(new VersionCondition(lessThan, moreThan, equalTo, includingTag, excludingTag)); + } + else if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, customSerializationAttribute)) + { + readMethod = (string)attribute.ConstructorArguments[0].Value!; + sizeExpression = (string)attribute.ConstructorArguments[1].Value!; + } + } + + properties.Add(new PropertySerializationInfo( + member.Name, + readMethod, + sizeExpression, + alignment, + versionConditions.ToImmutableEquatableArray() + )); + } + + var hasBaseType = false; + if (classSymbol.BaseType != null) + { + var objectSymbol = compilation.GetSpecialType(SpecialType.System_Object); + var valueTypeSymbol = compilation.GetSpecialType(SpecialType.System_ValueType); + + if (!SymbolEqualityComparer.Default.Equals(objectSymbol, classSymbol.BaseType) + && !SymbolEqualityComparer.Default.Equals(valueTypeSymbol, classSymbol.BaseType)) + hasBaseType = true; + } + + return new ObjectSerializationInfo( + classSymbol.ContainingNamespace.ToDisplayString(), + classSymbol.Name, + hasBaseType, + contextClass.Kind() == SyntaxKind.StructDeclaration, + true, + canGenerateSizeMethod, + properties.ToImmutableEquatableArray() + ); + } + + private static (PropertyType Type, string ComplexTypeName, bool IsArray) ParseType(ITypeSymbol typeSymbol) + { + switch (typeSymbol) + { + case IArrayTypeSymbol arrayTypeSymbol: + { + var elementType = ParseType(arrayTypeSymbol.ElementType); + return (elementType.Type, elementType.ComplexTypeName, true); + } + case INamedTypeSymbol { EnumUnderlyingType: not null } namedTypeSymbol: + var res = ParseType(namedTypeSymbol.EnumUnderlyingType); + return (res.Type, typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), false); + } + + if (typeSymbol.SpecialType != SpecialType.None) + { + var type = typeSymbol.SpecialType switch + { + SpecialType.System_Boolean => PropertyType.Boolean, + SpecialType.System_Byte => PropertyType.UInt8, + SpecialType.System_UInt16 => PropertyType.UInt16, + SpecialType.System_UInt32 => PropertyType.UInt32, + SpecialType.System_UInt64 => PropertyType.UInt64, + SpecialType.System_SByte => PropertyType.Int8, + SpecialType.System_Int16 => PropertyType.Int16, + SpecialType.System_Int32 => PropertyType.Int32, + SpecialType.System_Int64 => PropertyType.Int64, + SpecialType.System_String => PropertyType.String, + _ => PropertyType.Unsupported + }; + + return (type, "", false); + } + + var complexType = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + return (PropertyType.None, complexType, false); + } + } +} diff --git a/VersionedSerialization.Generator/StructVersion.cs b/VersionedSerialization.Generator/StructVersion.cs new file mode 100644 index 0000000..c69018b --- /dev/null +++ b/VersionedSerialization.Generator/StructVersion.cs @@ -0,0 +1,71 @@ +using System; + +namespace VersionedSerialization; + +public readonly struct StructVersion(int major = 0, int minor = 0, string? tag = null) +{ + public readonly int Major = major; + public readonly int Minor = minor; + public readonly string? Tag = tag; + + #region Equality operators + + public static bool operator ==(StructVersion left, StructVersion right) + => left.Major == right.Major && left.Minor == right.Minor; + + public static bool operator !=(StructVersion left, StructVersion right) + => !(left == right); + + public static bool operator >(StructVersion left, StructVersion right) + => left.Major > right.Major || (left.Major == right.Major && left.Minor > right.Minor); + + public static bool operator <(StructVersion left, StructVersion right) + => left.Major < right.Major || (left.Major == right.Major && left.Minor < right.Minor); + + public static bool operator >=(StructVersion left, StructVersion right) + => left.Major > right.Major || (left.Major == right.Major && left.Minor >= right.Minor); + + public static bool operator <=(StructVersion left, StructVersion right) + => left.Major < right.Major || (left.Major == right.Major && left.Minor <= right.Minor); + + public override bool Equals(object? obj) + => obj is StructVersion other && Equals(other); + + public bool Equals(StructVersion other) + => Major == other.Major && Minor == other.Minor; + + public override int GetHashCode() + => HashCode.Combine(Major, Minor, Tag); + + #endregion + + public override string ToString() => $"{Major}.{Minor}{(Tag != null ? $"-{Tag}" : "")}"; + + public static bool TryParse(string version, out StructVersion parsed) + { + parsed = default; + + var versionParts = version.Split('.'); + if (versionParts.Length is 1 or > 2) + return false; + + var tagParts = versionParts[1].Split('-'); + if (tagParts.Length > 2) + return false; + + var major = int.Parse(versionParts[0]); + var minor = int.Parse(tagParts[0]); + var tag = tagParts.Length == 1 ? null : tagParts[1]; + + parsed = new StructVersion(major, minor, tag); + return true; + } + + public static implicit operator StructVersion(string value) + { + if (!TryParse(value, out var ver)) + throw new InvalidOperationException("Invalid version string"); + + return ver; + } +} \ No newline at end of file diff --git a/VersionedSerialization.Generator/Utils/CodeGenerator.cs b/VersionedSerialization.Generator/Utils/CodeGenerator.cs new file mode 100644 index 0000000..5c9ac9b --- /dev/null +++ b/VersionedSerialization.Generator/Utils/CodeGenerator.cs @@ -0,0 +1,54 @@ +using System.Text; + +namespace VersionedSerialization.Generator.Utils +{ + public class CodeGenerator + { + private const string Indent = " "; + + private readonly StringBuilder _sb = new(); + private string _currentIndent = ""; + + public void EnterScope(string? header = null) + { + if (header != null) + { + AppendLine(header); + } + + AppendLine("{"); + IncreaseIndentation(); + } + + public void LeaveScope(string suffix = "") + { + DecreaseIndentation(); + AppendLine($"}}{suffix}"); + } + + public void IncreaseIndentation() + { + _currentIndent += Indent; + } + + public void DecreaseIndentation() + { + _currentIndent = _currentIndent.Substring(0, _currentIndent.Length - Indent.Length); + } + + public void AppendLine() + { + _sb.AppendLine(); + } + + public void AppendLine(string text) + { + _sb.AppendLine(_currentIndent + text); + } + + public override string ToString() + { + return _sb.ToString(); + } + } +} \ No newline at end of file diff --git a/VersionedSerialization.Generator/Utils/Constants.cs b/VersionedSerialization.Generator/Utils/Constants.cs new file mode 100644 index 0000000..db705d0 --- /dev/null +++ b/VersionedSerialization.Generator/Utils/Constants.cs @@ -0,0 +1,17 @@ +namespace VersionedSerialization.Generator.Utils; + +public static class Constants +{ + private const string AttributeNamespace = "VersionedSerialization.Attributes"; + + public const string VersionedStructAttribute = $"{AttributeNamespace}.{nameof(VersionedStructAttribute)}"; + public const string AlignedAttribute = $"{AttributeNamespace}.{nameof(AlignedAttribute)}"; + public const string VersionConditionAttribute = $"{AttributeNamespace}.{nameof(VersionConditionAttribute)}"; + public const string CustomSerializationAttribute = $"{AttributeNamespace}.{nameof(CustomSerializationAttribute)}"; + + public const string LessThan = nameof(LessThan); + public const string GreaterThan = nameof(GreaterThan); + public const string EqualTo = nameof(EqualTo); + public const string IncludingTag = nameof(IncludingTag); + public const string ExcludingTag = nameof(ExcludingTag); +} \ No newline at end of file diff --git a/VersionedSerialization.Generator/Utils/HashCode.cs b/VersionedSerialization.Generator/Utils/HashCode.cs new file mode 100644 index 0000000..ff67559 --- /dev/null +++ b/VersionedSerialization.Generator/Utils/HashCode.cs @@ -0,0 +1,454 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// NOTE: This code is derived from an implementation originally in dotnet/roslyn-analyzers +// https://github.com/dotnet/roslyn-analyzers/blob/1dc431ec828e9cc816fc69f89ad9b8286e74d707/src/Utilities/Compiler/RoslynHashCode.cs + +/* + +The xxHash32 implementation is based on the code published by Yann Collet: +https://raw.githubusercontent.com/Cyan4973/xxHash/5c174cfa4e45a42f94082dc0d4539b39696afea1/xxhash.c + + xxHash - Fast Hash algorithm + Copyright (C) 2012-2016, Yann Collet + + BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php) + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + You can contact the author at : + - xxHash homepage: http://www.xxhash.com + - xxHash source repository : https://github.com/Cyan4973/xxHash + +*/ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +[SuppressMessage("Design", "CA1066:Implement IEquatable when overriding Object.Equals", Justification = "This type is not equatable.")] +[SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "This type is not equatable.")] +[SuppressMessage("Usage", "CA2231:Overload operator equals on overriding value type Equals", Justification = "This type is not equatable.")] +internal struct HashCode +{ + private static readonly uint s_seed = GenerateGlobalSeed(); + + private const uint Prime1 = 2654435761U; + private const uint Prime2 = 2246822519U; + private const uint Prime3 = 3266489917U; + private const uint Prime4 = 668265263U; + private const uint Prime5 = 374761393U; + + private uint _v1, _v2, _v3, _v4; + private uint _queue1, _queue2, _queue3; + private uint _length; + + private static uint GenerateGlobalSeed() + { + using var randomNumberGenerator = RandomNumberGenerator.Create(); + var array = new byte[sizeof(uint)]; + randomNumberGenerator.GetBytes(array); + return BitConverter.ToUInt32(array, 0); + } + + public static int Combine(T1 value1) + { + // Provide a way of diffusing bits from something with a limited + // input hash space. For example, many enums only have a few + // possible hashes, only using the bottom few bits of the code. Some + // collections are built on the assumption that hashes are spread + // over a larger space, so diffusing the bits may help the + // collection work more efficiently. + + var hc1 = (uint)(value1?.GetHashCode() ?? 0); + + var hash = MixEmptyState(); + hash += 4; + + hash = QueueRound(hash, hc1); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2) + { + var hc1 = (uint)(value1?.GetHashCode() ?? 0); + var hc2 = (uint)(value2?.GetHashCode() ?? 0); + + var hash = MixEmptyState(); + hash += 8; + + hash = QueueRound(hash, hc1); + hash = QueueRound(hash, hc2); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3) + { + var hc1 = (uint)(value1?.GetHashCode() ?? 0); + var hc2 = (uint)(value2?.GetHashCode() ?? 0); + var hc3 = (uint)(value3?.GetHashCode() ?? 0); + + var hash = MixEmptyState(); + hash += 12; + + hash = QueueRound(hash, hc1); + hash = QueueRound(hash, hc2); + hash = QueueRound(hash, hc3); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4) + { + var hc1 = (uint)(value1?.GetHashCode() ?? 0); + var hc2 = (uint)(value2?.GetHashCode() ?? 0); + var hc3 = (uint)(value3?.GetHashCode() ?? 0); + var hc4 = (uint)(value4?.GetHashCode() ?? 0); + + Initialize(out var v1, out var v2, out var v3, out var v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + var hash = MixState(v1, v2, v3, v4); + hash += 16; + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5) + { + var hc1 = (uint)(value1?.GetHashCode() ?? 0); + var hc2 = (uint)(value2?.GetHashCode() ?? 0); + var hc3 = (uint)(value3?.GetHashCode() ?? 0); + var hc4 = (uint)(value4?.GetHashCode() ?? 0); + var hc5 = (uint)(value5?.GetHashCode() ?? 0); + + Initialize(out var v1, out var v2, out var v3, out var v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + var hash = MixState(v1, v2, v3, v4); + hash += 20; + + hash = QueueRound(hash, hc5); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6) + { + var hc1 = (uint)(value1?.GetHashCode() ?? 0); + var hc2 = (uint)(value2?.GetHashCode() ?? 0); + var hc3 = (uint)(value3?.GetHashCode() ?? 0); + var hc4 = (uint)(value4?.GetHashCode() ?? 0); + var hc5 = (uint)(value5?.GetHashCode() ?? 0); + var hc6 = (uint)(value6?.GetHashCode() ?? 0); + + Initialize(out var v1, out var v2, out var v3, out var v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + var hash = MixState(v1, v2, v3, v4); + hash += 24; + + hash = QueueRound(hash, hc5); + hash = QueueRound(hash, hc6); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6, T7 value7) + { + var hc1 = (uint)(value1?.GetHashCode() ?? 0); + var hc2 = (uint)(value2?.GetHashCode() ?? 0); + var hc3 = (uint)(value3?.GetHashCode() ?? 0); + var hc4 = (uint)(value4?.GetHashCode() ?? 0); + var hc5 = (uint)(value5?.GetHashCode() ?? 0); + var hc6 = (uint)(value6?.GetHashCode() ?? 0); + var hc7 = (uint)(value7?.GetHashCode() ?? 0); + + Initialize(out var v1, out var v2, out var v3, out var v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + var hash = MixState(v1, v2, v3, v4); + hash += 28; + + hash = QueueRound(hash, hc5); + hash = QueueRound(hash, hc6); + hash = QueueRound(hash, hc7); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6, T7 value7, T8 value8) + { + var hc1 = (uint)(value1?.GetHashCode() ?? 0); + var hc2 = (uint)(value2?.GetHashCode() ?? 0); + var hc3 = (uint)(value3?.GetHashCode() ?? 0); + var hc4 = (uint)(value4?.GetHashCode() ?? 0); + var hc5 = (uint)(value5?.GetHashCode() ?? 0); + var hc6 = (uint)(value6?.GetHashCode() ?? 0); + var hc7 = (uint)(value7?.GetHashCode() ?? 0); + var hc8 = (uint)(value8?.GetHashCode() ?? 0); + + Initialize(out var v1, out var v2, out var v3, out var v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + v1 = Round(v1, hc5); + v2 = Round(v2, hc6); + v3 = Round(v3, hc7); + v4 = Round(v4, hc8); + + var hash = MixState(v1, v2, v3, v4); + hash += 32; + + hash = MixFinal(hash); + return (int)hash; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Initialize(out uint v1, out uint v2, out uint v3, out uint v4) + { + v1 = s_seed + Prime1 + Prime2; + v2 = s_seed + Prime2; + v3 = s_seed; + v4 = s_seed - Prime1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Round(uint hash, uint input) + { + return BitOperations.RotateLeft(hash + (input * Prime2), 13) * Prime1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint QueueRound(uint hash, uint queuedValue) + { + return BitOperations.RotateLeft(hash + (queuedValue * Prime3), 17) * Prime4; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint MixState(uint v1, uint v2, uint v3, uint v4) + { + return BitOperations.RotateLeft(v1, 1) + BitOperations.RotateLeft(v2, 7) + BitOperations.RotateLeft(v3, 12) + BitOperations.RotateLeft(v4, 18); + } + + private static uint MixEmptyState() + { + return s_seed + Prime5; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint MixFinal(uint hash) + { + hash ^= hash >> 15; + hash *= Prime2; + hash ^= hash >> 13; + hash *= Prime3; + hash ^= hash >> 16; + return hash; + } + + public void Add(T value) + { + Add(value?.GetHashCode() ?? 0); + } + + public void Add(T value, IEqualityComparer? comparer) + { + Add(value is null ? 0 : (comparer?.GetHashCode(value) ?? value.GetHashCode())); + } + + private void Add(int value) + { + // The original xxHash works as follows: + // 0. Initialize immediately. We can't do this in a struct (no + // default ctor). + // 1. Accumulate blocks of length 16 (4 uints) into 4 accumulators. + // 2. Accumulate remaining blocks of length 4 (1 uint) into the + // hash. + // 3. Accumulate remaining blocks of length 1 into the hash. + + // There is no need for #3 as this type only accepts ints. _queue1, + // _queue2 and _queue3 are basically a buffer so that when + // ToHashCode is called we can execute #2 correctly. + + // We need to initialize the xxHash32 state (_v1 to _v4) lazily (see + // #0) nd the last place that can be done if you look at the + // original code is just before the first block of 16 bytes is mixed + // in. The xxHash32 state is never used for streams containing fewer + // than 16 bytes. + + // To see what's really going on here, have a look at the Combine + // methods. + + var val = (uint)value; + + // Storing the value of _length locally shaves of quite a few bytes + // in the resulting machine code. + var previousLength = _length++; + var position = previousLength % 4; + + // Switch can't be inlined. + + if (position == 0) + { + _queue1 = val; + } + else if (position == 1) + { + _queue2 = val; + } + else if (position == 2) + { + _queue3 = val; + } + else // position == 3 + { + if (previousLength == 3) + { + Initialize(out _v1, out _v2, out _v3, out _v4); + } + + _v1 = Round(_v1, _queue1); + _v2 = Round(_v2, _queue2); + _v3 = Round(_v3, _queue3); + _v4 = Round(_v4, val); + } + } + + public int ToHashCode() + { + // Storing the value of _length locally shaves of quite a few bytes + // in the resulting machine code. + var length = _length; + + // position refers to the *next* queue position in this method, so + // position == 1 means that _queue1 is populated; _queue2 would have + // been populated on the next call to Add. + var position = length % 4; + + // If the length is less than 4, _v1 to _v4 don't contain anything + // yet. xxHash32 treats this differently. + + var hash = length < 4 ? MixEmptyState() : MixState(_v1, _v2, _v3, _v4); + + // _length is incremented once per Add(Int32) and is therefore 4 + // times too small (xxHash length is in bytes, not ints). + + hash += length * 4; + + // Mix what remains in the queue + + // Switch can't be inlined right now, so use as few branches as + // possible by manually excluding impossible scenarios (position > 1 + // is always false if position is not > 0). + if (position > 0) + { + hash = QueueRound(hash, _queue1); + if (position > 1) + { + hash = QueueRound(hash, _queue2); + if (position > 2) + { + hash = QueueRound(hash, _queue3); + } + } + } + + hash = MixFinal(hash); + return (int)hash; + } + +#pragma warning disable CS0809 // Obsolete member overrides non-obsolete member +#pragma warning disable CA1065 // Do not raise exceptions in unexpected locations + // Obsolete member 'memberA' overrides non-obsolete member 'memberB'. + // Disallowing GetHashCode and Equals is by design + + // * We decided to not override GetHashCode() to produce the hash code + // as this would be weird, both naming-wise as well as from a + // behavioral standpoint (GetHashCode() should return the object's + // hash code, not the one being computed). + + // * Even though ToHashCode() can be called safely multiple times on + // this implementation, it is not part of the contract. If the + // implementation has to change in the future we don't want to worry + // about people who might have incorrectly used this type. + + [Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes. Use ToHashCode to retrieve the computed hash code.", error: true)] + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => throw new NotSupportedException(SR.HashCode_HashCodeNotSupported); + + [Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes.", error: true)] + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals(object? obj) => throw new NotSupportedException(SR.HashCode_EqualityNotSupported); +#pragma warning restore CA1065 // Do not raise exceptions in unexpected locations +#pragma warning restore CS0809 // Obsolete member overrides non-obsolete member + + private static class SR + { + public static string HashCode_HashCodeNotSupported = "HashCode is a mutable struct and should not be compared with other HashCodes. Use ToHashCode to retrieve the computed hash code."; + public static string HashCode_EqualityNotSupported = "HashCode is a mutable struct and should not be compared with other HashCodes."; + } + + private static class BitOperations + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint RotateLeft(uint value, int offset) + => (value << offset) | (value >> (32 - offset)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong RotateLeft(ulong value, int offset) + => (value << offset) | (value >> (64 - offset)); + } +} \ No newline at end of file diff --git a/VersionedSerialization.Generator/Utils/ImmutableEquatableArray.cs b/VersionedSerialization.Generator/Utils/ImmutableEquatableArray.cs new file mode 100644 index 0000000..94d8182 --- /dev/null +++ b/VersionedSerialization.Generator/Utils/ImmutableEquatableArray.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; + +namespace VersionedSerialization.Generator.Utils; + +[DebuggerDisplay("Length = {Length}")] +[DebuggerTypeProxy(typeof(ImmutableEquatableArray<>.DebugView))] +public sealed class ImmutableEquatableArray : + IEquatable>, + IReadOnlyList, + IList, + IList + + where T : IEquatable +{ + public static ImmutableEquatableArray Empty { get; } = new([]); + + private readonly T[] _values; + public ref readonly T this[int index] => ref _values[index]; + public int Length => _values.Length; + + private ImmutableEquatableArray(T[] values) + => _values = values; + + public bool Equals(ImmutableEquatableArray? other) + => ReferenceEquals(this, other) || ((ReadOnlySpan)_values).SequenceEqual(other?._values); + + public override bool Equals(object? obj) + => obj is ImmutableEquatableArray other && Equals(other); + + public override int GetHashCode() + { + var hash = 0; + foreach (var value in _values) + { + hash = HashCode.Combine(hash, value.GetHashCode()); + } + + return hash; + } + + public Enumerator GetEnumerator() => new(_values); + + public struct Enumerator + { + private readonly T[] _values; + private int _index; + + internal Enumerator(T[] values) + { + _values = values; + _index = -1; + } + + public bool MoveNext() => ++_index < _values.Length; + public readonly ref T Current => ref _values[_index]; + } + + [EditorBrowsable(EditorBrowsableState.Never)] + internal static ImmutableEquatableArray UnsafeCreateFromArray(T[] values) + => new(values); + + #region Explicit interface implementations + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_values).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_values).GetEnumerator(); + bool ICollection.IsReadOnly => true; + bool IList.IsFixedSize => true; + bool IList.IsReadOnly => true; + T IReadOnlyList.this[int index] => _values[index]; + T IList.this[int index] { get => _values[index]; set => throw new InvalidOperationException(); } + object? IList.this[int index] { get => _values[index]; set => throw new InvalidOperationException(); } + void ICollection.CopyTo(T[] array, int arrayIndex) => _values.CopyTo(array, arrayIndex); + void ICollection.CopyTo(Array array, int index) => _values.CopyTo(array, index); + int IList.IndexOf(T item) => _values.AsSpan().IndexOf(item); + int IList.IndexOf(object? value) => ((IList)_values).IndexOf(value); + bool ICollection.Contains(T item) => _values.AsSpan().IndexOf(item) >= 0; + bool IList.Contains(object? value) => ((IList)_values).Contains(value); + bool ICollection.IsSynchronized => false; + object ICollection.SyncRoot => this; + + int IReadOnlyCollection.Count => Length; + int ICollection.Count => Length; + int ICollection.Count => Length; + + void ICollection.Add(T item) => throw new InvalidOperationException(); + bool ICollection.Remove(T item) => throw new InvalidOperationException(); + void ICollection.Clear() => throw new InvalidOperationException(); + void IList.Insert(int index, T item) => throw new InvalidOperationException(); + void IList.RemoveAt(int index) => throw new InvalidOperationException(); + int IList.Add(object? value) => throw new InvalidOperationException(); + void IList.Clear() => throw new InvalidOperationException(); + void IList.Insert(int index, object? value) => throw new InvalidOperationException(); + void IList.Remove(object? value) => throw new InvalidOperationException(); + void IList.RemoveAt(int index) => throw new InvalidOperationException(); + #endregion + + private sealed class DebugView(ImmutableEquatableArray array) + { + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public T[] Items => array.ToArray(); + } +} + +public static class ImmutableEquatableArray +{ + public static ImmutableEquatableArray ToImmutableEquatableArray(this IEnumerable values) where T : IEquatable + => values is ICollection { Count: 0 } ? ImmutableEquatableArray.Empty : ImmutableEquatableArray.UnsafeCreateFromArray(values.ToArray()); + + public static ImmutableEquatableArray Create(ReadOnlySpan values) where T : IEquatable + => values.IsEmpty ? ImmutableEquatableArray.Empty : ImmutableEquatableArray.UnsafeCreateFromArray(values.ToArray()); +} \ No newline at end of file diff --git a/VersionedSerialization.Generator/VersionedSerialization.Generator.csproj b/VersionedSerialization.Generator/VersionedSerialization.Generator.csproj new file mode 100644 index 0000000..ba0dd08 --- /dev/null +++ b/VersionedSerialization.Generator/VersionedSerialization.Generator.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + latest + 4.10 + 4.10.0 + enable + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/VersionedSerialization/Attributes/AlignedAttribute.cs b/VersionedSerialization/Attributes/AlignedAttribute.cs new file mode 100644 index 0000000..bfb6c04 --- /dev/null +++ b/VersionedSerialization/Attributes/AlignedAttribute.cs @@ -0,0 +1,6 @@ +namespace VersionedSerialization.Attributes; + +#pragma warning disable CS9113 // Parameter is unread. +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class AlignedAttribute(int alignment) : Attribute; +#pragma warning restore CS9113 // Parameter is unread. diff --git a/VersionedSerialization/Attributes/CustomSerializationAttribute.cs b/VersionedSerialization/Attributes/CustomSerializationAttribute.cs new file mode 100644 index 0000000..8e44430 --- /dev/null +++ b/VersionedSerialization/Attributes/CustomSerializationAttribute.cs @@ -0,0 +1,6 @@ +namespace VersionedSerialization.Attributes; + +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +#pragma warning disable CS9113 // Parameter is unread. +public class CustomSerializationAttribute(string methodName, string sizeExpression) : Attribute; +#pragma warning restore CS9113 // Parameter is unread. diff --git a/VersionedSerialization/Attributes/VersionConditionAttribute.cs b/VersionedSerialization/Attributes/VersionConditionAttribute.cs new file mode 100644 index 0000000..d105800 --- /dev/null +++ b/VersionedSerialization/Attributes/VersionConditionAttribute.cs @@ -0,0 +1,11 @@ +namespace VersionedSerialization.Attributes; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true)] +public class VersionConditionAttribute : Attribute +{ + public string LessThan { get; set; } = ""; + public string GreaterThan { get; set; } = ""; + public string EqualTo { get; set; } = ""; + public string IncludingTag { get; set; } = ""; + public string ExcludingTag { get; set; } = ""; +} \ No newline at end of file diff --git a/VersionedSerialization/Attributes/VersionedStructAttribute.cs b/VersionedSerialization/Attributes/VersionedStructAttribute.cs new file mode 100644 index 0000000..c96776a --- /dev/null +++ b/VersionedSerialization/Attributes/VersionedStructAttribute.cs @@ -0,0 +1,4 @@ +namespace VersionedSerialization.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public class VersionedStructAttribute : Attribute; \ No newline at end of file diff --git a/VersionedSerialization/IReadable.cs b/VersionedSerialization/IReadable.cs new file mode 100644 index 0000000..eb04b41 --- /dev/null +++ b/VersionedSerialization/IReadable.cs @@ -0,0 +1,7 @@ +namespace VersionedSerialization; + +public interface IReadable +{ + public void Read(ref TReader reader, in StructVersion version = default) where TReader : IReader, allows ref struct; + public static abstract int Size(in StructVersion version = default, bool is32Bit = false); +} \ No newline at end of file diff --git a/VersionedSerialization/IReader.cs b/VersionedSerialization/IReader.cs new file mode 100644 index 0000000..75b5208 --- /dev/null +++ b/VersionedSerialization/IReader.cs @@ -0,0 +1,22 @@ +using System.Collections.Immutable; + +namespace VersionedSerialization; + +public interface IReader +{ + bool Is32Bit { get; } + + bool ReadBoolean(); + long ReadNInt(); + ulong ReadNUInt(); + string ReadString(); + ReadOnlySpan ReadBytes(int length); + + T Read() where T : unmanaged; + ImmutableArray ReadArray(long count) where T : unmanaged; + + T ReadObject(in StructVersion version = default) where T : IReadable, new(); + ImmutableArray ReadObjectArray(long count, in StructVersion version = default) where T : IReadable, new(); + + public void Align(int alignment = 0); +} \ No newline at end of file diff --git a/VersionedSerialization/ReaderExtensions.cs b/VersionedSerialization/ReaderExtensions.cs new file mode 100644 index 0000000..93912ed --- /dev/null +++ b/VersionedSerialization/ReaderExtensions.cs @@ -0,0 +1,62 @@ +using System.Runtime.CompilerServices; + +namespace VersionedSerialization; + +public static class ReaderExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint ReadCompressedUInt(this ref T reader) where T : struct, IReader, allows ref struct + { + var first = reader.Read(); + + if ((first & 0b10000000) == 0b00000000) + return first; + + if ((first & 0b11000000) == 0b10000000) + return (uint)(((first & ~0b10000000) << 8) | reader.Read()); + + if ((first & 0b11100000) == 0b11000000) + return (uint)(((first & ~0b11000000) << 24) | (reader.Read() << 16) | (reader.Read() << 8) | reader.Read()); + + return first switch + { + 0b11110000 => reader.Read(), + 0b11111110 => uint.MaxValue - 1, + 0b11111111 => uint.MaxValue, + _ => throw new InvalidDataException("Invalid compressed uint") + }; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int ReadCompressedInt(this ref T reader) where T : struct, IReader, allows ref struct + { + var value = reader.ReadCompressedUInt(); + if (value == uint.MaxValue) + return int.MinValue; + + var isNegative = (value & 0b1) == 1; + value >>= 1; + + return (int)(isNegative ? -(value + 1) : value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong ReadSLEB128(this ref T reader) where T : struct, IReader, allows ref struct + { + var value = 0uL; + var shift = 0; + byte current; + + do + { + current = reader.Read(); + value |= (current & 0x7FuL) << shift; + shift += 7; + } while ((current & 0x80) != 0); + + if (64 >= shift && (current & 0x40) != 0) + value |= ulong.MaxValue << shift; + + return value; + } +} \ No newline at end of file diff --git a/VersionedSerialization/SpanReader.cs b/VersionedSerialization/SpanReader.cs new file mode 100644 index 0000000..78dfab5 --- /dev/null +++ b/VersionedSerialization/SpanReader.cs @@ -0,0 +1,146 @@ +using System.Buffers.Binary; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +namespace VersionedSerialization; + +// ReSharper disable ReplaceSliceWithRangeIndexer | The range indexer gets compiled into .Slice(x, y) and not .Slice(x) which worsens performance +public ref struct SpanReader(ReadOnlySpan data, int offset = 0, bool littleEndian = true, bool is32Bit = false) : IReader +{ + public int Offset = offset; + public readonly byte Peek => _data[Offset]; + public readonly bool IsLittleEndian => _littleEndian; + public readonly bool Is32Bit => _is32Bit; + public readonly int Length => _data.Length; + public readonly int PointerSize => Is32Bit ? sizeof(uint) : sizeof(ulong); + + private readonly ReadOnlySpan _data = data; + private readonly bool _littleEndian = littleEndian; + private readonly bool _is32Bit = is32Bit; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private T ReadInternal() where T : unmanaged + { + var value = MemoryMarshal.Read(_data.Slice(Offset)); + Offset += Unsafe.SizeOf(); + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static TTo Cast(in TFrom from) => Unsafe.As(ref Unsafe.AsRef(in from)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ReadOnlySpan ReadBytes(int length) + { + var val = _data.Slice(Offset, length); + Offset += length; + return val; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public T Read() where T : unmanaged + { + if (typeof(T) == typeof(byte)) + return Cast(_data[Offset++]); + + var value = ReadInternal(); + if (!_littleEndian) + { + if (value is ulong val) + { + var converted = BinaryPrimitives.ReverseEndianness(val); + value = Cast(converted); + } + else if (typeof(T) == typeof(long)) + { + var converted = BinaryPrimitives.ReverseEndianness(Cast(value)); + value = Cast(converted); + } + else if (typeof(T) == typeof(uint)) + { + var converted = BinaryPrimitives.ReverseEndianness(Cast(value)); + value = Cast(converted); + } + else if (typeof(T) == typeof(int)) + { + var converted = BinaryPrimitives.ReverseEndianness(Cast(value)); + value = Cast(converted); + } + else if (typeof(T) == typeof(ushort)) + { + var converted = BinaryPrimitives.ReverseEndianness(Cast(value)); + value = Cast(converted); + } + else if (typeof(T) == typeof(short)) + { + var converted = BinaryPrimitives.ReverseEndianness(Cast(value)); + value = Cast(converted); + } + } + + return value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ImmutableArray ReadArray(long count) where T : unmanaged + { + var array = ImmutableArray.CreateBuilder(checked((int)count)); + for (long i = 0; i < count; i++) + array.Add(Read()); + + return array.MoveToImmutable(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public T ReadObject(in StructVersion version = default) where T : IReadable, new() + { + var obj = new T(); + obj.Read(ref this, in version); + return obj; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ImmutableArray ReadObjectArray(long count, in StructVersion version = default) where T : IReadable, new() + { + var array = ImmutableArray.CreateBuilder(checked((int)count)); + for (long i = 0; i < count; i++) + array.Add(ReadObject(in version)); + + return array.MoveToImmutable(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public string ReadString() + { + var length = _data.Slice(Offset).IndexOf(byte.MinValue); + + if (length == -1) + throw new InvalidDataException("Failed to find string in span."); + + var val = _data.Slice(Offset, length); + Offset += length + 1; // Skip null terminator + + return Encoding.UTF8.GetString(val); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ReadBoolean() => Read() != 0; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ulong ReadNUInt() => _is32Bit ? Read() : Read(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public long ReadNInt() => _is32Bit ? Read() : Read(); + + public void Align(int alignment = 0) + { + if (alignment == 0) + alignment = Is32Bit ? 4 : 8; + + var rem = Offset % alignment; + if (rem != 0) + Offset += alignment - rem; + } +} \ No newline at end of file diff --git a/VersionedSerialization/StructVersion.cs b/VersionedSerialization/StructVersion.cs new file mode 100644 index 0000000..23a1c82 --- /dev/null +++ b/VersionedSerialization/StructVersion.cs @@ -0,0 +1,58 @@ +namespace VersionedSerialization; + +public readonly struct StructVersion(int major = 0, int minor = 0, string? tag = null) : IEquatable +{ + public readonly int Major = major; + public readonly int Minor = minor; + public readonly string? Tag = tag; + + #region Equality operators + + public static bool operator ==(StructVersion left, StructVersion right) + => left.Major == right.Major && left.Minor == right.Minor; + + public static bool operator !=(StructVersion left, StructVersion right) + => !(left == right); + + public static bool operator >(StructVersion left, StructVersion right) + => left.Major > right.Major || (left.Major == right.Major && left.Minor > right.Minor); + + public static bool operator <(StructVersion left, StructVersion right) + => left.Major < right.Major || (left.Major == right.Major && left.Minor < right.Minor); + + public static bool operator >=(StructVersion left, StructVersion right) + => left.Major > right.Major || (left.Major == right.Major && left.Minor >= right.Minor); + + public static bool operator <=(StructVersion left, StructVersion right) + => left.Major < right.Major || (left.Major == right.Major && left.Minor <= right.Minor); + + public override bool Equals(object? obj) + => obj is StructVersion other && Equals(other); + + public bool Equals(StructVersion other) + => Major == other.Major && Minor == other.Minor; + + public override int GetHashCode() + => HashCode.Combine(Major, Minor); + + #endregion + + public override string ToString() => $"{Major}.{Minor}{(Tag != null ? $"-{Tag}" : "")}"; + + public static implicit operator StructVersion(string value) + { + var versionParts = value.Split('.'); + if (versionParts.Length is 1 or > 2) + throw new InvalidOperationException("Invalid version string."); + + var tagParts = versionParts[1].Split("-"); + if (tagParts.Length > 2) + throw new InvalidOperationException("Invalid version string."); + + var major = int.Parse(versionParts[0]); + var minor = int.Parse(tagParts[0]); + var tag = tagParts.Length == 1 ? null : tagParts[1]; + + return new StructVersion(major, minor, tag); + } +} \ No newline at end of file diff --git a/VersionedSerialization/VersionedSerialization.csproj b/VersionedSerialization/VersionedSerialization.csproj new file mode 100644 index 0000000..b602acf --- /dev/null +++ b/VersionedSerialization/VersionedSerialization.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + preview + + +