1560 lines
68 KiB
C#
1560 lines
68 KiB
C#
using System.Buffers;
|
||
using System.Collections.Concurrent;
|
||
using System.Numerics;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Runtime.InteropServices;
|
||
using static AyCode.Core.Helpers.JsonUtilities;
|
||
|
||
namespace AyCode.Core.Serializers.Binaries;
|
||
|
||
public static partial class AcBinarySerializer
|
||
{
|
||
private static class BinarySerializationContextPool<TOutput> where TOutput : struct, IBinaryOutputBase
|
||
{
|
||
private static readonly ConcurrentQueue<BinarySerializationContext<TOutput>> Pool = new();
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public static BinarySerializationContext<TOutput> Get(AcBinarySerializerOptions options)
|
||
{
|
||
if (Pool.TryDequeue(out var context))
|
||
{
|
||
context.Reset(options);
|
||
return context;
|
||
}
|
||
|
||
return new BinarySerializationContext<TOutput>(options);
|
||
}
|
||
|
||
public static void ReturnAsync(BinarySerializationContext<TOutput> context)
|
||
{
|
||
// 🔥 FIRE-AND-FORGET: cleanup háttérben
|
||
ThreadPool.UnsafeQueueUserWorkItem(Return, context, preferLocal: true);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public static void Return(BinarySerializationContext<TOutput> context)
|
||
{
|
||
if (Pool.Count < context.Options.MaxContextPoolSize)
|
||
{
|
||
context.Clear();
|
||
Pool.Enqueue(context);
|
||
}
|
||
else
|
||
{
|
||
context.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Binary serialization context. Generic on TOutput for output strategy selection.
|
||
/// Owns _buffer/_position for zero virtual dispatch on the hot path.
|
||
/// All write operations (WriteByte, WriteVarUInt, etc.) are inline methods here.
|
||
/// TOutput Output handles only cold-path buffer management (Grow/Initialize) and finalization.
|
||
/// </summary>
|
||
internal sealed partial class BinarySerializationContext<TOutput>
|
||
: SerializationContextBase<BinarySerializeTypeMetadata, AcBinarySerializerOptions>, IDisposable
|
||
where TOutput : struct, IBinaryOutputBase
|
||
{
|
||
private const int PropertyIndexBufferMaxCache = 512;
|
||
private const int PropertyStateBufferMaxCache = 512;
|
||
|
||
/// <summary>
|
||
/// Output target — handles only Grow (cold path) and finalization (AsSpan/ToArray/Flush).
|
||
/// </summary>
|
||
public TOutput Output;
|
||
|
||
/// <summary>
|
||
/// True if Output has been assigned (struct can't be null-checked).
|
||
/// </summary>
|
||
public bool OutputInitialized;
|
||
|
||
#region Buffer State — owned by context for zero virtual dispatch
|
||
|
||
/// <summary>Current writable buffer (from ArrayPool or IBufferWriter chunk).</summary>
|
||
internal byte[] _buffer = null!;
|
||
|
||
/// <summary>Current write position within _buffer.</summary>
|
||
internal int _position;
|
||
|
||
/// <summary>One past the last writable index in _buffer. Write must satisfy _position < _bufferEnd.</summary>
|
||
internal int _bufferEnd;
|
||
|
||
#endregion
|
||
|
||
private IdentityMap<string, InternEntry>? _stringInternMap;
|
||
private int _nextCacheIndex;
|
||
public int NextFirstIndex; // Next first occurrence index for scan pass. Direct access for performance.
|
||
|
||
/// <summary>
|
||
/// Global recursion depth counter — final safety net against pathological graphs and non-IId cycles.
|
||
/// Incremented at <see cref="EnterRecursion"/> (before marker write at each object-recursion entry),
|
||
/// decremented at <see cref="ExitRecursion"/> (at WriteProperties/ScanObject body exit).
|
||
/// Checked against <see cref="AcSerializerContextBase{TMetadata,TOptions}.MaxDepth"/> (byte, default 255)
|
||
/// inside <see cref="TryHandleMaxDepth"/> which fires BEFORE any marker byte is written.
|
||
/// </summary>
|
||
private byte _recursionDepth;
|
||
|
||
/// <summary>
|
||
/// Pre-computed depth-check gate, set at <see cref="Reset"/>: <c>MaxDepthBehavior != Disable</c>.
|
||
/// Only the explicit <see cref="MaxDepthBehavior.Disable"/> opts out. <see cref="MaxDepthBehavior.Throw"/>
|
||
/// stays active even with <c>HasAllRefHandling=true</c>, because per-type <c>EnableRefHandling=false</c>
|
||
/// attribute opt-outs leave gaps that ref-handling alone cannot cover (cycle through a non-tracked type).
|
||
/// </summary>
|
||
private bool _needsDepthCheck;
|
||
|
||
/// <summary>
|
||
/// Pre-computed action when depth reaches MaxDepth. Consulted on the rare depth-limit-hit cold path only.
|
||
/// </summary>
|
||
private MaxDepthBehavior _maxDepthAction;
|
||
|
||
#region WriteDuplicateEntry — scan pass output for write pass cursor
|
||
|
||
private WriteDuplicateEntry[]? _writePlan;
|
||
private int _writePlanCount;
|
||
|
||
/// <summary>Unified scan visit counter. Increments on every IId object and internable string visit.</summary>
|
||
public int ScanVisitIndex;
|
||
|
||
/// <summary>Write plan entry count for write pass cursor.</summary>
|
||
internal int WritePlanCount => _writePlanCount;
|
||
|
||
/// <summary>Write plan array for write pass cursor. Sorted by VisitIndex after scan pass.</summary>
|
||
internal WriteDuplicateEntry[]? WritePlan => _writePlan;
|
||
|
||
/// <summary>
|
||
/// Adds a pre-computed write instruction for a duplicate string or IId object reference.
|
||
/// </summary>
|
||
public void AddWriteDuplicateEntry(int visitIndex, int cacheMapIndex, bool isFirst, string? value)
|
||
{
|
||
if (_writePlan == null)
|
||
{
|
||
_writePlan = ArrayPool<WriteDuplicateEntry>.Shared.Rent(16);
|
||
}
|
||
else if (_writePlanCount >= _writePlan.Length)
|
||
{
|
||
var newArray = ArrayPool<WriteDuplicateEntry>.Shared.Rent(_writePlan.Length * 2);
|
||
_writePlan.AsSpan(0, _writePlanCount).CopyTo(newArray);
|
||
ArrayPool<WriteDuplicateEntry>.Shared.Return(_writePlan, clearArray: true);
|
||
_writePlan = newArray;
|
||
}
|
||
|
||
ref var entry = ref _writePlan[_writePlanCount++];
|
||
entry.VisitIndex = visitIndex;
|
||
entry.CacheMapIndex = cacheMapIndex;
|
||
entry.IsFirst = isFirst;
|
||
entry.Value = value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sorts write plan by VisitIndex for sequential cursor consumption in write pass.
|
||
/// Called once after scan pass completes.
|
||
/// </summary>
|
||
internal void SortWritePlan()
|
||
{
|
||
if (_writePlanCount > 1)
|
||
_writePlan.AsSpan(0, _writePlanCount).Sort(static (a, b) => a.VisitIndex.CompareTo(b.VisitIndex));
|
||
|
||
_nextWritePlanVisitIndex = _writePlanCount > 0 ? _writePlan![0].VisitIndex : int.MaxValue;
|
||
}
|
||
|
||
/// <summary>Write pass cursor index into sorted _writePlan array.</summary>
|
||
internal int WritePlanCursor;
|
||
|
||
/// <summary>Write pass visit counter. Mirrors ScanVisitIndex ordering.</summary>
|
||
internal int WriteVisitIndex;
|
||
|
||
/// <summary>
|
||
/// Pre-cached VisitIndex of the next write plan entry, or int.MaxValue when exhausted.
|
||
/// </summary>
|
||
private int _nextWritePlanVisitIndex = int.MaxValue;
|
||
|
||
/// <summary>
|
||
/// Set per-property in WritePropertyOrSkip before calling WriteString.
|
||
/// Controls whether the current string property participates in the cursor-based interning.
|
||
/// Must mirror scan pass's prop.UseStringPropertyInterning() check.
|
||
/// </summary>
|
||
internal bool StringInternEligible;
|
||
|
||
/// <summary>
|
||
/// Next polymorphic type cache index. Assigned sequentially on first polymorphic write per runtime type.
|
||
/// Used together with TypeMetadataWrapper.PolymorphicSeen/PolymorphicCacheIndex.
|
||
/// </summary>
|
||
internal int _nextTypeSlot;
|
||
|
||
/// <summary>
|
||
/// Tries to consume the next write plan entry at the current WriteVisitIndex.
|
||
/// Returns true if the entry matches (duplicate exists at this visit point).
|
||
/// Always increments WriteVisitIndex.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
internal bool TryConsumeWritePlanEntry(out WriteDuplicateEntry entry)
|
||
{
|
||
var visitIndex = WriteVisitIndex++;
|
||
if (visitIndex == _nextWritePlanVisitIndex)
|
||
{
|
||
entry = _writePlan![WritePlanCursor++];
|
||
_nextWritePlanVisitIndex = WritePlanCursor < _writePlanCount
|
||
? _writePlan[WritePlanCursor].VisitIndex
|
||
: int.MaxValue;
|
||
return true;
|
||
}
|
||
entry = default;
|
||
return false;
|
||
}
|
||
|
||
#endregion
|
||
|
||
/// <summary>
|
||
/// Next cache index reference for scan pass. Direct ref access for TryTrack methods.
|
||
/// </summary>
|
||
public ref int NextCacheIndexRef
|
||
{
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
get => ref _nextCacheIndex;
|
||
}
|
||
|
||
private int[]? _propertyIndexBuffer;
|
||
private byte[]? _propertyStateBuffer;
|
||
|
||
/// <summary>
|
||
/// Current property being serialized. Set in WriteObject property loops.
|
||
/// Used by WriteString for per-property interning control (UseStringPropertyInterning).
|
||
/// null when writing non-property values (arrays, dictionaries, root value).
|
||
/// </summary>
|
||
//internal BinaryPropertyAccessorBase? CurrentProperty;
|
||
|
||
#if DEBUG
|
||
/// <summary>
|
||
/// DEBUG ONLY: Callback invoked when a string is registered for interning.
|
||
/// Parameters: (propertyPath, stringValue)
|
||
/// Use this to analyze which properties have repeated string values.
|
||
/// </summary>
|
||
internal Action<string?, string>? OnStringInterned;
|
||
#endif
|
||
|
||
// These properties delegate to Options for convenience
|
||
internal bool HasStringInterning { get; private set; }
|
||
internal bool UseStringInterning => HasStringInterning;
|
||
|
||
/// <summary>
|
||
/// Pre-computed <c>1 << (int)Options.UseStringInterning</c>.
|
||
/// Used by sgen scan (per-object intern check) and sgen write (per-property StringInternEligible).
|
||
/// Avoids repeated field chain traversal (context.Options.UseStringInterning) + shift per call site.
|
||
/// Value: None=1, Attribute=2, All=4.
|
||
/// </summary>
|
||
public int InternBit { get; private set; }
|
||
|
||
public bool IsValidForInterningString(int strLength)
|
||
{
|
||
return strLength >= MinStringInternLength && (MaxStringInternLength == 0 || strLength <= MaxStringInternLength);
|
||
}
|
||
|
||
/// <summary>
|
||
/// True if we have interning/ref tracking (cache count needed in header).
|
||
/// </summary>
|
||
public bool HasCaching => HasStringInterning || HasRefHandling;
|
||
public bool UseMetadata => Options.UseMetadata;
|
||
public bool UseGeneratedCode => Options.UseGeneratedCode;
|
||
|
||
/// <summary>
|
||
/// True when generated writers can bypass WriteObject entirely and write markers + properties inline.
|
||
/// Requires: no UseMetadata (no inline metadata tracking).
|
||
/// PropertyFilter is handled by generated code's per-property filter checks.
|
||
/// Reference handling is safe because generated code inlines TryConsumeWritePlanEntry for IId types.
|
||
/// </summary>
|
||
public bool IsDirectObjectWrite => !UseMetadata;
|
||
public bool FastWire { get; private set; }
|
||
public byte MinStringInternLength => Options.MinStringInternLength;
|
||
public byte MaxStringInternLength => Options.MaxStringInternLength;
|
||
public BinaryPropertyFilter? PropertyFilter => Options.PropertyFilter;
|
||
|
||
/// <summary>
|
||
/// Cached check for PropertyFilter != null. Set in Reset() to avoid property getter in hot loop.
|
||
/// </summary>
|
||
public bool HasPropertyFilter { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Current output position (total bytes written so far).
|
||
/// Cold path — uses virtual dispatch through Output.GetTotalPosition.
|
||
/// </summary>
|
||
public int Position
|
||
{
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
get => Output.GetTotalPosition(_position);
|
||
}
|
||
|
||
public BinarySerializationContext(AcBinarySerializerOptions options)
|
||
{
|
||
Reset(options);
|
||
InitializeWrapperSlots(Volatile.Read(ref s_nextWrapperSlot));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Factory for creating BinarySerializeTypeMetadata instances.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The lambda's <c>t</c> parameter explicitly declares DAMs to match the
|
||
/// <see cref="MetadataFactoryDelegate"/> signature — DAMs do not auto-propagate from the
|
||
/// delegate type to lambda parameters.
|
||
/// </remarks>
|
||
protected override MetadataFactoryDelegate MetadataFactory
|
||
=> static ([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] t)
|
||
=> new BinarySerializeTypeMetadata(t, HasJsonIgnoreAttribute);
|
||
|
||
public override void Reset(AcBinarySerializerOptions options)
|
||
{
|
||
// IMPORTANT: base.Reset sets Options first, so derived code can use Options-derived properties
|
||
base.Reset(options);
|
||
|
||
HasPropertyFilter = Options.PropertyFilter != null;
|
||
|
||
InternBit = 1 << (int)Options.UseStringInterning;
|
||
HasStringInterning = Options.UseStringInterning != StringInterningMode.None;
|
||
|
||
FastWire = Options.WireMode == WireMode.Fast;
|
||
|
||
// Pre-compute depth-check gate. Only `MaxDepthBehavior.Disable` opts out:
|
||
// - `Throw` (default): safety net. `HasAllRefHandling=true` is NOT enough on its own — per-type
|
||
// `EnableRefHandling=false` attribute opt-outs leave non-tracked types that can still form cycles.
|
||
// The safety net catches those + pathological-depth non-cyclic graphs.
|
||
// - `Truncate`: explicit developer intent for intentional shallow serialization (delta updates,
|
||
// view-model projections). Always active when set, regardless of ref-handling mode.
|
||
// - `Disable`: explicit opt-out — dev guarantees cycle-free + bounded-depth graph.
|
||
// (MaxDepth value itself is NOT an opt-out — 0 means shallow-copy semantic when paired with Truncate.)
|
||
_maxDepthAction = Options.MaxDepthBehavior;
|
||
_needsDepthCheck = _maxDepthAction != MaxDepthBehavior.Disable;
|
||
_recursionDepth = 0;
|
||
}
|
||
|
||
public override void Clear()
|
||
{
|
||
// Reset output buffer (large buffers → return to pool, rent halved)
|
||
if (OutputInitialized)
|
||
{
|
||
Output.Reset();
|
||
}
|
||
|
||
_stringInternMap?.Reset();
|
||
|
||
_nextCacheIndex = 0;
|
||
NextFirstIndex = 0;
|
||
ScanVisitIndex = 0;
|
||
_recursionDepth = 0;
|
||
WritePlanCursor = 0;
|
||
WriteVisitIndex = 0;
|
||
_nextWritePlanVisitIndex = int.MaxValue;
|
||
StringInternEligible = false;
|
||
_nextTypeSlot = 0;
|
||
|
||
// Clear write plan string references to avoid GC pinning, keep array if small enough
|
||
if (_writePlan != null)
|
||
{
|
||
_writePlan.AsSpan(0, _writePlanCount).Clear();
|
||
if (_writePlan.Length > PropertyIndexBufferMaxCache)
|
||
{
|
||
ArrayPool<WriteDuplicateEntry>.Shared.Return(_writePlan);
|
||
_writePlan = null;
|
||
}
|
||
}
|
||
_writePlanCount = 0;
|
||
|
||
if (_propertyIndexBuffer != null && _propertyIndexBuffer.Length > PropertyIndexBufferMaxCache)
|
||
{
|
||
ArrayPool<int>.Shared.Return(_propertyIndexBuffer);
|
||
_propertyIndexBuffer = null;
|
||
}
|
||
|
||
if (_propertyStateBuffer != null && _propertyStateBuffer.Length > PropertyStateBufferMaxCache)
|
||
{
|
||
ArrayPool<byte>.Shared.Return(_propertyStateBuffer);
|
||
_propertyStateBuffer = null;
|
||
}
|
||
|
||
// Clear wrapper tracking - returns IdentityMap arrays to pool
|
||
base.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Hot wrapper: combined depth check + recursion enter. Gated by <c>_needsDepthCheck</c>.
|
||
/// On limit hit: dispatches to <see cref="OnMaxDepthHit"/> (truncate writes Null or throw) and returns
|
||
/// <c>true</c> — caller must skip marker write + recursive call. NO inc on this path.
|
||
/// On miss: increments <c>_recursionDepth</c> and returns <c>false</c> — caller proceeds with marker write
|
||
/// and recursive body. <see cref="ExitRecursion"/> at body exit balances the inc.
|
||
/// Called BEFORE any marker write — wire-correct for any marker width.
|
||
/// 2nd-occurrence ObjectRef paths (which don't actually recurse into a body) must call
|
||
/// <see cref="ExitRecursion"/> to undo the inc that this method did on the success path.
|
||
/// Single ctx field-read pattern: 2 ops merged into 1 method call vs the prior 3-method split.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
internal bool TryEnterRecursion(bool hasTruncatePath)
|
||
{
|
||
if (!_needsDepthCheck) return false;
|
||
if (_recursionDepth >= _maxDepth)
|
||
{
|
||
OnMaxDepthHit(hasTruncatePath);
|
||
return true;
|
||
}
|
||
_recursionDepth++;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dec <c>_recursionDepth</c> if depth tracking is active. Called at WriteProperties/ScanObject body exit
|
||
/// to balance the corresponding <see cref="TryEnterRecursion"/> call on its success path.
|
||
/// Also called on 2nd-occurrence ObjectRef paths in <c>WriteObjectFullMarker*</c> / <c>WriteObjectRefMarker*</c>
|
||
/// to undo the inc (2nd-occ writes ObjectRef without recursing — wrote no body, must dec back).
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
internal void ExitRecursion()
|
||
{
|
||
if (_needsDepthCheck) _recursionDepth--;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Cold path: dispatches on <c>_maxDepthAction</c> when the depth limit is hit.
|
||
/// <list type="bullet">
|
||
/// <item><c>Throw</c>: throws <see cref="InvalidOperationException"/> (offending type name comes from the stack trace)</item>
|
||
/// <item><c>Truncate</c> + <paramref name="hasTruncatePath"/>=<c>true</c> (write pass): writes <c>Null</c> marker in place of the object — intentional shallow serialization. Wire-correct: no marker has been written yet, so no rewind needed</item>
|
||
/// <item><c>Truncate</c> + <paramref name="hasTruncatePath"/>=<c>false</c> (scan pass): no-op — caller's return skips children scan</item>
|
||
/// </list>
|
||
/// <c>NoInlining</c> keeps the throw / WriteByte body out of the hot caller (smaller i-cache footprint).
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
private void OnMaxDepthHit(bool hasTruncatePath)
|
||
{
|
||
if (_maxDepthAction == MaxDepthBehavior.Throw)
|
||
throw new InvalidOperationException(
|
||
$"AcBinary: recursion depth exceeded MaxDepth={_maxDepth} (depth={_recursionDepth}, position={Position})");
|
||
// Truncate: write Null in place of the object. No rewind — check fires BEFORE any marker write.
|
||
if (hasTruncatePath) WriteByte(BinaryTypeCode.Null);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_propertyIndexBuffer != null)
|
||
{
|
||
ArrayPool<int>.Shared.Return(_propertyIndexBuffer);
|
||
_propertyIndexBuffer = null;
|
||
}
|
||
|
||
if (_propertyStateBuffer != null)
|
||
{
|
||
ArrayPool<byte>.Shared.Return(_propertyStateBuffer);
|
||
_propertyStateBuffer = null;
|
||
}
|
||
|
||
// Dispose the output if it implements IDisposable (e.g. ArrayBinaryOutput returns buffer to pool)
|
||
if (Output is IDisposable disposableOutput)
|
||
disposableOutput.Dispose();
|
||
}
|
||
|
||
#region Write Methods — inline, zero virtual dispatch
|
||
|
||
/// <summary>
|
||
/// Unchecked <c>ref byte</c> into <see cref="_buffer"/> at <paramref name="position"/> — omits the
|
||
/// JIT array bounds-check that a plain <c>_buffer[position]</c> index emits on every write.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Safe by the buffer invariant: every write primitive first guarantees <c>position < _bufferEnd</c>
|
||
/// (the <c>_position < _bufferEnd</c> grow-guard or <see cref="EnsureCapacity"/>), and <c>_bufferEnd</c>
|
||
/// is "one past the last writable index in <c>_buffer</c>" ⇒ <c>_bufferEnd <= _buffer.Length</c>.
|
||
/// So <c>position < _buffer.Length</c> already holds — the capacity guard IS the bounds check.
|
||
/// The JIT cannot see the <c>_bufferEnd <= _buffer.Length</c> relation, so <c>_buffer[position]</c>
|
||
/// would emit a second, redundant <c>cmp/jae</c> per write; this helper removes it. DEBUG builds keep
|
||
/// an explicit guard so a misbehaving <c>IBinaryOutputBase</c> surfaces loudly, not as corruption.
|
||
/// </remarks>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
private ref byte BufferAt(int position)
|
||
{
|
||
#if DEBUG
|
||
if ((uint)position >= (uint)_buffer.Length)
|
||
throw new InvalidOperationException(
|
||
$"BufferAt({position}) out of range — buffer invariant violated " +
|
||
$"(_buffer.Length={_buffer.Length}, _bufferEnd={_bufferEnd}).");
|
||
#endif
|
||
return ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_buffer), (nint)(uint)position);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
private void EnsureCapacity(int additionalBytes)
|
||
{
|
||
if (_position + additionalBytes > _bufferEnd) GrowAndValidate(additionalBytes);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Slow path for <see cref="EnsureCapacity"/>: invokes <c>Output.Grow</c> and revalidates that
|
||
/// the request was actually satisfied. A chunk-limited output (e.g. <c>AsyncPipeWriterOutput</c>
|
||
/// on a single-value write > <c>MaxChunkSize</c> data bytes) may return WITHOUT growing the
|
||
/// active buffer to the requested size — silent under-provisioning would cause downstream
|
||
/// <c>ArgumentOutOfRangeException</c> in <c>AsSpan</c>/<c>CopyTo</c> at corrupt offsets.
|
||
/// <c>NoInlining</c> keeps the hot path of <see cref="EnsureCapacity"/> tiny and inline-friendly.
|
||
/// Related limit: see <c>BINARY_ASYNCPIPE_ISSUES.md#accore-bin-i-b7k9</c>.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
private void GrowAndValidate(int additionalBytes)
|
||
{
|
||
Output.Grow(ref _buffer, ref _position, ref _bufferEnd, additionalBytes);
|
||
|
||
var available = _bufferEnd - _position;
|
||
if (available < additionalBytes) ThrowGrowFailedToSatisfy(additionalBytes, available);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteByte(byte value)
|
||
{
|
||
if (_position >= _bufferEnd) GrowOne();
|
||
BufferAt(_position++) = value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Cold-path single-byte grow helper. Outlined from the hot-path 1-byte writers
|
||
/// (<see cref="WriteByte"/>, <see cref="WriteVarUInt"/> / <see cref="WriteVarULong"/> fast-paths)
|
||
/// so the inliner cost-models the hot path WITHOUT the 4-ref-arg <c>Output.Grow</c> call's
|
||
/// argument-prep IL. This keeps the per-property hot-path tight enough for AOT to inline the
|
||
/// write into the source-gen <c>WriteProperties</c> body across many call-sites — the cumulative
|
||
/// gain shows up on graph-heavy cells where every leaf serialises ~6 primitive properties.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
private void GrowOne() => Output.Grow(ref _buffer, ref _position, ref _bufferEnd, 1);
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteTwoBytes(byte b1, byte b2)
|
||
{
|
||
EnsureCapacity(2);
|
||
WriteTwoBytesUnsafe(b1, b2);
|
||
}
|
||
|
||
/// <summary>Writes two bytes without capacity check. Caller must ensure buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteTwoBytesUnsafe(byte b1, byte b2)
|
||
{
|
||
BufferAt(_position++) = b1;
|
||
BufferAt(_position++) = b2;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteBytes(ReadOnlySpan<byte> data)
|
||
{
|
||
EnsureCapacity(data.Length);
|
||
WriteBytesUnsafe(data);
|
||
}
|
||
|
||
/// <summary>Writes a span of bytes without capacity check. Caller must ensure buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteBytesUnsafe(ReadOnlySpan<byte> data)
|
||
{
|
||
data.CopyTo(_buffer.AsSpan(_position));
|
||
_position += data.Length;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteRaw<T>(T value) where T : unmanaged
|
||
{
|
||
EnsureCapacity(Unsafe.SizeOf<T>());
|
||
WriteRawUnsafe(value);
|
||
}
|
||
|
||
/// <summary>Writes an unmanaged value without capacity check. Caller must ensure buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteRawUnsafe<T>(T value) where T : unmanaged
|
||
{
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position), value);
|
||
_position += Unsafe.SizeOf<T>();
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteTypeCodeAndRaw<T>(byte typeCode, T value) where T : unmanaged
|
||
{
|
||
EnsureCapacity(1 + Unsafe.SizeOf<T>());
|
||
WriteTypeCodeAndRawUnsafe(typeCode, value);
|
||
}
|
||
|
||
/// <summary>Writes a type code + unmanaged value without capacity check. Caller must ensure buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteTypeCodeAndRawUnsafe<T>(byte typeCode, T value) where T : unmanaged
|
||
{
|
||
BufferAt(_position++) = typeCode;
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position), value);
|
||
_position += Unsafe.SizeOf<T>();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region VarInt Encoding — inline
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteVarUInt(uint value)
|
||
{
|
||
//if (FastWire) { WriteRaw(value); return; }
|
||
if (value < 0x80)
|
||
{
|
||
if (_position >= _bufferEnd) GrowOne();
|
||
BufferAt(_position++) = (byte)value;
|
||
return;
|
||
}
|
||
EnsureCapacity(5);
|
||
WriteVarUIntMultiByteUnsafe(value);
|
||
}
|
||
|
||
/// <summary>Writes a VarUInt without capacity check. Caller must ensure at least 5 bytes of buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteVarUIntUnsafe(uint value)
|
||
{
|
||
if (value < 0x80)
|
||
{
|
||
BufferAt(_position++) = (byte)value;
|
||
return;
|
||
}
|
||
|
||
WriteVarUIntMultiByteUnsafe(value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prefix-tier VarUInt encoding (UTF-8-style: first byte's high bits encode total size).
|
||
/// Compact path: <see cref="BitOperations.Log2"/> picks the tier (2/3/4) in O(1), then a single
|
||
/// <see cref="Unsafe.WriteUnaligned{T}"/><<see cref="uint"/>> stores [prefix-byte | value-bytes LE]
|
||
/// in one machine instruction. 5-byte tier uses one byte + one uint32 store.
|
||
/// Tier table:
|
||
/// 0xxxxxxx → 1 byte (handled inline by caller)
|
||
/// 10xxxxxx + 1B → 2 byte, 128..16 383 (14 bit)
|
||
/// 110xxxxx + 2B LE → 3 byte, 16 384..2 097 151 (21 bit)
|
||
/// 1110xxxx + 3B LE → 4 byte, 2 097 152..268 435 455 (28 bit)
|
||
/// 1111xxxx + 4B LE → 5 byte, 268 435 456..uint.MaxValue (32 bit; prefix nibble unused)
|
||
/// Caller MUST ensure ≥5 bytes of buffer space (interface contract) — the uint32 store on the
|
||
/// 2/3/4-byte tiers writes 4 bytes even though only `tier` bytes are advanced; the trailing
|
||
/// 1-2 bytes get overwritten by the next encoded element. Little-endian host assumed (all
|
||
/// shipping .NET 9 platforms).
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
private void WriteVarUIntMultiByteUnsafe(uint value)
|
||
{
|
||
// Writes EXACTLY `tier` bytes per call — does NOT overrun into following buffer space.
|
||
// (The earlier Unsafe.WriteUnaligned<uint> compact path wrote 4 bytes on 2/3-byte tiers
|
||
// expecting the trailing 1-2 bytes to be overwritten by the next encoded element. That
|
||
// assumption breaks for callers using a savedPos-rewind-then-prefix pattern — e.g.
|
||
// WriteStringUtf8 where the rewinded position sits right before already-emitted UTF-8
|
||
// bytes; a 4-byte uint store overwrites the first 1-2 bytes of the UTF-8 body with zero
|
||
// padding, corrupting the string on the wire.)
|
||
|
||
BufferAt(_position + 1) = (byte)value;
|
||
if (value < 0x4000)
|
||
{
|
||
// 2-byte tier: 10xxxxxx (high 6 bits of value) + low 8 bits.
|
||
BufferAt(_position) = (byte)(0x80 | (value >> 8));
|
||
_position += 2;
|
||
return;
|
||
}
|
||
|
||
BufferAt(_position + 2) = (byte)(value >> 8);
|
||
if (value < 0x200000)
|
||
{
|
||
// 3-byte tier: 110xxxxx (high 5 bits) + 2 bytes LE (low 16 bits).
|
||
BufferAt(_position) = (byte)(0xC0 | (value >> 16));
|
||
_position += 3;
|
||
return;
|
||
}
|
||
|
||
BufferAt(_position + 3) = (byte)(value >> 16);
|
||
if (value < 0x10000000)
|
||
{
|
||
// 4-byte tier: 1110xxxx (high 4 bits) + 3 bytes LE (low 24 bits).
|
||
BufferAt(_position) = (byte)(0xE0 | (value >> 24));
|
||
|
||
_position += 4;
|
||
return;
|
||
}
|
||
// 5-byte tier: 1111xxxx (low nibble unused) + 4 bytes LE (full uint32).
|
||
BufferAt(_position) = 0xF0;
|
||
BufferAt(_position + 4) = (byte)(value >> 24);
|
||
_position += 5;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
private static int VarUIntSize(uint value)
|
||
{
|
||
return value switch
|
||
{
|
||
< 0x80 => 1,
|
||
< 0x4000 => 2,
|
||
< 0x200000 => 3,
|
||
< 0x10000000 => 4,
|
||
_ => 5
|
||
};
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteVarInt(int value)
|
||
{
|
||
//if (FastWire) { WriteRaw(value); return; }
|
||
var encoded = (uint)((value << 1) ^ (value >> 31));
|
||
WriteVarUInt(encoded);
|
||
}
|
||
|
||
/// <summary>Writes a zigzag-encoded VarInt without capacity check. Caller must ensure at least 5 bytes of buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteVarIntUnsafe(int value)
|
||
{
|
||
var encoded = (uint)((value << 1) ^ (value >> 31));
|
||
WriteVarUIntUnsafe(encoded);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteVarULong(ulong value)
|
||
{
|
||
//if (FastWire) { WriteRaw(value); return; }
|
||
if (value < 0x80)
|
||
{
|
||
if (_position >= _bufferEnd) GrowOne();
|
||
|
||
BufferAt(_position++) = (byte)value;
|
||
return;
|
||
}
|
||
|
||
EnsureCapacity(10);
|
||
WriteVarULongMultiByteUnsafe(value);
|
||
}
|
||
|
||
/// <summary>Writes a VarULong without capacity check. Caller must ensure at least 10 bytes of buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteVarULongUnsafe(ulong value)
|
||
{
|
||
if (value < 0x80)
|
||
{
|
||
BufferAt(_position++) = (byte)value;
|
||
return;
|
||
}
|
||
|
||
WriteVarULongMultiByteUnsafe(value);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
private void WriteVarULongMultiByteUnsafe(ulong value)
|
||
{
|
||
while (value >= 0x80)
|
||
{
|
||
BufferAt(_position++) = (byte)(value | 0x80);
|
||
value >>= 7;
|
||
}
|
||
|
||
BufferAt(_position++) = (byte)value;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteVarLong(long value)
|
||
{
|
||
//if (FastWire) { WriteRaw(value); return; }
|
||
var encoded = (ulong)((value << 1) ^ (value >> 63));
|
||
WriteVarULong(encoded);
|
||
}
|
||
|
||
/// <summary>Writes a zigzag-encoded VarLong without capacity check. Caller must ensure at least 10 bytes of buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteVarLongUnsafe(long value)
|
||
{
|
||
var encoded = (ulong)((value << 1) ^ (value >> 63));
|
||
WriteVarULongUnsafe(encoded);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Specialized Types — inline
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteDecimalBits(decimal value)
|
||
{
|
||
EnsureCapacity(16);
|
||
WriteDecimalBitsUnsafe(value);
|
||
}
|
||
|
||
/// <summary>Writes 16-byte decimal bits without capacity check. Caller must ensure buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteDecimalBitsUnsafe(decimal value)
|
||
{
|
||
Span<int> bits = stackalloc int[4];
|
||
decimal.TryGetBits(value, bits, out _);
|
||
|
||
MemoryMarshal.AsBytes(bits).CopyTo(_buffer.AsSpan(_position, 16));
|
||
_position += 16;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteDateTimeBits(DateTime value)
|
||
{
|
||
EnsureCapacity(9);
|
||
WriteDateTimeBitsUnsafe(value);
|
||
}
|
||
|
||
/// <summary>Writes 9-byte DateTime (Ticks + Kind) without capacity check. Caller must ensure buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteDateTimeBitsUnsafe(DateTime value)
|
||
{
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position), value.Ticks);
|
||
BufferAt(_position + 8) = (byte)value.Kind;
|
||
_position += 9;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteGuidBits(Guid value)
|
||
{
|
||
EnsureCapacity(16);
|
||
WriteGuidBitsUnsafe(value);
|
||
}
|
||
|
||
/// <summary>Writes 16-byte Guid without capacity check. Caller must ensure buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteGuidBitsUnsafe(Guid value)
|
||
{
|
||
value.TryWriteBytes(_buffer.AsSpan(_position, 16));
|
||
_position += 16;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteDateTimeOffsetBits(DateTimeOffset value)
|
||
{
|
||
EnsureCapacity(10);
|
||
WriteDateTimeOffsetBitsUnsafe(value);
|
||
}
|
||
|
||
/// <summary>Writes 10-byte DateTimeOffset (UtcTicks + Offset) without capacity check. Caller must ensure buffer space.</summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteDateTimeOffsetBitsUnsafe(DateTimeOffset value)
|
||
{
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position), value.UtcTicks);
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position + 8), (short)value.Offset.TotalMinutes);
|
||
_position += 10;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region String Writes — inline
|
||
|
||
/// <summary>
|
||
/// FastWire markerless string write — int32 sentinel header. Self-contained: handles all three
|
||
/// states (null / empty / content) via int32 dispatch. <c>-1</c> = null, <c>0</c> = empty,
|
||
/// <c>N > 0</c> = content (followed by N×2 UTF-16 raw bytes). Saves 1 byte per content string vs
|
||
/// the markered <see cref="WriteStringWithDispatch"/> StringSmall scheme; null/empty pay +3 bytes
|
||
/// (4-byte int32 vs 1-byte marker), but null/empty are rare in typical workloads → net wire-size win.
|
||
/// Companion reader is <see cref="BinaryDeserializationContext{TInput}.ReadStringUtf16Markerless"/>.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteStringUtf16Markerless(string? value)
|
||
{
|
||
if (value == null) { WriteRaw(-1); return; }
|
||
var charLength = value.Length;
|
||
if (charLength == 0) { WriteRaw(0); return; }
|
||
|
||
var byteLenF = charLength * 2;
|
||
EnsureCapacity(4 + byteLenF);
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position), charLength);
|
||
_position += 4;
|
||
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(_buffer.AsSpan(_position, byteLenF));
|
||
_position += byteLenF;
|
||
}
|
||
|
||
public void WriteStringUtf8(string value)
|
||
{
|
||
if (FastWire)
|
||
{
|
||
// UTF-16: char count (VarUInt) + raw char data (zero-encoding memcopy)
|
||
var charLen = value.Length;
|
||
var byteLen = charLen * 2;
|
||
|
||
WriteVarUInt((uint)charLen);
|
||
EnsureCapacity(byteLen);
|
||
|
||
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(_buffer.AsSpan(_position, byteLen));
|
||
_position += byteLen;
|
||
|
||
return;
|
||
}
|
||
|
||
// D-2 + tight reserve: single-pass UTF-8 encode with input-bound-aware VarUInt slot.
|
||
// Replaces the prior try-ASCII-then-rewind-and-encode-UTF-8 pattern (1 scan ASCII / 3 scans
|
||
// non-ASCII) with a single GetBytes call that works identically for both content classes.
|
||
//
|
||
// Layout: [reserved N bytes for VarUInt][UTF-8 bytes...]
|
||
// 1. Compute worst-case byte count from charLength (UTF-8 max = 4 bytes/char) and the
|
||
// VarUInt size needed for that upper bound. For charLength ≤ 31, reserveSize = 1
|
||
// (since 4*31 = 124 < 128 ⇒ VarUInt(124) = 1 byte). Most short strings hit this.
|
||
// 2. EnsureCapacity for reserveSize + maxBytes.
|
||
// 3. GetBytes directly into buffer at savedPos+reserveSize → returns exact byteCount.
|
||
// 4. If actual VarUInt < reserveSize (rare), memmove encoded bytes left to compact.
|
||
// 5. WriteVarUInt at savedPos and advance.
|
||
//
|
||
// Win vs the prior fixed-5-byte reserve: short strings (the common case) skip the memmove
|
||
// entirely. For 32-char strings the reserve is 2 bytes; if actual byteCount < 128 we
|
||
// memmove a smaller distance (1 byte) than the prior fixed approach (4 bytes).
|
||
//
|
||
// Span<byte>.CopyTo is overlap-safe via Buffer.Memmove on byte arrays.
|
||
var charLength = value.Length;
|
||
// Tight UTF-8 upper bound for valid UTF-16 input: max 3 bytes per UTF-16 code unit.
|
||
var maxBytes = charLength * 3;
|
||
var reserveSize = VarUIntSize((uint)maxBytes);
|
||
|
||
EnsureCapacity(reserveSize + maxBytes);
|
||
|
||
var savedPos = _position;
|
||
var encodeStart = savedPos + reserveSize;
|
||
System.Text.Unicode.Utf8.FromUtf16(value.AsSpan(), _buffer.AsSpan(encodeStart, maxBytes), out _, out var bytesWritten, replaceInvalidSequences: false);
|
||
|
||
var actualVarUIntSize = VarUIntSize((uint)bytesWritten);
|
||
if (actualVarUIntSize < reserveSize)
|
||
{
|
||
var shift = reserveSize - actualVarUIntSize;
|
||
_buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart - shift, bytesWritten));
|
||
}
|
||
|
||
_position = savedPos;
|
||
|
||
WriteVarUIntUnsafe((uint)bytesWritten); // advances _position by actualVarUIntSize
|
||
_position += bytesWritten;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Writes a non-empty UTF-8 string in a shift-free layout with an unsigned excess slot.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Header is fully determined before encode:
|
||
/// <list type="bullet">
|
||
/// <item><c>charLength <= 31</c>: <c>[FixStr(marker carries charLength)][unsigned excess:1]</c></item>
|
||
/// <item><c>charLength > 31</c>: <c>[String][VarUInt(charLength - FixStrCount)][unsigned excess:1|2|4]</c> — single marker with prefix-tier VarUInt charLength</item>
|
||
/// </list>
|
||
/// Body is UTF-8-encoded exactly once to the final destination (<c>encodeStart</c>) — no post-encode
|
||
/// body shift/copy. For the current path, <c>excess = bytesWritten - charLength</c> is expected to be
|
||
/// non-negative (ASCII=0, UTF-8=>0). UTF-16 signed-negative slot usage remains on the existing FastWire
|
||
/// path for now and is intentionally not activated in this method.
|
||
///
|
||
/// <para>Caller MUST guarantee non-empty input (<c>value.Length > 0</c>) — empty strings are handled by
|
||
/// the higher-level <c>WriteString</c> via the <c>StringEmpty</c> marker.</para>
|
||
/// </remarks>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteStringWithDispatch(string value)
|
||
{
|
||
var charLength = value.Length;
|
||
|
||
#if DEBUG
|
||
System.Diagnostics.Debug.Assert(charLength > 0, "WriteStringWithDispatch expects non-empty string; empty is handled by StringEmpty marker in WriteString.");
|
||
#endif
|
||
|
||
// Overflow guard (O7G2) — predict-friendly (always false on realistic input). NoInlining throw helper.
|
||
//if ((uint)charLength > BinaryTypeCode.MaxStringCharLength) ThrowStringTooLong(charLength);
|
||
|
||
// Tight UTF-8 upper bound for valid UTF-16 input: max 3 bytes per UTF-16 code unit.
|
||
var maxBytes = charLength * 3;
|
||
|
||
// Single branch on FixStr vs long-form — replaces the previous 4 ternary-on-isFixStr cascade.
|
||
// IMPORTANT: the slot VALUE (excess) is not known before UTF-8 encode, but the slot SIZE is.
|
||
// We reserve the slot by width (1/2/4) from charLength, so encodeStart is final and no body shift is needed.
|
||
int slotSize, headerSize, headerPos, slotPos, encodeStart;
|
||
if (charLength <= BinaryTypeCode.FixStrMaxLength)
|
||
{
|
||
// FixStr: header = [marker:1][slot:1]
|
||
slotSize = 1;
|
||
headerSize = 2;
|
||
|
||
EnsureCapacity(headerSize + maxBytes);
|
||
|
||
headerPos = _position;
|
||
slotPos = headerPos + 1;
|
||
encodeStart = headerPos + 2;
|
||
|
||
BufferAt(headerPos) = BinaryTypeCode.EncodeFixStr(charLength);
|
||
}
|
||
else
|
||
{
|
||
// Long-form: header = [marker:1][VarUInt(charLength - FixStrCount)][slot:1|2|4]
|
||
// FixStr already covers 0..FixStrMaxLength, so wireLen = charLength - FixStrCount
|
||
// keeps the small-band 1-byte VarUInt populated.
|
||
slotSize = BinaryTypeCode.GetUniversalStringExcessSlotSize(charLength);
|
||
var varUIntSize = VarUIntSize((uint)(charLength - BinaryTypeCode.FixStrCount));
|
||
headerSize = 1 + varUIntSize + slotSize;
|
||
|
||
EnsureCapacity(headerSize + maxBytes);
|
||
|
||
headerPos = _position;
|
||
slotPos = headerPos + 1 + varUIntSize;
|
||
encodeStart = headerPos + headerSize;
|
||
|
||
BufferAt(headerPos) = BinaryTypeCode.String;
|
||
|
||
_position = headerPos + 1;
|
||
WriteVarUIntUnsafe((uint)(charLength - BinaryTypeCode.FixStrCount));
|
||
// _position now == slotPos. Slot write below uses Unsafe.WriteUnaligned at slotPos;
|
||
// _position is finalized at the end via `_position = encodeStart + bytesWritten`.
|
||
}
|
||
|
||
var status = System.Text.Unicode.Utf8.FromUtf16(value.AsSpan(), _buffer.AsSpan(encodeStart, maxBytes), out _, out var bytesWritten, replaceInvalidSequences: false);
|
||
var excess = bytesWritten - charLength;
|
||
|
||
if (status != OperationStatus.Done) ThrowStringEncodingFailed(status);
|
||
|
||
#if DEBUG
|
||
// With status==Done, UTF-8 path mathematically implies bytesWritten >= charLength.
|
||
System.Diagnostics.Debug.Assert(excess >= 0, "WriteStringWithDispatch invariant broken: UTF-8 path produced negative excess.");
|
||
#endif
|
||
|
||
// UTF16 branch remains on the existing FastWire path for now.
|
||
// Current universal slot is unsigned (ASCII=0, UTF8>0). If UTF16-via-slot is introduced later,
|
||
// the discriminator design must be revisited (separate flag/marker or signed slot variant).
|
||
|
||
if (slotSize == 1) Unsafe.WriteUnaligned(ref BufferAt(slotPos), unchecked((byte)excess));
|
||
else if (slotSize == 2) Unsafe.WriteUnaligned(ref BufferAt(slotPos), unchecked((ushort)excess));
|
||
else Unsafe.WriteUnaligned(ref BufferAt(slotPos), excess);
|
||
|
||
_position = encodeStart + bytesWritten;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
private static void ThrowStringEncodingFailed(OperationStatus status) =>
|
||
throw new InvalidOperationException(
|
||
$"String UTF-8 encode failed in WriteStringWithDispatch: status={status}. " +
|
||
"This indicates an unexpected encoder failure (e.g. destination sizing or invalid input state)." );
|
||
|
||
/// <summary>
|
||
/// Writes the first-occurrence body of an interned string with H2Q6 tier-marker dispatch.
|
||
/// Used by the runtime/SGen string-intern write path; subsequent occurrences use cache-index ref.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Wire layout per tier:
|
||
/// <list type="bullet">
|
||
/// <item><c>StringInternFirstSmall</c>: <c>[marker:1][cacheIdx:VarUInt][charLen:8][utf8Len:8][bytes]</c> — utf8Len ≤ 255</item>
|
||
/// <item><c>StringInternFirstMedium</c>: <c>[marker:1][cacheIdx:VarUInt][charLen:16][utf8Len:16][bytes]</c> — utf8Len ≤ 65535</item>
|
||
/// </list>
|
||
///
|
||
/// <para>Big tier never engages — <c>MaxStringInternLength</c> is byte-typed in
|
||
/// <c>AcBinarySerializerOptions</c> (abszolút max 255 char × 4 byte/char = 1020 byte fits in Medium).</para>
|
||
///
|
||
/// <para>Tier prediction by <c>charLength</c>: ≤ 63 char → Small (worst-case 252 byte ≤ 255);
|
||
/// > 63 char → Medium. Body is left-shifted by 2 bytes only when a long mostly-ASCII interning
|
||
/// string drops back into Small tier (rare).</para>
|
||
/// </remarks>
|
||
public void WriteStringInternFirstWithDispatch(string value, int cacheMapIndex)
|
||
{
|
||
// Post-encode tier choice (wire-optimal): mostly-ASCII interning string in the 64+ char band
|
||
// emits Small tier (3 byte) when bytesWritten ≤ 255, instead of Medium (5 byte). Big tier
|
||
// never engages — MaxStringInternLength byte-typed (max 255 char × 4 byte = 1020 byte fits in Medium).
|
||
var charLength = value.Length;
|
||
// Overflow guard (defensive — interning length is byte-typed so this should never trigger,
|
||
// but stays consistent with WriteStringWithDispatch and protects against future refactors).
|
||
if ((uint)charLength > BinaryTypeCode.MaxStringCharLength) ThrowStringTooLong(charLength);
|
||
|
||
var maxBytes = charLength * 4;
|
||
var cacheIdxSize = VarUIntSize((uint)cacheMapIndex);
|
||
|
||
// reserveHeader: charLength ≤ 63 → guaranteed Small (252 byte ≤ 255); else Medium-reserve.
|
||
var reserveHeader = charLength <= 63 ? 3 : 5;
|
||
|
||
EnsureCapacity(cacheIdxSize + reserveHeader + maxBytes);
|
||
|
||
var savedPos = _position;
|
||
var encodeStart = savedPos + cacheIdxSize + reserveHeader;
|
||
System.Text.Unicode.Utf8.FromUtf16(value.AsSpan(), _buffer.AsSpan(encodeStart, maxBytes), out _, out var bytesWritten, replaceInvalidSequences: false);
|
||
|
||
// Choose tier from actual bytesWritten (smallest fits)
|
||
var actualHeader = bytesWritten <= 255 ? 3 : 5;
|
||
var tierMarker = actualHeader == 3 ? BinaryTypeCode.StringInternFirstSmall : BinaryTypeCode.StringInternFirstMedium;
|
||
|
||
var shift = reserveHeader - actualHeader;
|
||
if (shift > 0)
|
||
_buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart - shift, bytesWritten));
|
||
|
||
// Write [marker][cacheIdx VarUInt][charLen + utf8Len header][bytes]
|
||
BufferAt(savedPos) = tierMarker;
|
||
_position = savedPos + 1;
|
||
|
||
WriteVarUIntUnsafe((uint)cacheMapIndex);
|
||
|
||
if (actualHeader == 3)
|
||
{
|
||
// Pack charLen:8 | utf8Len:8 → single ushort store
|
||
var packed = (ushort)(charLength | (bytesWritten << 8));
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position), packed);
|
||
_position += 2;
|
||
}
|
||
else
|
||
{
|
||
// Pack charLen:16 | utf8Len:16 → single uint store, LE
|
||
var packed = (uint)charLength | ((uint)bytesWritten << 16);
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position), packed);
|
||
_position += 4;
|
||
}
|
||
|
||
_position += bytesWritten;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Attempts to write <paramref name="value"/> through the string-interning protocol.
|
||
/// Reads and immediately resets <see cref="StringInternEligible"/>; when the property is
|
||
/// intern-eligible and the write-plan cursor yields an entry, emits the InternFirst /
|
||
/// InternRef wire form and returns <c>true</c>. Returns <c>false</c> when the string must
|
||
/// be written by the regular tier dispatch — the caller then invokes
|
||
/// <see cref="WriteStringWithDispatch"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Extracted from the runtime <c>WriteString</c> interning block (K9M3-style hoist) so the
|
||
/// SGen string-property emit can call it directly — no <c>WriteStringGenerated</c> /
|
||
/// <c>WriteString</c> hop. Caller guarantees non-null, non-empty content. The unconditional
|
||
/// flag reset prevents the per-property <see cref="StringInternEligible"/> from leaking into
|
||
/// subsequent string writes.
|
||
/// </remarks>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
internal bool TryWriteInternedString(string value)
|
||
{
|
||
// Read and immediately reset — prevents the flag leaking to subsequent string writes
|
||
// (TryWritePrimitive, WriteDictionary, or when IsValidForInterningString is false).
|
||
var internEligible = StringInternEligible;
|
||
StringInternEligible = false;
|
||
|
||
if (internEligible && IsValidForInterningString(value.Length))
|
||
{
|
||
if (TryConsumeWritePlanEntry(out var planEntry))
|
||
{
|
||
ValidateWritePlanString(in planEntry, value);
|
||
if (planEntry.IsFirst)
|
||
{
|
||
// H2Q6 v3 — StringInternFirst tier-marker dispatch (Small/Medium); charLen
|
||
// carried in header → 1-pass decode, no CountUtf8Chars Pass 1.
|
||
WriteStringInternFirstWithDispatch(planEntry.Value ?? value, planEntry.CacheMapIndex);
|
||
}
|
||
else
|
||
{
|
||
WriteStringInternRef(this, planEntry.CacheMapIndex);
|
||
}
|
||
return true;
|
||
}
|
||
// No plan entry → single occurrence, caller falls through to the tier dispatch.
|
||
#if DEBUG
|
||
OnStringInterned?.Invoke(null, value);
|
||
#endif
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// V4N5 dead-code cleanup (2026-05-06): WriteFixStr, WriteFixStrDirect, WriteFixStrBytes,
|
||
// WritePreencodedPropertyName, and WriteStringUtf8Internal removed — these were unreachable
|
||
// (no core call site, no SourceGenerator template hit, no test, no reflection path).
|
||
// The hot-path string writes go through WriteStringWithDispatch (M3R7 + H2Q6 marker dispatch).
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Throw helper for the overflow guard in <see cref="WriteStringWithDispatch"/> and
|
||
/// <see cref="WriteStringInternFirstWithDispatch"/>. Marked <c>NoInlining</c> so the hot path
|
||
/// stays compact — the JIT/AOT keeps the throw-site out of the inlined caller body.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
private static void ThrowStringTooLong(int charLength) =>
|
||
throw new InvalidOperationException(
|
||
$"String too long for binary serialization: {charLength} chars exceeds {BinaryTypeCode.MaxStringCharLength}. " +
|
||
$"This limit is dictated by the writer's worst-case 'charLength * 4' UTF-8 byte allocation; " +
|
||
$"larger inputs would silently overflow int arithmetic.");
|
||
|
||
/// <summary>
|
||
/// Throw helper for <see cref="GrowAndValidate"/>: invoked when <c>Output.Grow</c> returns
|
||
/// without satisfying the requested capacity (chunk-limited output, single value larger than
|
||
/// the per-chunk data capacity). Marked <c>NoInlining</c> so the throw-site stays out of the
|
||
/// inlined caller body. See <c>BINARY_ASYNCPIPE_ISSUES.md#accore-bin-i-b7k9</c> for fix direction.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
private static void ThrowGrowFailedToSatisfy(int requested, int available) =>
|
||
throw new InvalidOperationException(
|
||
$"Output.Grow did not satisfy the requested capacity: {requested} bytes requested, only {available} bytes available after Grow. " +
|
||
$"This typically indicates a chunk-limited output (e.g. AsyncPipeWriterOutput) with a single serialized value exceeding the per-chunk data capacity. " +
|
||
$"See BINARY_ASYNCPIPE_ISSUES.md#accore-bin-i-b7k9.");
|
||
|
||
#endregion
|
||
|
||
#region Bulk Array Writes — inline
|
||
|
||
public void WriteDoubleArrayBulk(ReadOnlySpan<double> span)
|
||
{
|
||
EnsureCapacity(span.Length * 9);
|
||
for (var i = 0; i < span.Length; i++)
|
||
{
|
||
BufferAt(_position++) = BinaryTypeCode.Float64;
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position), span[i]);
|
||
_position += 8;
|
||
}
|
||
}
|
||
|
||
public void WriteFloatArrayBulk(ReadOnlySpan<float> span)
|
||
{
|
||
EnsureCapacity(span.Length * 5);
|
||
for (var i = 0; i < span.Length; i++)
|
||
{
|
||
BufferAt(_position++) = BinaryTypeCode.Float32;
|
||
Unsafe.WriteUnaligned(ref BufferAt(_position), span[i]);
|
||
_position += 4;
|
||
}
|
||
}
|
||
|
||
public void WriteGuidArrayBulk(ReadOnlySpan<Guid> span)
|
||
{
|
||
EnsureCapacity(span.Length * 17);
|
||
for (var i = 0; i < span.Length; i++)
|
||
{
|
||
BufferAt(_position++) = BinaryTypeCode.Guid;
|
||
span[i].TryWriteBytes(_buffer.AsSpan(_position, 16));
|
||
_position += 16;
|
||
}
|
||
}
|
||
|
||
public void WriteInt32ArrayOptimized(ReadOnlySpan<int> span)
|
||
{
|
||
for (var i = 0; i < span.Length; i++)
|
||
{
|
||
var value = span[i];
|
||
if (BinaryTypeCode.TryEncodeTinyInt(value, out var tiny))
|
||
{
|
||
WriteByte(tiny);
|
||
}
|
||
else
|
||
{
|
||
WriteByte(BinaryTypeCode.Int32);
|
||
WriteVarInt(value);
|
||
}
|
||
}
|
||
}
|
||
|
||
public void WriteLongArrayOptimized(ReadOnlySpan<long> span)
|
||
{
|
||
for (var i = 0; i < span.Length; i++)
|
||
{
|
||
var value = span[i];
|
||
if (value >= int.MinValue && value <= int.MaxValue)
|
||
{
|
||
var intValue = (int)value;
|
||
if (BinaryTypeCode.TryEncodeTinyInt(intValue, out var tiny))
|
||
{
|
||
WriteByte(tiny);
|
||
}
|
||
else
|
||
{
|
||
WriteByte(BinaryTypeCode.Int32);
|
||
WriteVarInt(intValue);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
WriteByte(BinaryTypeCode.Int64);
|
||
WriteVarLong(value);
|
||
}
|
||
}
|
||
}
|
||
|
||
public void WriteBytesSimd(ReadOnlySpan<byte> source)
|
||
{
|
||
EnsureCapacity(source.Length);
|
||
var destination = _buffer.AsSpan(_position, source.Length);
|
||
|
||
if (Vector.IsHardwareAccelerated && source.Length >= Vector<byte>.Count * 2)
|
||
{
|
||
var vectorSize = Vector<byte>.Count;
|
||
var i = 0;
|
||
var length = source.Length;
|
||
var vectorCount = length / vectorSize;
|
||
for (var v = 0; v < vectorCount; v++)
|
||
{
|
||
var vec = new Vector<byte>(source.Slice(i, vectorSize));
|
||
vec.CopyTo(destination.Slice(i, vectorSize));
|
||
i += vectorSize;
|
||
}
|
||
if (i < length)
|
||
source.Slice(i).CopyTo(destination.Slice(i));
|
||
}
|
||
else
|
||
{
|
||
source.CopyTo(destination);
|
||
}
|
||
|
||
_position += source.Length;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region String Interning
|
||
|
||
/// <summary>
|
||
/// Serialize pass: looks up interned string state.
|
||
/// Returns the entry ref for caller to check IsFirstWrite and update it.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public ref InternEntry GetInternedStringEntry(string value, out bool found)
|
||
{
|
||
if (_stringInternMap == null)
|
||
{
|
||
found = false;
|
||
return ref Unsafe.NullRef<InternEntry>();
|
||
}
|
||
|
||
if (_stringInternMap.TryAdd(value, out var slotIndex))
|
||
{
|
||
// Not in map (shouldn't happen after scan pass for cached strings)
|
||
found = false;
|
||
return ref _stringInternMap.GetValueRef(slotIndex);
|
||
}
|
||
|
||
found = true;
|
||
return ref _stringInternMap.GetValueRef(slotIndex);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Scan pass: tracks a string for interning. Assigns CacheIndex immediately on 2nd occurrence.
|
||
/// Builds WriteDuplicateEntry for all duplicate occurrences.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void ScanInternString(string value)
|
||
{
|
||
var visitIndex = ScanVisitIndex++;
|
||
|
||
_stringInternMap ??= new IdentityMap<string, InternEntry>();
|
||
|
||
if (!_stringInternMap.TryAdd(value, out var slotIndex))
|
||
{
|
||
// 2+ occurrence: assign CacheIndex immediately
|
||
ref var entry = ref _stringInternMap.GetValueRef(slotIndex);
|
||
if (entry.CacheIndex == -1)
|
||
{
|
||
// 2nd occurrence: assign CacheIndex + add StringFirst entry at first visit position
|
||
entry.CacheIndex = _nextCacheIndex++;
|
||
entry.IsFirstWrite = true;
|
||
AddWriteDuplicateEntry(entry.FirstIndex, entry.CacheIndex, isFirst: true, value);
|
||
}
|
||
// 2nd+ occurrence: add StringRef entry at current position
|
||
AddWriteDuplicateEntry(visitIndex, entry.CacheIndex, isFirst: false, value: null);
|
||
return;
|
||
}
|
||
|
||
// 1st occurrence: store scan visit index, CacheIndex = -1 (not cached yet)
|
||
ref var newEntry = ref _stringInternMap.GetValueRef(slotIndex);
|
||
newEntry.FirstIndex = visitIndex;
|
||
newEntry.CacheIndex = -1;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns true if there are any interned strings that occurred more than once.
|
||
/// </summary>
|
||
public bool HasInternedStrings => _stringInternMap != null && _stringInternMap.Count > 0;
|
||
|
||
/// <summary>
|
||
/// Gets the count of cached values (string intern + object ref that occurred more than once).
|
||
/// </summary>
|
||
public int GetCacheCount() => _nextCacheIndex;
|
||
|
||
#endregion
|
||
|
||
#region Polymorphic Type Prefix
|
||
|
||
/// <summary>
|
||
/// Writes a polymorphic type prefix when the runtime type differs from the declared property type.
|
||
/// <para>
|
||
/// When <paramref name="cachedObjectCacheIndex"/> is -1 (default): PREFIX markers.
|
||
/// An inner Object/Array/Dict marker follows.
|
||
/// First type occurrence: ObjectWithTypeName (68) + typename
|
||
/// Cached type: ObjectWithTypeIndex (70) + typeIndex
|
||
/// </para>
|
||
/// <para>
|
||
/// When <paramref name="cachedObjectCacheIndex"/> >= 0: COMBINED markers.
|
||
/// Object body follows directly (no inner Object/ObjectRefFirst marker).
|
||
/// First type occurrence: ObjectWithTypeNameRefFirst (69) + typename + refCacheIndex
|
||
/// Cached type: ObjectWithTypeIndexRefFirst (71) + typeIndex + refCacheIndex
|
||
/// </para>
|
||
/// </summary>
|
||
//[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WritePolymorphicPrefix(Type runtimeType, int cachedObjectCacheIndex = -1)
|
||
{
|
||
var rtWrapper = GetWrapper(runtimeType);
|
||
if (!rtWrapper.PolymorphicSeen)
|
||
{
|
||
rtWrapper.PolymorphicSeen = true;
|
||
rtWrapper.PolymorphicCacheIndex = _nextTypeSlot++;
|
||
if (cachedObjectCacheIndex >= 0)
|
||
{
|
||
WriteByte(BinaryTypeCode.ObjectWithTypeNameRefFirst);
|
||
WriteStringUtf8(runtimeType.FullName!);
|
||
WriteVarUInt((uint)cachedObjectCacheIndex);
|
||
}
|
||
else
|
||
{
|
||
WriteByte(BinaryTypeCode.ObjectWithTypeName);
|
||
WriteStringUtf8(runtimeType.FullName!);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (cachedObjectCacheIndex >= 0)
|
||
{
|
||
WriteByte(BinaryTypeCode.ObjectWithTypeIndexRefFirst);
|
||
WriteVarUInt((uint)rtWrapper.PolymorphicCacheIndex);
|
||
WriteVarUInt((uint)cachedObjectCacheIndex);
|
||
}
|
||
else
|
||
{
|
||
WriteByte(BinaryTypeCode.ObjectWithTypeIndex);
|
||
WriteVarUInt((uint)rtWrapper.PolymorphicCacheIndex);
|
||
}
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region UseMetadata Type Tracking
|
||
|
||
/// <summary>
|
||
/// Registers a type for UseMetadata first/repeated tracking.
|
||
/// Returns true on first occurrence (caller should write inline property hashes),
|
||
/// false on repeated (caller writes only typeNameHash).
|
||
/// Used by both runtime and SGen paths via wrapper.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public static bool RegisterMetadataType(TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper)
|
||
{
|
||
if (wrapper.MetadataSeen)
|
||
return false;
|
||
|
||
wrapper.MetadataSeen = true;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Inline metadata kiírása az ObjectWithMetadata marker után.
|
||
/// Első előfordulás: [propNameHash (4b)][propCount (VarUInt)][hash0 (4b)][hash1 (4b)]...
|
||
/// Ismételt: [propNameHash (4b)]
|
||
/// </summary>
|
||
//[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void WriteInlineMetadata(BinarySerializeTypeMetadata metadata, bool isFirstOccurrence)
|
||
{
|
||
WriteRaw(metadata.PropNameHash);
|
||
|
||
if (isFirstOccurrence)
|
||
{
|
||
var hashes = metadata.MetadataPropertyHashes;
|
||
WriteVarUInt((uint)hashes.Length);
|
||
for (var i = 0; i < hashes.Length; i++)
|
||
{
|
||
WriteRaw(hashes[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Property State Buffer
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public byte[] RentPropertyStateBuffer(int size)
|
||
{
|
||
if (_propertyStateBuffer != null && _propertyStateBuffer.Length >= size)
|
||
{
|
||
return _propertyStateBuffer;
|
||
}
|
||
|
||
if (_propertyStateBuffer != null)
|
||
{
|
||
ArrayPool<byte>.Shared.Return(_propertyStateBuffer);
|
||
}
|
||
|
||
_propertyStateBuffer = ArrayPool<byte>.Shared.Rent(size);
|
||
return _propertyStateBuffer;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public void ReturnPropertyStateBuffer(byte[] buffer)
|
||
{
|
||
// Buffer stays cached for reuse.
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Property Filtering
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
public bool ShouldSerializeProperty(object instance, BinaryPropertyAccessor property)
|
||
{
|
||
if (PropertyFilter == null)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var context = new BinaryPropertyFilterContext(
|
||
instance,
|
||
property.DeclaringType,
|
||
property.Name,
|
||
property.PropertyType,
|
||
property.DynamicGetter);
|
||
|
||
return PropertyFilter(context);
|
||
}
|
||
|
||
public bool CheckDuplicatePropName => Options.CheckDuplicatePropName;
|
||
|
||
#endregion
|
||
|
||
#region Header
|
||
|
||
/// <summary>
|
||
/// Writes the binary header directly. Call AFTER ScanForDuplicates (cacheCount is known).
|
||
/// No placeholder, no shift — single forward write.
|
||
/// Layout: [version (1b)][flags (1b)][cacheCount (VarUInt, if caching)]
|
||
/// </summary>
|
||
public void WriteHeader()
|
||
{
|
||
var flags = BinaryTypeCode.HeaderFlagsBase;
|
||
if (UseMetadata)
|
||
flags |= BinaryTypeCode.HeaderFlag_Metadata;
|
||
if (ReferenceHandling == ReferenceHandlingMode.OnlyId)
|
||
flags |= BinaryTypeCode.HeaderFlag_RefHandling_OnlyId;
|
||
else if (ReferenceHandling == ReferenceHandlingMode.All)
|
||
flags |= BinaryTypeCode.HeaderFlag_RefHandling_OnlyId | BinaryTypeCode.HeaderFlag_RefHandling_All;
|
||
if (HasCaching)
|
||
flags |= BinaryTypeCode.HeaderFlag_HasCacheCount;
|
||
|
||
WriteByte(AcBinarySerializerOptions.FormatVersion);
|
||
WriteByte(flags);
|
||
|
||
if (HasCaching)
|
||
{
|
||
WriteVarUInt((uint)GetCacheCount());
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Helpers
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
private static void ClearAndTrimIfNeeded<TKey, TValue>(Dictionary<TKey, TValue>? dict, int maxCapacity)
|
||
where TKey : notnull
|
||
{
|
||
if (dict == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
dict.Clear();
|
||
if (dict.EnsureCapacity(0) > maxCapacity)
|
||
{
|
||
dict.TrimExcess();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
private static void ClearAndTrimIfNeeded<T>(HashSet<T>? set, int maxCapacity)
|
||
{
|
||
if (set == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
set.Clear();
|
||
if (set.EnsureCapacity(0) > maxCapacity)
|
||
{
|
||
set.TrimExcess();
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
}
|