diff --git a/AyCode.Core.Serializers.Console/Program.cs b/AyCode.Core.Serializers.Console/Program.cs index d92b245..1f4996f 100644 --- a/AyCode.Core.Serializers.Console/Program.cs +++ b/AyCode.Core.Serializers.Console/Program.cs @@ -124,10 +124,10 @@ public static class Program /// Aggregation rule: if ALL tagged types have the feature enabled → true; if any tagged type /// disables it → false (a single disabling type suppresses the feature on the type-graph). /// - private static readonly (bool refHandling, bool internString, bool metadata, bool idTracking) _attrFlags + private static readonly (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) _attrFlags = ScanAcBinaryAttributeFlags(); - private static (bool refHandling, bool internString, bool metadata, bool idTracking) ScanAcBinaryAttributeFlags() + private static (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) ScanAcBinaryAttributeFlags() { var attrs = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty(); } }) @@ -135,13 +135,14 @@ public static class Program .Where(a => a != null) .ToList(); - if (attrs.Count == 0) return (false, false, false, false); + if (attrs.Count == 0) return (false, false, false, false, false); return ( - refHandling: attrs.All(a => a!.EnableRefHandlingFeature), - internString: attrs.All(a => a!.EnableInternStringFeature), - metadata: attrs.All(a => a!.EnableMetadataFeature), - idTracking: attrs.All(a => a!.EnableIdTrackingFeature)); + refHandling: attrs.All(a => a!.EnableRefHandlingFeature), + internString: attrs.All(a => a!.EnableInternStringFeature), + metadata: attrs.All(a => a!.EnableMetadataFeature), + idTracking: attrs.All(a => a!.EnableIdTrackingFeature), + propertyFilter: attrs.All(a => a!.EnablePropertyFilterFeature)); } /// @@ -153,10 +154,16 @@ public static class Program /// private static string BuildAcBinaryOptionsDescription(AcBinarySerializerOptions options, string extra = "") { + // PropertyFilter: opt-side is "Set"/"None" depending on whether a callback is registered (the callback + // itself isn't a meaningful display value); attr-side is the cross-type-aggregated bool (true = every + // tagged type has the feature enabled, false = at least one type opted out via + // [AcBinarySerializable(enablePropertyFilterFeature: false)] → SGen-emit + Runtime hot-loop both gate). + var propFilterOpt = options.PropertyFilter == null ? "None" : "Set"; return $"WireMode={options.WireMode}, " + $"RefHandling={options.ReferenceHandling}(opt) | {_attrFlags.refHandling} (attr), " + $"Interning={options.UseStringInterning}(opt) | {_attrFlags.internString} (attr), " + $"Metadata={options.UseMetadata}(opt) | {_attrFlags.metadata} (attr), " + + $"PropertyFilter={propFilterOpt}(opt) | {_attrFlags.propertyFilter} (attr), " + $"SGen={options.UseGeneratedCode}, " + $"Compression={options.UseCompression}{extra}"; } diff --git a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs index 7be815a..8badf04 100644 --- a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs +++ b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs @@ -41,7 +41,8 @@ public class AcBinarySourceGenerator : IIncrementalGenerator // attribute properties so consumers can opt out per type. Until then, keep both `false` for // benchmark-vs-MemPack measurements; flip to `true` for production where the features are needed. // ──────────────────────────────────────────────────────────────────────────────────────────── - private const bool UsePropertyFilter = false; + // UsePropertyFilter const removed — replaced by `[AcBinarySerializable(EnablePropertyFilterFeature = ...)]` + // attribute flag, propagated through SerializableClassInfo.EnablePropertyFilter to EmitProp/EmitScanProp. private const bool UsePolymorphType = false; private static readonly DiagnosticDescriptor CircularReferenceWarning = new( @@ -82,6 +83,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator var enableRefHandling = true; var enableInternString = true; var enableMetadata = true; + var enablePropertyFilter = true; var binarySerializableAttr = typeSymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == AttributeName); if (binarySerializableAttr != null) @@ -94,15 +96,25 @@ public class AcBinarySourceGenerator : IIncrementalGenerator enableRefHandling = all; enableInternString = all; enableMetadata = all; + enablePropertyFilter = all; } else if (binarySerializableAttr.ConstructorArguments.Length == 4) { - // Four bool ctor: (metadata, idTracking, refHandling, internString) + // Four bool ctor: (metadata, idTracking, refHandling, internString) — filter defaults to true enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!; enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!; enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!; enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!; } + else if (binarySerializableAttr.ConstructorArguments.Length == 5) + { + // Five bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter) + enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!; + enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!; + enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!; + enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!; + enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!; + } } foreach (var p in GetAllSerializablePropertySymbols(typeSymbol)) @@ -384,7 +396,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator var typeNameHash = ComputeFnvHash(typeSymbol.Name); var propertyNameHashes = properties.Select(prop => ComputeFnvHash(prop.Name)).ToArray(); var selfScanFlags = ComputeNeedsScan(typeSymbol); - return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes, enableMetadata, selfScanFlags.needsIdScan, selfScanFlags.needsAllRefScan, selfScanFlags.needsInternScan); + return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes, enableMetadata, enablePropertyFilter, selfScanFlags.needsIdScan, selfScanFlags.needsAllRefScan, selfScanFlags.needsInternScan); } private static void Execute(ImmutableArray classes, SourceProductionContext context) @@ -537,7 +549,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator foreach (var p in ci.Properties) { sb.AppendLine(); - EmitProp(sb, p, " ", ci.FullTypeName, ci.EnableMetadata); + EmitProp(sb, p, " ", ci.FullTypeName, ci.EnableMetadata, ci.EnablePropertyFilter); } sb.AppendLine(" }"); @@ -668,7 +680,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator { sb.AppendLine(); hasAnyScanProp = true; - EmitScanProp(sb, p, " ", ci.FullTypeName); + EmitScanProp(sb, p, " ", ci.FullTypeName, ci.EnablePropertyFilter); } if (!hasAnyScanProp) @@ -679,7 +691,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine(" }"); } - private static void EmitProp(StringBuilder sb, PropInfo p, string i, string fullTypeName, bool enableMetadata) + private static void EmitProp(StringBuilder sb, PropInfo p, string i, string fullTypeName, bool enableMetadata, bool enablePropertyFilter) { var a = $"obj.{p.Name}"; @@ -699,9 +711,10 @@ public class AcBinarySourceGenerator : IIncrementalGenerator // All non-markerless properties: emit PropertyFilter guard // When filter returns false, write PropertySkip and skip the property write. - // Gated by `UsePropertyFilter` (TEMPORARY const) — `false` skips emit entirely → leaner - // generated code on benchmark workloads where no property-filter is ever set. - if (UsePropertyFilter) + // Gated by `[AcBinarySerializable(EnablePropertyFilterFeature = ...)]` — when false, the entire + // filter-check block is omitted from emit (no `HasPropertyFilter` test, no filter-context allocation, + // no lambda-call) → leaner generated code on hot-path types that never use a property-filter. + if (enablePropertyFilter) { sb.AppendLine($"{i}if (context.HasPropertyFilter)"); sb.AppendLine($"{i}{{"); @@ -911,7 +924,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator /// 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) + private static void EmitScanProp(StringBuilder sb, PropInfo p, string i, string fullTypeName, bool enablePropertyFilter) { // Compile-time proven: no scan work for this property — skip entirely (including PropertyFilter guard) if (!HasScanWork(p)) return; @@ -920,8 +933,8 @@ public class AcBinarySourceGenerator : IIncrementalGenerator // PropertyFilter: must match write pass — if filter skips property, scan must skip too // Only for non-markerless properties (matching EmitProp behavior). - // Gated by `UsePropertyFilter` (TEMPORARY const) — same A/B flag as the writer pass. - if (UsePropertyFilter && !IsMarkerless(p.TypeKind)) + // Gated by `[AcBinarySerializable(EnablePropertyFilterFeature = ...)]` — same gate as the writer pass. + if (enablePropertyFilter && !IsMarkerless(p.TypeKind)) { sb.AppendLine($"{i}if (context.HasPropertyFilter)"); sb.AppendLine($"{i}{{"); @@ -2996,6 +3009,10 @@ internal sealed class SerializableClassInfo public int[] PropertyNameHashes { get; } /// When false, skip inline metadata and use markerless property write for this type. public bool EnableMetadata { get; } + /// True if EnablePropertyFilterFeature is enabled — controls per-property HasPropertyFilter + /// guard emission in WriteProperties / ScanObject. When false, the filter check is omitted entirely + /// → leaner generated code on the hot path (typical for high-throughput types that never use a filter). + public bool EnablePropertyFilter { get; } /// When true, type subtree has IId types needing scan (active in OnlyId + All). public bool NeedsIdScan { get; } /// When true, type subtree has non-IId ref tracking (active only in All mode). @@ -3006,8 +3023,8 @@ internal sealed class SerializableClassInfo public bool NeedsRefScan => NeedsIdScan || NeedsAllRefScan; /// Derived: any scan axis active. public bool NeedsScan => NeedsIdScan || NeedsAllRefScan || NeedsInternScan; - public SerializableClassInfo(string ns, string cn, string ftn, List p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes, bool enableMetadata, bool needsIdScan, bool needsAllRefScan, bool needsInternScan) - { Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; EnableMetadata = enableMetadata; NeedsIdScan = needsIdScan; NeedsAllRefScan = needsAllRefScan; NeedsInternScan = needsInternScan; } + public SerializableClassInfo(string ns, string cn, string ftn, List p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes, bool enableMetadata, bool enablePropertyFilter, bool needsIdScan, bool needsAllRefScan, bool needsInternScan) + { Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; EnableMetadata = enableMetadata; EnablePropertyFilter = enablePropertyFilter; NeedsIdScan = needsIdScan; NeedsAllRefScan = needsAllRefScan; NeedsInternScan = needsInternScan; } } internal sealed class PropInfo diff --git a/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs b/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs index e00d186..b637060 100644 --- a/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs +++ b/AyCode.Core/Serializers/Attributes/AcBinarySerializableAttribute.cs @@ -19,6 +19,19 @@ public sealed class AcBinarySerializableAttribute : Attribute public bool EnableRefHandlingFeature { get; } public bool EnableInternStringFeature { get; } + /// + /// When true (default): the SGen-emitted writer/scan code includes the per-property + /// HasPropertyFilter branch + filter-context allocation + lambda-call. Required for runtime + /// to take effect on this type. + /// When false: the SGen-emitted writer/scan code omits the filter check entirely + /// for every property of this type — leaner generated code on the hot path. Use this when the + /// type is never serialized with a property-filter (typical for high-throughput message types). + /// Filter callbacks set on AcBinarySerializerOptions.PropertyFilter will be silently ignored + /// for this type's properties — only set false if you can guarantee no consumer relies on + /// per-property filtering for this specific type. + /// + public bool EnablePropertyFilterFeature { get; } + public AcBinarySerializableAttribute() : this(true) { } @@ -29,13 +42,15 @@ public sealed class AcBinarySerializableAttribute : Attribute EnableIdTrackingFeature = enableAllFeatures; EnableRefHandlingFeature = enableAllFeatures; EnableInternStringFeature = enableAllFeatures; + EnablePropertyFilterFeature = enableAllFeatures; } - public AcBinarySerializableAttribute(bool enableMetadataFeature, bool enableIdTrackingFeature, bool enableRefHandlingFeature, bool enableInternStringFeature) + public AcBinarySerializableAttribute(bool enableMetadataFeature, bool enableIdTrackingFeature, bool enableRefHandlingFeature, bool enableInternStringFeature, bool enablePropertyFilterFeature) { EnableMetadataFeature = enableMetadataFeature; EnableIdTrackingFeature = enableIdTrackingFeature; EnableRefHandlingFeature = enableRefHandlingFeature; EnableInternStringFeature = enableInternStringFeature; + EnablePropertyFilterFeature = enablePropertyFilterFeature; } } diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs index 3348370..d120a31 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.Read.cs @@ -30,8 +30,10 @@ public static partial class AcBinaryDeserializer get { if (_position < _bufferLength) return false; - // Multi-segment: try advancing to next segment before declaring end. - // ArrayBinaryInput: TryAdvanceSegment => false (JIT eliminates, same as before). + // Trusted-single-segment fast path — JIT folds the constant: for ArrayBinaryInput + // the branch becomes `if (true) return true;` and the TryAdvanceSegment call below is + // dead-code-eliminated. For multi-segment / streaming inputs, the call is preserved. + if (TInput.IsTrustedSingleSegment) return true; return !Input.TryAdvanceSegment(ref _buffer, ref _position, ref _bufferLength, 1); } } @@ -47,27 +49,18 @@ public static partial class AcBinaryDeserializer [MethodImpl(MethodImplOptions.AggressiveInlining)] public byte ReadByte() { - if (_position >= _bufferLength) - { - // Multi-segment: try advancing to next segment before giving up. - // ArrayBinaryInput: TryAdvanceSegment => false (JIT eliminates this branch). - if (!Input.TryAdvanceSegment(ref _buffer, ref _position, ref _bufferLength, 1)) - throw new AcBinaryDeserializationException("Unexpected end of binary payload.", _position); - } - + // Routes through EnsureAvailable to leverage the trusted-single-segment JIT-eliminate guard. + // ArrayBinaryInput: EnsureAvailable body fully eliminated → just `_buffer[_position++]`. + // Multi-segment / streaming: bounds-check + TryAdvanceSegment kept (cross-segment safe). + EnsureAvailable(1); return _buffer[_position++]; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public byte PeekByte() { - if (_position >= _bufferLength) - { - // Multi-segment: try advancing to next segment before giving up. - if (!Input.TryAdvanceSegment(ref _buffer, ref _position, ref _bufferLength, 1)) - throw new AcBinaryDeserializationException("Unexpected end of binary payload.", _position); - } - + // Same trusted-single-segment fast path as ReadByte (no _position advance). + EnsureAvailable(1); return _buffer[_position]; } diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs index c7368e3..34834e8 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs @@ -830,57 +830,86 @@ public static partial class AcBinarySerializer WriteVarUIntUnsafe((uint)bytesWritten); _position += bytesWritten; } + + return; } - else + + switch (bytesWritten) { - // Non-ASCII — post-encode tier choice from bytesWritten (smallest fitting tier wins) - int actualHeader; - byte tierMarker; - switch (bytesWritten) + // Non-ASCII — post-encode tier choice (smallest fitting tier wins). One if-else chain + // per tier; each branch handles shift + header-store + position update inline. + // + // Combined header-store optimization (shift > 0 only): + // When the actual tier downgrades from the predicted reserve (e.g. Medium predicted + // from charLength but Small actual from bytesWritten), the body needs a left-shift + // memcpy. We write the FULL combined header (uint for Small, ulong for Medium) at + // savedPos BEFORE the body memcpy — the slack byte(s) at the high end of the store + // get overwritten by the subsequent memcpy → 1 store instead of 1+N for the header. + // When shift == 0 (predicted tier matches actual), the body is already at its final + // position; a combined store would corrupt the body's first byte(s), so we fall + // back to separate marker + packed-header stores. + // + // Big tier (9-byte header) always has shift == 0 (reserveHeader == actualHeader == 9) + // because Big is the largest tier — no downgrade path possible. The 1-byte marker + + // 8-byte ulong-packed pattern is already minimal (no slack issue, 9 dedicated bytes). + case <= 255: { - case <= 255: - actualHeader = 3; - tierMarker = BinaryTypeCode.StringSmall; - break; - case <= 65535: - actualHeader = 5; - tierMarker = BinaryTypeCode.StringMedium; - break; - default: - actualHeader = 9; - tierMarker = BinaryTypeCode.StringBig; - break; + // Small tier: 3-byte header [marker:1][charLen:8][utf8Len:8] + var shift = reserveHeader - 3; + if (shift > 0) + { + // Combined uint store: 4 bytes physical, 3 bytes meaningful, 1 byte slack + // at savedPos+3 — overwritten by body memcpy below. + var packedFull = (uint)BinaryTypeCode.StringSmall + | ((uint)charLength << 8) + | ((uint)bytesWritten << 16); + Unsafe.WriteUnaligned(ref _buffer[savedPos], packedFull); + _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(savedPos + 3, bytesWritten)); + } + else + { + // shift == 0: body in place at savedPos+3 = encodeStart. Combined uint store + // would corrupt body's first byte; use separate 1-byte marker + 2-byte packed. + _buffer[savedPos] = BinaryTypeCode.StringSmall; + var packedHl = (ushort)(charLength | (bytesWritten << 8)); + Unsafe.WriteUnaligned(ref _buffer[savedPos + 1], packedHl); + } + _position = savedPos + 3 + bytesWritten; + break; } - - var nonAsciiShift = reserveHeader - actualHeader; - if (nonAsciiShift > 0) _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart - nonAsciiShift, bytesWritten)); - - _buffer[savedPos] = tierMarker; - switch (actualHeader) + case <= 65535: { - case 3: + // Medium tier: 5-byte header [marker:1][charLen:16][utf8Len:16] + var shift = reserveHeader - 5; + if (shift > 0) { - // A-direction: pack charLen:8 | utf8Len:8 → single ushort store - var packed = (ushort)(charLength | (bytesWritten << 8)); - Unsafe.WriteUnaligned(ref _buffer[savedPos + 1], packed); - break; + // Combined ulong store: 8 bytes physical, 5 bytes meaningful, 3 bytes slack + // at savedPos+5..7 — overwritten by body memcpy below. + var packedFull = (ulong)BinaryTypeCode.StringMedium + | ((ulong)(uint)charLength << 8) + | ((ulong)(uint)bytesWritten << 24); + Unsafe.WriteUnaligned(ref _buffer[savedPos], packedFull); + _buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(savedPos + 5, bytesWritten)); } - case 5: + else { - // A-direction: pack charLen:16 | utf8Len:16 → single uint store, LE - var packed = (uint)charLength | ((uint)bytesWritten << 16); - Unsafe.WriteUnaligned(ref _buffer[savedPos + 1], packed); - break; - } - default: - { - // A-direction: pack charLen:32 | utf8Len:32 → single ulong store, LE - var packed = (ulong)(uint)charLength | ((ulong)(uint)bytesWritten << 32); - Unsafe.WriteUnaligned(ref _buffer[savedPos + 1], packed); - break; + // shift == 0: separate 1-byte marker + 4-byte packed. + _buffer[savedPos] = BinaryTypeCode.StringMedium; + var packedHl = (uint)charLength | ((uint)bytesWritten << 16); + Unsafe.WriteUnaligned(ref _buffer[savedPos + 1], packedHl); } + _position = savedPos + 5 + bytesWritten; + break; + } + default: + { + // Big tier: 9-byte header [marker:1][charLen:32][utf8Len:32]. shift always 0. + _buffer[savedPos] = BinaryTypeCode.StringBig; + var packed = (ulong)(uint)charLength | ((ulong)(uint)bytesWritten << 32); + Unsafe.WriteUnaligned(ref _buffer[savedPos + 1], packed); + _position = savedPos + 9 + bytesWritten; + break; } - _position = savedPos + actualHeader + bytesWritten; } } diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs index f635814..534b0ab 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs @@ -1663,7 +1663,11 @@ public static partial class AcBinarySerializer { var properties = wrapper.Metadata.Properties; var propCount = properties.Length; - var hasPropertyFilter = context.HasPropertyFilter; + // Combined runtime+attribute gate: if the type opted out of the filter feature via + // [AcBinarySerializable(EnablePropertyFilterFeature = false)], the runtime PropertyFilter is + // ignored for this type — short-circuit eliminates the per-property `ShouldSerializeProperty` + // call entirely. Mirrors the SGen-side gate in `EmitProp`. + var hasPropertyFilter = context.HasPropertyFilter && wrapper.Metadata.EnablePropertyFilterFeature; for (var i = 0; i < propCount; i++) { @@ -1684,7 +1688,8 @@ public static partial class AcBinarySerializer { var properties = wrapper.Metadata.Properties; var propCount = properties.Length; - var hasPropertyFilter = context.HasPropertyFilter; + // Combined runtime+attribute gate — see WritePropertiesWithMeta for the rationale. + var hasPropertyFilter = context.HasPropertyFilter && wrapper.Metadata.EnablePropertyFilterFeature; for (var i = 0; i < propCount; i++) { diff --git a/AyCode.Core/Serializers/TypeMetadataBase.cs b/AyCode.Core/Serializers/TypeMetadataBase.cs index 703362e..6d974e0 100644 --- a/AyCode.Core/Serializers/TypeMetadataBase.cs +++ b/AyCode.Core/Serializers/TypeMetadataBase.cs @@ -138,6 +138,19 @@ public abstract class TypeMetadataBase /// public bool EnableMetadataFeature { get; } + /// + /// When false, this type's runtime write loop skips the per-property + /// PropertyFilter callback check entirely — runtime-set filters on + /// + /// are silently ignored for this type. + /// Read from [AcBinarySerializable(EnablePropertyFilterFeature = ...)]; defaults to true. + /// Mirrors the SGen-side gate in AcBinarySourceGenerator.EmitProp: when false, the SGen + /// emit omits the filter-check block entirely. Runtime-side this flag combines with + /// context.HasPropertyFilter via short-circuit AND to drop the per-property branch + /// for filter-disabled types. + /// + public bool EnablePropertyFilterFeature { get; } + /// /// True if this type is a primitive, string, enum, Guid, DateTime, etc. /// Pre-computed to avoid IsPrimitiveOrStringFast() calls in hot path. @@ -240,6 +253,7 @@ public abstract class TypeMetadataBase var serializableAttr = type.GetCustomAttribute(inherit: false); EnableMetadataFeature = serializableAttr == null || serializableAttr.EnableMetadataFeature; + EnablePropertyFilterFeature = serializableAttr == null || serializableAttr.EnablePropertyFilterFeature; if (serializableAttr is { EnableIdTrackingFeature: false }) { IsIId = false;