diff --git a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs index d76238b..8116495 100644 --- a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs +++ b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs @@ -80,6 +80,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator bool hasGenWriter = false; bool propTypeIsIId = false; string? writerClassName = null; + string? propIdTypeName = null; int childTypeNameHash = 0; int[]? childPropertyHashes = null; if (kind == PropertyTypeKind.Complex) @@ -95,9 +96,13 @@ public class AcBinarySourceGenerator : IIncrementalGenerator if (hasGenWriter) { - propTypeIsIId = resolvedType.AllInterfaces.Any(i => + var iidIface = resolvedType.AllInterfaces.FirstOrDefault(i => i.IsGenericType && i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId"); + propTypeIsIId = iidIface != null; + if (iidIface != null) + propIdTypeName = iidIface.TypeArguments[0].ToDisplayString(); + // Writer class: {Namespace}.{FlatName}_GeneratedWriter var flatName = BuildFlatName((INamedTypeSymbol)resolvedType); var ns = resolvedType.ContainingNamespace.IsGlobalNamespace @@ -118,6 +123,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator bool elemHasGenWriter = false; bool elemIsIId = false; string? elemWriterClassName = null; + string? elemIdTypeName = null; string? collKind = null; string? elemFullTypeName = null; int elementTypeNameHash = 0; @@ -160,9 +166,13 @@ public class AcBinarySourceGenerator : IIncrementalGenerator a.AttributeClass?.ToDisplayString() == AttributeName); if (elemHasGenWriter) { - elemIsIId = resolvedElem.AllInterfaces.Any(ifc => + var elemIidIface = resolvedElem.AllInterfaces.FirstOrDefault(ifc => ifc.IsGenericType && ifc.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId"); + elemIsIId = elemIidIface != null; + if (elemIidIface != null) + elemIdTypeName = elemIidIface.TypeArguments[0].ToDisplayString(); + var elemFlatName = BuildFlatName((INamedTypeSymbol)resolvedElem); var ens = resolvedElem.ContainingNamespace.IsGlobalNamespace ? string.Empty : resolvedElem.ContainingNamespace.ToDisplayString(); @@ -184,17 +194,24 @@ public class AcBinarySourceGenerator : IIncrementalGenerator typeNameForTypeof, kind, p.Type.NullableAnnotation == NullableAnnotation.Annotated || IsNullableVT(p.Type), - stringInternAttr, hasGenWriter, propTypeIsIId, writerClassName, - elemKind, elemHasGenWriter, elemIsIId, elemWriterClassName, collKind, elemFullTypeName, + stringInternAttr, hasGenWriter, propTypeIsIId, writerClassName, propIdTypeName, + elemKind, elemHasGenWriter, elemIsIId, elemWriterClassName, elemIdTypeName, collKind, elemFullTypeName, childTypeNameHash, childPropertyHashes, elementTypeNameHash, elementPropertyHashes)); } } // IId: Id first (index 0), then alphabetical — matches runtime TypeMetadataBase ordering - var isIId = typeSymbol.AllInterfaces.Any(i => + var isIId = false; + string? idTypeName = null; + var iidInterface = typeSymbol.AllInterfaces.FirstOrDefault(i => i.IsGenericType && i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId"); + if (iidInterface != null) + { + isIId = true; + idTypeName = iidInterface.TypeArguments[0].ToDisplayString(); + } if (isIId) properties.Sort((a, b) => @@ -210,7 +227,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator var className = BuildFlatName(typeSymbol); var typeNameHash = ComputeFnvHash(typeSymbol.Name); var propertyNameHashes = properties.Select(prop => ComputeFnvHash(prop.Name)).ToArray(); - return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, typeNameHash, propertyNameHashes); + return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, typeNameHash, propertyNameHashes); } private static void Execute(ImmutableArray classes, SourceProductionContext context) @@ -227,14 +244,13 @@ public class AcBinarySourceGenerator : IIncrementalGenerator private static string GenWriter(SerializableClassInfo ci) { - var sb = new StringBuilder(2048); + var sb = new StringBuilder(4096); sb.AppendLine("// "); sb.AppendLine("#nullable enable"); sb.AppendLine("using System.Runtime.CompilerServices;"); sb.AppendLine("using AyCode.Core.Serializers.Binaries;"); - // ReferenceHandlingMode is needed when any Complex/Collection property has direct object write - if (ci.Properties.Any(p => p.HasGeneratedWriter || p.ElementHasGeneratedWriter)) - sb.AppendLine("using AyCode.Core.Serializers;"); + // ReferenceHandlingMode is needed for ScanObject self ref tracking and direct object write/scan + sb.AppendLine("using AyCode.Core.Serializers;"); sb.AppendLine(); if (!string.IsNullOrEmpty(ci.Namespace)) sb.AppendLine($"namespace {ci.Namespace};"); @@ -243,6 +259,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine("{"); sb.AppendLine($" internal static readonly {ci.ClassName}_GeneratedWriter Instance = new();"); sb.AppendLine($" internal static readonly int s_metadataSlot = AcBinarySerializer.AllocateMetadataSlot();"); + sb.AppendLine($" internal static readonly int s_trackingSlot = AcBinarySerializer.AllocateTrackingSlot();"); sb.AppendLine($" internal static readonly int s_typeNameHash = {ci.TypeNameHash};"); sb.Append( $" internal static readonly int[] s_propertyHashes = new int[] {{ "); sb.Append(string.Join(", ", ci.PropertyNameHashes)); @@ -259,10 +276,108 @@ public class AcBinarySourceGenerator : IIncrementalGenerator } sb.AppendLine(" }"); + sb.AppendLine(); + + // ScanObject — full scan pass (null/depth + self ref tracking + property scan) + GenScanProperties(sb, ci); + + sb.AppendLine(); + + // ScanForDuplicates — instance method on IGeneratedBinaryWriter, called from Serialize + sb.AppendLine(" public void ScanForDuplicates(object value, AcBinarySerializer.BinarySerializationContext context)"); + sb.AppendLine(" where TOutput : struct, IBinaryOutputBase"); + sb.AppendLine(" {"); + sb.AppendLine(" if (!context.HasCaching) return;"); + sb.AppendLine(" ScanObject(value, context, 0);"); + sb.AppendLine(" context.SortWritePlan();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); return sb.ToString(); } + /// + /// Generates the ScanObject method — full scan pass entry point for this type. + /// Includes: null/depth check, self ref tracking (IId or All mode), property scan. + /// Only emits code for reference properties (strings + complex types) — primitives are skipped. + /// + private static void GenScanProperties(StringBuilder sb, SerializableClassInfo ci) + { + sb.AppendLine(" public void ScanObject(object value, AcBinarySerializer.BinarySerializationContext context, int depth) where TOutput : struct, IBinaryOutputBase"); + sb.AppendLine(" {"); + + // Null/depth guard — matches runtime ScanValue entry + sb.AppendLine(" if (value == null || depth > context.MaxDepth) return;"); + sb.AppendLine(); + sb.AppendLine($" var obj = Unsafe.As<{ci.FullTypeName}>(value);"); + + // Self ref tracking — matches runtime ScanValue UseTypeReferenceHandling block + if (ci.IsIId) + { + // IId type: track when ReferenceHandling != None + var trackMethod = ci.IdTypeName switch + { + "int" => "ScanTrackObjectInt32", + "long" => "ScanTrackObjectInt64", + "System.Guid" => "ScanTrackObjectGuid", + _ => "ScanTrackObjectInt32" + }; + sb.AppendLine(); + sb.AppendLine(" if (context.ReferenceHandling != ReferenceHandlingMode.None)"); + sb.AppendLine(" {"); + sb.AppendLine($" if (!context.{trackMethod}(s_trackingSlot, obj.Id))"); + sb.AppendLine(" return;"); + sb.AppendLine(" }"); + } + else + { + // Non-IId type: track when ReferenceHandling == All + sb.AppendLine(); + sb.AppendLine(" if (context.ReferenceHandling == ReferenceHandlingMode.All)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (!context.ScanTrackObjectInt32(s_trackingSlot, System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(value)))"); + sb.AppendLine(" return;"); + sb.AppendLine(" }"); + } + + // Collect scannable properties + var scanProps = ci.Properties.Where(p => + p.TypeKind == PropertyTypeKind.String || + p.TypeKind == PropertyTypeKind.Complex || + p.TypeKind == PropertyTypeKind.Collection).ToList(); + + // Hoist UseStringInterning + IsValidForInterningString checks if any string scanning needed + var hasStringScan = scanProps.Any(p => + (p.TypeKind == PropertyTypeKind.String && p.InterningFlags != 0) || + (p.TypeKind == PropertyTypeKind.Collection && p.ElementKind == PropertyTypeKind.String && p.InterningFlags != 0)); + + if (hasStringScan) + { + // Hoist the shift once — per-property InterningFlags check uses internBit directly. + // Cannot combine flags (OR) because different properties may have different flags + // and Attribute mode must NOT scan All-only properties. + sb.AppendLine(); + sb.AppendLine(" var internBit = 1 << (int)context.Options.UseStringInterning;"); + sb.AppendLine(" int minIntern = 0, maxIntern = 0;"); + sb.AppendLine(" if (internBit > 1) { minIntern = context.MinStringInternLength; maxIntern = context.MaxStringInternLength; }"); + } + + var hasAnyScanProp = false; + foreach (var p in scanProps) + { + sb.AppendLine(); + hasAnyScanProp = true; + EmitScanProp(sb, p, " ", ci.FullTypeName); + } + + if (!hasAnyScanProp) + { + sb.AppendLine(" // No reference properties to scan"); + } + + sb.AppendLine(" }"); + } + private static void EmitProp(StringBuilder sb, PropInfo p, string i, string fullTypeName) { var a = $"obj.{p.Name}"; @@ -396,6 +511,200 @@ public class AcBinarySourceGenerator : IIncrementalGenerator /// /// Emits direct object write — bypasses GetWrapper + WriteObject entirely. + #region Scan Pass Code Generation + + /// + /// Emits scan pass code for a single property. + /// String: interning check + ScanInternString. + /// Complex (SGen): ref tracking via slot IdentityMap + recursive ScanProperties. + /// Complex (no SGen): fallback to ScanValueGenerated (runtime wrapper lookup). + /// Collection: iterate elements with same patterns. + /// + private static void EmitScanProp(StringBuilder sb, PropInfo p, string i, string fullTypeName) + { + var a = $"obj.{p.Name}"; + + // PropertyFilter: must match write pass — if filter skips property, scan must skip too + // Only for non-markerless properties (matching EmitProp behavior) + if (!IsMarkerless(p.TypeKind)) + { + sb.AppendLine($"{i}if (context.HasPropertyFilter)"); + sb.AppendLine($"{i}{{"); + sb.AppendLine($"{i} var fc_{p.Name} = new BinaryPropertyFilterContext(obj, typeof({fullTypeName}), \"{p.Name}\", typeof({p.TypeNameForTypeof}), static o => (({fullTypeName})o).{p.Name});"); + sb.AppendLine($"{i} if (!context.PropertyFilter!(in fc_{p.Name}))"); + sb.AppendLine($"{i} goto scanskip_{p.Name};"); + sb.AppendLine($"{i}}}"); + } + + switch (p.TypeKind) + { + case PropertyTypeKind.String: + EmitScanString(sb, p, a, i); + break; + + case PropertyTypeKind.Complex: + if (p.HasGeneratedWriter) + EmitScanComplexSGen(sb, p, a, i); + else + EmitScanComplexRuntime(sb, p, a, i); + break; + + case PropertyTypeKind.Collection: + EmitScanCollection(sb, p, a, i); + break; + } + + if (!IsMarkerless(p.TypeKind)) + sb.AppendLine($"{i}scanskip_{p.Name}:;"); + } + + /// + /// Emits scan pass code for a string property: interning flags check + ScanInternString. + /// + private static void EmitScanString(StringBuilder sb, PropInfo p, string a, string i) + { + if (p.InterningFlags == 0) + { + // Never interned (explicit [AcStringIntern(false)] or no flags) — skip entirely + return; + } + + // Per-property InterningFlags check with hoisted internBit (context.Options read once) + sb.AppendLine($"{i}if (({p.InterningFlags} & internBit) != 0)"); + sb.AppendLine($"{i}{{"); + sb.AppendLine($"{i} var str_{p.Name} = {a};"); + sb.AppendLine($"{i} if (str_{p.Name} != null)"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} var slen_{p.Name} = str_{p.Name}.Length;"); + sb.AppendLine($"{i} if (slen_{p.Name} >= minIntern && (maxIntern == 0 || slen_{p.Name} <= maxIntern))"); + sb.AppendLine($"{i} context.ScanInternString(str_{p.Name});"); + sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i}}}"); + } + + /// + /// Emits scan pass code for a Complex property with SGen writer. + /// No parent-side ref tracking — child ScanObject handles its own (ScanTrackObjectXxx). + /// + private static void EmitScanComplexSGen(StringBuilder sb, PropInfo p, string a, string i) + { + var writer = p.WriterClassName; + var childVar = $"sc_{p.Name}"; + + // Null check only — ScanObject handles depth + ref tracking internally + sb.AppendLine($"{i}var {childVar} = {a};"); + sb.AppendLine($"{i}if ({childVar} != null)"); + sb.AppendLine($"{i} {writer}.Instance.ScanObject({childVar}, context, depth + 1);"); + } + + /// + /// Emits scan pass code for a Complex property without SGen writer (runtime fallback). + /// + private static void EmitScanComplexRuntime(StringBuilder sb, PropInfo p, string a, string i) + { + var childVar = $"sc_{p.Name}"; + sb.AppendLine($"{i}var {childVar} = {a};"); + sb.AppendLine($"{i}if ({childVar} != null)"); + sb.AppendLine($"{i} AcBinarySerializer.ScanValueGenerated({childVar}, typeof({p.TypeNameForTypeof}), context, depth + 1);"); + } + + /// + /// Emits scan pass code for a Collection property. + /// Handles string collections (interning) and complex element collections (SGen or runtime fallback). + /// + private static void EmitScanCollection(StringBuilder sb, PropInfo p, string a, string i) + { + // String element collection + if (p.ElementKind == PropertyTypeKind.String) + { + if (p.InterningFlags == 0) return; // never interned + + sb.AppendLine($"{i}var scol_{p.Name} = {a};"); + sb.AppendLine($"{i}if (scol_{p.Name} != null && ({p.InterningFlags} & internBit) != 0)"); + sb.AppendLine($"{i}{{"); + + if (p.CollectionKind == "Array") + { + sb.AppendLine($"{i} for (var si_{p.Name} = 0; si_{p.Name} < scol_{p.Name}.Length; si_{p.Name}++)"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} var se_{p.Name} = scol_{p.Name}[si_{p.Name}];"); + } + else if (p.CollectionKind == "List") + { + sb.AppendLine($"{i} for (var si_{p.Name} = 0; si_{p.Name} < scol_{p.Name}.Count; si_{p.Name}++)"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} var se_{p.Name} = scol_{p.Name}[si_{p.Name}];"); + } + else + { + sb.AppendLine($"{i} foreach (var se_{p.Name} in scol_{p.Name})"); + sb.AppendLine($"{i} {{"); + } + + sb.AppendLine($"{i} if (se_{p.Name} != null)"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} var slen_{p.Name} = se_{p.Name}.Length;"); + sb.AppendLine($"{i} if (slen_{p.Name} >= minIntern && (maxIntern == 0 || slen_{p.Name} <= maxIntern))"); + sb.AppendLine($"{i} context.ScanInternString(se_{p.Name});"); + sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i}}}"); + return; + } + + // Complex element collection with SGen writer + if (p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter && p.CollectionKind != null) + { + var writer = p.ElementWriterClassName; + + sb.AppendLine($"{i}var scol_{p.Name} = {a};"); + sb.AppendLine($"{i}if (scol_{p.Name} != null)"); + sb.AppendLine($"{i}{{"); + sb.AppendLine($"{i} var snd_{p.Name} = depth + 1;"); + + if (p.CollectionKind == "Array") + { + sb.AppendLine($"{i} for (var si_{p.Name} = 0; si_{p.Name} < scol_{p.Name}.Length; si_{p.Name}++)"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} var se_{p.Name} = scol_{p.Name}[si_{p.Name}];"); + } + else if (p.CollectionKind == "List") + { + sb.AppendLine($"{i} for (var si_{p.Name} = 0; si_{p.Name} < scol_{p.Name}.Count; si_{p.Name}++)"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} var se_{p.Name} = scol_{p.Name}[si_{p.Name}];"); + } + else // Counted (foreach) + { + sb.AppendLine($"{i} foreach (var se_{p.Name} in scol_{p.Name})"); + sb.AppendLine($"{i} {{"); + } + + var e = $"se_{p.Name}"; + // Null check only — ScanObject handles depth + ref tracking internally + sb.AppendLine($"{i} if ({e} == null) continue;"); + sb.AppendLine($"{i} {writer}.Instance.ScanObject({e}, context, snd_{p.Name});"); + sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i}}}"); + return; + } + + // Complex element collection without SGen writer — runtime fallback + if (p.ElementKind == PropertyTypeKind.Complex) + { + sb.AppendLine($"{i}var scol_{p.Name} = {a};"); + sb.AppendLine($"{i}if (scol_{p.Name} != null)"); + sb.AppendLine($"{i} AcBinarySerializer.ScanValueGenerated(scol_{p.Name}, typeof({p.TypeNameForTypeof}), context, depth);"); + return; + } + + // Primitive element collection — no scanning needed + } + + #endregion + + /// + /// Emits inline object write for a Complex property that has a generated writer. /// Writes marker bytes + inline metadata (UseMetadata) + calls child GeneratedWriter.WriteProperties. /// IId types: guard ReferenceHandling != None (tracked in OnlyId + All). /// Non-IId types: guard ReferenceHandling == All (tracked only in All mode). @@ -888,12 +1197,16 @@ internal sealed class SerializableClassInfo public string ClassName { get; } public string FullTypeName { get; } public List Properties { get; } + /// True if this type implements IId<T> + public bool IsIId { get; } + /// The Id type name ("int", "long", "System.Guid") if IsIId, null otherwise + public string? IdTypeName { get; } /// FNV-1a hash of ClassName (matches runtime SourceType.Name hash) public int TypeNameHash { get; } /// FNV-1a hash of each property name, in property order public int[] PropertyNameHashes { get; } - public SerializableClassInfo(string ns, string cn, string ftn, List p, int typeNameHash, int[] propertyNameHashes) - { Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; } + public SerializableClassInfo(string ns, string cn, string ftn, List p, bool isIId, string? idTypeName, int typeNameHash, int[] propertyNameHashes) + { Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; } } internal sealed class PropInfo @@ -920,6 +1233,8 @@ internal sealed class PropInfo public bool IsIId { get; } /// Generated writer class name, e.g. "SharedTag_GeneratedWriter". Only set when HasGeneratedWriter. public string? WriterClassName { get; } + /// Id type name ("int", "long", "System.Guid") for IId child types. Null if not IId. + public string? IdTypeName { get; } // Collection element metadata — set when TypeKind == Collection and element type is Complex with generated writer /// Element type kind for collection properties. Only meaningful when TypeKind == Collection. @@ -930,6 +1245,8 @@ internal sealed class PropInfo public bool ElementIsIId { get; } /// Generated writer class name for collection element type. public string? ElementWriterClassName { get; } + /// Id type name for collection element IId types. Null if not IId. + public string? ElementIdTypeName { get; } /// Collection type: "List", "Array", or null (unknown — fallback to runtime). public string? CollectionKind { get; } /// Full element type name for generated code (e.g. "SharedTag"). @@ -946,9 +1263,9 @@ internal sealed class PropInfo public int[]? ElementPropertyHashes { get; } public PropInfo(string n, string tn, string tnForTypeof, PropertyTypeKind tk, bool nullable, - bool? stringInternAttr = null, bool hasGeneratedWriter = false, bool isIId = false, string? writerClassName = null, + bool? stringInternAttr = null, bool hasGeneratedWriter = false, bool isIId = false, string? writerClassName = null, string? idTypeName = null, PropertyTypeKind elementKind = PropertyTypeKind.Unknown, bool elementHasGenWriter = false, bool elementIsIId = false, - string? elementWriterClassName = null, string? collectionKind = null, string? elementFullTypeName = null, + string? elementWriterClassName = null, string? elementIdTypeName = null, string? collectionKind = null, string? elementFullTypeName = null, int childTypeNameHash = 0, int[]? childPropertyHashes = null, int elementTypeNameHash = 0, int[]? elementPropertyHashes = null) { @@ -960,10 +1277,12 @@ internal sealed class PropInfo HasGeneratedWriter = hasGeneratedWriter; IsIId = isIId; WriterClassName = writerClassName; + IdTypeName = idTypeName; ElementKind = elementKind; ElementHasGeneratedWriter = elementHasGenWriter; ElementIsIId = elementIsIId; ElementWriterClassName = elementWriterClassName; + ElementIdTypeName = elementIdTypeName; CollectionKind = collectionKind; ElementFullTypeName = elementFullTypeName; ChildTypeNameHash = childTypeNameHash; diff --git a/AyCode.Core.Tests/GeneratedWriters/TestOrderWriter.cs b/AyCode.Core.Tests/GeneratedWriters/TestOrderWriter.cs index a96a150..08a4560 100644 --- a/AyCode.Core.Tests/GeneratedWriters/TestOrderWriter.cs +++ b/AyCode.Core.Tests/GeneratedWriters/TestOrderWriter.cs @@ -90,6 +90,18 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter context.WriteDecimalBits(obj.TotalAmount); } + public void ScanObject(object value, AcBinarySerializer.BinarySerializationContext context, int depth) where TOutput : struct, IBinaryOutputBase + { + throw new NotImplementedException(); + } + + public void ScanForDuplicates(object value, AcBinarySerializer.BinarySerializationContext context) where TOutput : struct, IBinaryOutputBase + { + if (!context.HasCaching) return; + ScanObject(value, context, 0); + context.SortWritePlan(); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void WriteComplexOrNull(object? value, AcBinarySerializer.BinarySerializationContext context, int depth) where TOutput : struct, IBinaryOutputBase diff --git a/AyCode.Core/Serializers/AcSerializerOptions.cs b/AyCode.Core/Serializers/AcSerializerOptions.cs index e03a52a..eecb9f8 100644 --- a/AyCode.Core/Serializers/AcSerializerOptions.cs +++ b/AyCode.Core/Serializers/AcSerializerOptions.cs @@ -19,7 +19,7 @@ public abstract class AcSerializerOptions set => _referenceHandling = value; } - private ReferenceHandlingMode _referenceHandling = ReferenceHandlingMode.All; + private ReferenceHandlingMode _referenceHandling = ReferenceHandlingMode.OnlyId; private readonly byte _maxDepth = byte.MaxValue; private readonly bool _throwOnCircularReference = true; diff --git a/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs b/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs index 9acd1d1..e00d186 100644 --- a/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs +++ b/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs @@ -14,4 +14,28 @@ namespace AyCode.Core.Serializers.Attributes; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] public sealed class AcBinarySerializableAttribute : Attribute { + public bool EnableMetadataFeature { get; } + public bool EnableIdTrackingFeature { get; } + public bool EnableRefHandlingFeature { get; } + public bool EnableInternStringFeature { get; } + + public AcBinarySerializableAttribute() : this(true) + { + } + + public AcBinarySerializableAttribute(bool enableAllFeatures) + { + EnableMetadataFeature = enableAllFeatures; + EnableIdTrackingFeature = enableAllFeatures; + EnableRefHandlingFeature = enableAllFeatures; + EnableInternStringFeature = enableAllFeatures; + } + + public AcBinarySerializableAttribute(bool enableMetadataFeature, bool enableIdTrackingFeature, bool enableRefHandlingFeature, bool enableInternStringFeature) + { + EnableMetadataFeature = enableMetadataFeature; + EnableIdTrackingFeature = enableIdTrackingFeature; + EnableRefHandlingFeature = enableRefHandlingFeature; + EnableInternStringFeature = enableInternStringFeature; + } } diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs index a8b0051..13b312e 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs @@ -94,6 +94,14 @@ public static partial class AcBinarySerializer private int _nextCacheIndex; // Next dense cache index to assign (starts at 0, uses ++_nextCacheIndex) public int NextFirstIndex; // Next first occurrence index for scan pass. Direct access for performance. + #region Slot-based IdentityMaps for SGen scan pass (replaces wrapper-based tracking) + + private IdentityMap?[]? _slottedIdMapsInt32; + private IdentityMap?[]? _slottedIdMapsInt64; + private IdentityMap?[]? _slottedIdMapsGuid; + + #endregion + #region WriteDuplicateEntry — scan pass output for write pass cursor private WriteDuplicateEntry[]? _writePlan; @@ -281,6 +289,9 @@ public static partial class AcBinarySerializer _stringInternMap?.Reset(); _metadataSeenBits.AsSpan().Clear(); _metadataSeenOverflow?.Clear(); + ResetSlottedMaps(_slottedIdMapsInt32); + ResetSlottedMaps(_slottedIdMapsInt64); + ResetSlottedMaps(_slottedIdMapsGuid); _nextCacheIndex = 0; NextFirstIndex = 0; ScanVisitIndex = 0; @@ -769,6 +780,140 @@ public static partial class AcBinarySerializer #endregion + #region Slot-based Scan Pass Ref Tracking (SGen) + + /// + /// SGen scan pass: tracks an object by Int32 Id (IId or RuntimeHelpers.GetHashCode for non-IId). + /// Increments ScanVisitIndex, manages IdentityMap via slot, and builds WriteDuplicateEntry on duplicates. + /// Returns true if first occurrence (caller should scan children), false if duplicate (skip children). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ScanTrackObjectInt32(int slot, int id) + { + var visitIndex = ScanVisitIndex++; + + if (id == 0) return true; // default Id — no tracking + + ref var maps = ref _slottedIdMapsInt32; + if (maps == null || maps.Length <= slot) + GrowSlottedMaps(ref maps, slot); + + var map = maps[slot] ??= new IdentityMap(); + + if (!map.TryAdd(id, out var si)) + { + ref var entry = ref map.GetValueRef(si); + if (entry.CacheIndex == -1) + { + entry.CacheIndex = ++_nextCacheIndex; + entry.IsFirstWrite = true; + AddWriteDuplicateEntry(entry.FirstIndex, entry.CacheIndex, isFirst: true, value: null); + } + AddWriteDuplicateEntry(visitIndex, entry.CacheIndex, isFirst: false, value: null); + return false; + } + + ref var ne = ref map.GetValueRef(si); + ne.FirstIndex = visitIndex; + ne.CacheIndex = -1; + return true; + } + + /// + /// SGen scan pass: tracks an object by Int64 Id. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ScanTrackObjectInt64(int slot, long id) + { + var visitIndex = ScanVisitIndex++; + + if (id == 0) return true; + + ref var maps = ref _slottedIdMapsInt64; + if (maps == null || maps.Length <= slot) + GrowSlottedMaps(ref maps, slot); + + var map = maps[slot] ??= new IdentityMap(); + + if (!map.TryAdd(id, out var si)) + { + ref var entry = ref map.GetValueRef(si); + if (entry.CacheIndex == -1) + { + entry.CacheIndex = ++_nextCacheIndex; + entry.IsFirstWrite = true; + AddWriteDuplicateEntry(entry.FirstIndex, entry.CacheIndex, isFirst: true, value: null); + } + AddWriteDuplicateEntry(visitIndex, entry.CacheIndex, isFirst: false, value: null); + return false; + } + + ref var ne = ref map.GetValueRef(si); + ne.FirstIndex = visitIndex; + ne.CacheIndex = -1; + return true; + } + + /// + /// SGen scan pass: tracks an object by Guid Id. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ScanTrackObjectGuid(int slot, Guid id) + { + var visitIndex = ScanVisitIndex++; + + if (id == Guid.Empty) return true; + + ref var maps = ref _slottedIdMapsGuid; + if (maps == null || maps.Length <= slot) + GrowSlottedMaps(ref maps, slot); + + var map = maps[slot] ??= new IdentityMap(); + + if (!map.TryAdd(id, out var si)) + { + ref var entry = ref map.GetValueRef(si); + if (entry.CacheIndex == -1) + { + entry.CacheIndex = ++_nextCacheIndex; + entry.IsFirstWrite = true; + AddWriteDuplicateEntry(entry.FirstIndex, entry.CacheIndex, isFirst: true, value: null); + } + AddWriteDuplicateEntry(visitIndex, entry.CacheIndex, isFirst: false, value: null); + return false; + } + + ref var ne = ref map.GetValueRef(si); + ne.FirstIndex = visitIndex; + ne.CacheIndex = -1; + return true; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void GrowSlottedMaps(ref TMap?[]? maps, int slot) where TMap : class + { + var newSize = Math.Max(slot + 1, 16); + if (maps == null) + { + maps = new TMap?[newSize]; + return; + } + var newMaps = new TMap?[newSize]; + maps.AsSpan().CopyTo(newMaps); + maps = newMaps; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ResetSlottedMaps(IdentityMap?[]? maps) + where TKey : notnull where TValue : struct + { + if (maps == null) return; + for (var i = 0; i < maps.Length; i++) + maps[i]?.Reset(); + } + + #endregion + #region UseMetadata Type Tracking /// diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializeTypeMetadata.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializeTypeMetadata.cs index c1a586e..9903c31 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializeTypeMetadata.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializeTypeMetadata.cs @@ -24,6 +24,8 @@ public static partial class AcBinarySerializer /// public Type? GeneratedSerializerType { get; } + public bool EnableInternString { get; } + /// /// Lazy-computed FNV-1a hash of the type name (SourceType.Name). /// Only computed once per type, on first access when UseMetadata=true. @@ -78,7 +80,7 @@ public static partial class AcBinarySerializer for (var i = 0; i < Properties.Length; i++) { var prop = Properties[i]; - if (prop.IsComplexType || prop.AccessorType == PropertyAccessorType.String) + if (prop.IsComplexType || (EnableInternString && prop.AccessorType == PropertyAccessorType.String)) list.Add(prop); } @@ -120,12 +122,16 @@ public static partial class AcBinarySerializer // Use pre-computed WritableProperties directly - no method call overhead! var orderedProperties = WritableProperties; + // Read [AcBinarySerializable] once per type — passed to property accessors + var serializableAttr = type.GetCustomAttribute(inherit: false); + EnableInternString = serializableAttr == null || serializableAttr.EnableInternStringFeature; + Properties = new BinaryPropertyAccessor[orderedProperties.Length]; var complexCount = 0; for (var i = 0; i < orderedProperties.Length; i++) { - var accessor = new BinaryPropertyAccessor(orderedProperties[i], type); + var accessor = new BinaryPropertyAccessor(orderedProperties[i], type, EnableInternString); accessor.PropertyIndex = i; Properties[i] = accessor; @@ -176,8 +182,8 @@ public static partial class AcBinarySerializer /// internal sealed class BinaryPropertyAccessor : BinaryPropertyAccessorBase { - public BinaryPropertyAccessor(PropertyInfo prop, Type declaringType) - : base(prop, declaringType) + public BinaryPropertyAccessor(PropertyInfo prop, Type declaringType, bool enableInternString) + : base(prop, declaringType, enableInternString) { } } diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs index 0298b9b..a50d98a 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs @@ -14,6 +14,9 @@ public static partial class AcBinarySerializer /// - Consistent with write pass which writes ObjectRef (no children) for 2nd occurrence /// - Strings/objects skipped here are never written anyway (parent is ObjectRef) /// CacheIndex is assigned immediately on 2nd occurrence (no post-processing needed). + /// Uses wrapper.GeneratedWriter when available — the wrapper is already cached, + /// so no dictionary lookup overhead. SGen types call generated ScanForDuplicates + /// which bypasses the entire runtime scan path. /// private static void ScanForDuplicates(object value, Type type, BinarySerializationContext context) where TOutput : struct, IBinaryOutputBase @@ -22,8 +25,18 @@ public static partial class AcBinarySerializer return; var wrapper = context.GetWrapper(type); - ScanValue(value, wrapper, context, 0); + // SGen path: wrapper.GeneratedWriter is cached (no registry lookup per call). + // Generated ScanForDuplicates handles HasCaching + ScanObject + SortWritePlan. + var genWriter = wrapper.GeneratedWriter; + if (genWriter != null && context.Options.UseGeneratedCode) + { + genWriter.ScanObject(value, context, 0); + context.SortWritePlan(); + return; + } + + ScanValue(value, wrapper, context, 0); context.SortWritePlan(); } @@ -86,6 +99,15 @@ public static partial class AcBinarySerializer // Object → ref tracking + recursive scan + // SGen path: ScanObject handles its own ref tracking (ScanTrackObjectXxx) + // Must be checked BEFORE runtime ref tracking to avoid double ScanVisitIndex++ + var genWriter = wrapper.GeneratedWriter; + if (genWriter != null && context.Options.UseGeneratedCode) + { + genWriter.ScanObject(value, context, depth); + return; + } + // Reference tracking for IId types (or all types when ReferenceHandling == All) // 2nd occurrence → skip children because: // 1. Write pass writes ObjectRef (no children) → strings/objects here are never in output @@ -135,8 +157,7 @@ public static partial class AcBinarySerializer } } - // Recursive scan on reference properties only - // Use typed getter for strings (much faster than reflection GetValue) + // Fallback: runtime property loop (no SGen writer for this type) var refProperties = metadata.ReferenceProperties; var hasPropertyFilter = context.HasPropertyFilter; var nextDepth2 = depth + 1; @@ -144,7 +165,6 @@ public static partial class AcBinarySerializer for (var i = 0; i < refProperties.Length; i++) { var prop = refProperties[i]; - //context.CurrentProperty = prop; // Must match write pass: filtered properties write PropertySkip (no value) → // scanning them would assign CacheIndex for strings/objects never in output @@ -155,14 +175,12 @@ public static partial class AcBinarySerializer { if (!prop.UseStringPropertyInterning(context.Options.UseStringInterning)) continue; - // Fast path: typed getter for string var str2 = prop.GetString(value); if (str2 != null && context.IsValidForInterningString(str2.Length)) context.ScanInternString(str2); } else if (prop.IsStringCollectionProperty) { - // String collection: per-property interning control, no GetWrapper needed if (!prop.UseStringPropertyInterning(context.Options.UseStringInterning)) continue; var propValue = prop.GetValue(value); @@ -171,7 +189,6 @@ public static partial class AcBinarySerializer } else { - // Object property: use pre-cached wrapper, fallback to GetWrapper on miss/polymorphism var propValue = prop.GetValue(value); if (propValue != null) { @@ -188,6 +205,17 @@ public static partial class AcBinarySerializer } } + /// + /// Public entry point for SGen-generated ScanProperties to call back into runtime ScanValue + /// for child types that don't have a generated writer (fallback to runtime path with wrapper lookup). + /// + internal static void ScanValueGenerated(object value, Type type, BinarySerializationContext context, int depth) + where TOutput : struct, IBinaryOutputBase + { + var wrapper = context.GetWrapper(type); + ScanValue(value, wrapper, context, depth); + } + /// /// Scans string elements of a collection for interning. Uses IList fast path when available. /// diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs index 654e3c2..89f1a80 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs @@ -264,6 +264,19 @@ public static partial class AcBinarySerializer #endregion + #region Scan Pass Tracking Slot Allocation + + private static int s_nextTrackingSlot; + + /// + /// Allocates a unique slot index for SGen scan pass ref tracking IdentityMaps. + /// Called once per SGen type at startup (ModuleInitializer). Thread-safe. + /// Slot indexes the per-context IdentityMap arrays in BinarySerializationContext. + /// + internal static int AllocateTrackingSlot() => Interlocked.Increment(ref s_nextTrackingSlot) - 1; + + #endregion + /// /// Thread-safe registry for generated writers. Looked up once per TypeMetadataWrapper creation. /// diff --git a/AyCode.Core/Serializers/Binaries/BinaryPropertyAccessorBase.cs b/AyCode.Core/Serializers/Binaries/BinaryPropertyAccessorBase.cs index f391899..1588b51 100644 --- a/AyCode.Core/Serializers/Binaries/BinaryPropertyAccessorBase.cs +++ b/AyCode.Core/Serializers/Binaries/BinaryPropertyAccessorBase.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Runtime.CompilerServices; +using AyCode.Core.Serializers.Attributes; using static AyCode.Core.Helpers.JsonUtilities; namespace AyCode.Core.Serializers.Binaries; @@ -53,13 +54,13 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase /// public byte? ExpectedTypeCode { get; } - protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType) + protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType, bool enableInternString) : base(prop, declaringType) { IsStringCollectionProperty = IsStringCollection(prop.PropertyType); - + // All typed getters are initialized in PropertyAccessorBase - if (AccessorType == PropertyAccessorType.String || IsStringCollectionProperty) + if (enableInternString && (AccessorType == PropertyAccessorType.String || IsStringCollectionProperty)) { // Cache [AcStringIntern] attribute (inherit: true to check base class properties) var internAttr = prop.GetCustomAttribute(inherit: true); @@ -67,7 +68,7 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase var stringInternAttributeValue = internAttr?.Enabled; byte flags = 0; - if (stringInternAttributeValue == true) flags |= (1 << (int)StringInterningMode.Attribute); + if (stringInternAttributeValue == true) flags |= (1 << (int)StringInterningMode.Attribute); if (stringInternAttributeValue != false) flags |= (1 << (int)StringInterningMode.All); _interningFlags = flags; } diff --git a/AyCode.Core/Serializers/Binaries/IGeneratedBinaryWriter.cs b/AyCode.Core/Serializers/Binaries/IGeneratedBinaryWriter.cs index f8389ac..fc39b0d 100644 --- a/AyCode.Core/Serializers/Binaries/IGeneratedBinaryWriter.cs +++ b/AyCode.Core/Serializers/Binaries/IGeneratedBinaryWriter.cs @@ -26,4 +26,20 @@ internal interface IGeneratedBinaryWriter /// Output strategy (ArrayBinaryOutput or BufferWriterBinaryOutput). void WriteProperties(object value, AcBinarySerializer.BinarySerializationContext context, int depth) where TOutput : struct, IBinaryOutputBase; + + /// + /// Full scan pass for this type: null/depth check, ref tracking (slot-based IdentityMap), + /// and recursive property scan (strings + complex types). + /// Replaces the entire runtime ScanValue for SGen types — no GetWrapper, no delegate invoke. + /// Called from ScanForDuplicates or from parent SGen ScanObject (child). + /// + void ScanObject(object value, AcBinarySerializer.BinarySerializationContext context, int depth) + where TOutput : struct, IBinaryOutputBase; + + /// + /// SGen scan pass entry point. Called from Serialize instead of runtime ScanForDuplicates. + /// HasCaching check + ScanObject + SortWritePlan — eliminates GetWrapper for the root type. + /// + void ScanForDuplicates(object value, AcBinarySerializer.BinarySerializationContext context) + where TOutput : struct, IBinaryOutputBase; }