[LOADED_DOCS: 2 files, no new loads]

AcBinary: ASCII string opt, Type-based API, MVC support

- Add ASCII-optimized string serialization/deserialization with new FixStrAscii/StringAscii markers for fast byte→char widening.
- Introduce non-generic Type-based Serialize/Deserialize overloads for runtime-typed scenarios (plugin, MVC, model binding).
- Add AcBinaryInputFormatter/OutputFormatter and AddAcBinaryFormatters extensions for ASP.NET Core MVC integration.
- Update project references and close ACCORE-BIN-T-N9G6 in docs.
This commit is contained in:
Loretta 2026-05-04 13:20:33 +02:00
parent 265b89da0a
commit 7b94d81485
10 changed files with 581 additions and 35 deletions

View File

@ -1820,12 +1820,11 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
}
/// <summary>
/// Emits inline string read from type code. Handles all string wire formats.
/// FixStr (range 34-65) is checked first as hot path for short strings.
/// Remaining codes use switch for O(1) JIT jump-table dispatch:
/// String=16, StringInterned=17, StringEmpty=18, StringInternFirst=19, Null=0.
/// This eliminates the sequential if-else chain that penalized StringInterned
/// (the hot path for repeated interned strings) with 4 comparisons.
/// Emits inline string read from type code. Handles all string wire formats:
/// FixStr (UTF-8 short, 103-134), FixStrAscii (ASCII short, 135-166), String (UTF-8 long, 91),
/// StringAscii (ASCII long, 167), StringInterned, StringEmpty, StringInternFirst, Null.
/// FixStr/FixStrAscii are checked first as hot paths for short strings — ASCII variant
/// dispatches to <c>ReadAsciiBytesAsString</c> (byte→char widen, no UTF-8 decode).
/// </summary>
private static void EmitReadString(StringBuilder sb, string a, string tc, string i)
{
@ -1835,8 +1834,14 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
sb.AppendLine($"{i} var flen = BinaryTypeCode.DecodeFixStrLength({tc});");
sb.AppendLine($"{i} {a} = flen == 0 ? string.Empty : context.ReadStringUtf8(flen);");
sb.AppendLine($"{i}}}");
// Switch gives O(1) dispatch via JIT jump table for codes 0, 16-19.
// StringInterned (17) is the hot path for repeated interned strings — no longer buried at 4th else-if.
// FixStrAscii — ASCII short strings, byte→char widen path (skips UTF-8 decode).
sb.AppendLine($"{i}else if (BinaryTypeCode.IsFixStrAscii({tc}))");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var falen = BinaryTypeCode.DecodeFixStrAsciiLength({tc});");
sb.AppendLine($"{i} {a} = falen == 0 ? string.Empty : context.ReadAsciiBytesAsString(falen);");
sb.AppendLine($"{i}}}");
// Switch gives O(1) dispatch via JIT jump table for the long markers.
// StringInterned is the hot path for repeated interned strings.
sb.AppendLine($"{i}else switch ({tc})");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} case BinaryTypeCode.StringInterned:");
@ -1848,6 +1853,12 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
sb.AppendLine($"{i} {a} = slen == 0 ? string.Empty : context.ReadStringUtf8(slen);");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} case BinaryTypeCode.StringAscii:");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var salen = (int)context.ReadVarUInt();");
sb.AppendLine($"{i} {a} = salen == 0 ? string.Empty : context.ReadAsciiBytesAsString(salen);");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirst:");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} context.DisableStringCaching();");

View File

@ -418,6 +418,44 @@ public static partial class AcBinaryDeserializer
return DecodeUtf8(length);
}
/// <summary>
/// Reads <paramref name="byteLength"/> ASCII bytes from the wire and widens them to a UTF-16
/// string. Caller MUST guarantee the payload is pure ASCII — typically by dispatching on a
/// <c>FixStrAscii</c> / <c>StringAscii</c> marker (the marker IS the ASCII-validity contract).
/// </summary>
/// <remarks>
/// Skips the UTF-8 decoder entirely — every byte maps 1:1 to a char via simple widening.
/// Uses <see cref="Encoding.Latin1"/>.<c>GetString</c> for the widen — Latin1 is byte→char
/// 1:1 (codepoints 0..255), and ASCII (0..127) is a strict subset, so for marker-validated
/// ASCII payloads the result is identical to a hand-rolled <c>(char)b</c> widen but uses
/// the BCL's SIMD-accelerated implementation (single-shot allocation + memcpy-class widen).
///
/// Beats a <c>string.Create</c> + scalar callback widen by avoiding the lambda-state passing
/// and JIT-trust on auto-vectorization across the callback boundary.
///
/// FastWire mode never emits ASCII markers — they're a Compact-mode-only optimization. If
/// FastWire encounters one (cross-mode wire mismatch), the read still works but the FastWire
/// raw-memcpy fast path doesn't apply.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public string ReadAsciiBytesAsString(int byteLength)
{
if (byteLength == 0) return string.Empty;
EnsureAvailable(byteLength);
// Cached short-string path (WASM optimization) — leverages full-content hash + Ascii.Equals
// verification (which is a no-op fast path on ASCII content).
if (_useStringCaching && byteLength <= _maxCachedStringLength)
{
return ReadStringUtf8Cached(byteLength);
}
var pos = _position;
_position += byteLength;
return Encoding.Latin1.GetString(_buffer, pos, byteLength);
}
/// <summary>
/// Custom UTF-8 → UTF-16 string decoder.
/// </summary>

View File

@ -5,6 +5,7 @@ using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO.Pipelines;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
@ -100,6 +101,7 @@ public static partial class AcBinaryDeserializer
readers[BinaryTypeCode.StringInterned] = static (ctx, _, _) => ctx.GetInternedString((int)ctx.ReadVarUInt());
readers[BinaryTypeCode.StringEmpty] = static (_, _, _) => string.Empty;
readers[BinaryTypeCode.StringInternFirst] = static (ctx, _, _) => ReadAndRegisterInternedString(ctx);
readers[BinaryTypeCode.StringAscii] = static (ctx, _, _) => ReadPlainStringAscii(ctx);
readers[BinaryTypeCode.DateTime] = static (ctx, _, _) => ctx.ReadDateTimeUnsafe();
readers[BinaryTypeCode.DateTimeOffset] = static (ctx, _, _) => ctx.ReadDateTimeOffsetUnsafe();
readers[BinaryTypeCode.TimeSpan] = static (ctx, _, _) => ctx.ReadTimeSpanUnsafe();
@ -125,6 +127,14 @@ public static partial class AcBinaryDeserializer
readers[code] = CreateFixStrReader<TInput>(length);
}
// Register FixStrAscii readers (135..166) — pure-ASCII short-string fast path.
// The marker IS the validity contract — reader byte→char widens without UTF-8 decode.
for (var code = BinaryTypeCode.FixStrAsciiBase; code <= BinaryTypeCode.FixStrAsciiMax; code++)
{
var length = BinaryTypeCode.DecodeFixStrAsciiLength(code);
readers[code] = CreateFixStrAsciiReader<TInput>(length);
}
// Register FixObj slot readers (0..SlotCount-1)
for (var slot = 0; slot < BinaryTypeCode.SlotCount; slot++) readers[slot] = CreateFixObjReader<TInput>(slot);
@ -144,6 +154,18 @@ public static partial class AcBinaryDeserializer
return (ctx, _, _) => ctx.ReadStringUtf8(length);
}
/// <summary>
/// Creates a reader for FixStrAscii with the given byte length (also char count, ASCII = 1:1).
/// Skips UTF-8 decode — byte→char widen only. Marker enforces ASCII validity.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static TypeReader<TInput> CreateFixStrAsciiReader<TInput>(int length) where TInput : struct, IBinaryInputBase
{
if (length == 0) return static (_, _, _) => string.Empty;
return (ctx, _, _) => ctx.ReadAsciiBytesAsString(length);
}
/// <summary>
/// Creates a reader for FixObj slot (0..SlotCount-1).
/// </summary>
@ -353,6 +375,57 @@ public static partial class AcBinaryDeserializer
}
}
/// <summary>
/// Drains a <see cref="PipeReader"/> end-to-end into a fresh <see cref="AsyncPipeReaderInput"/>
/// and deserializes one message. Background <c>Task.Run</c> deserializes incrementally while
/// the calling thread drains the reader. For long-lived multi-message scenarios use the
/// <see cref="AsyncPipeReaderInput"/> overloads directly.
/// </summary>
public static async Task<T?> DeserializeFromPipeReaderAsync<[DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] T>(PipeReader reader, AcBinarySerializerOptions options, CancellationToken cancellationToken = default)
{
if (reader is null) throw new ArgumentNullException(nameof(reader));
using var input = new AsyncPipeReaderInput(options.BufferWriterChunkSize * 2);
var deserTask = Task.Run(() => Deserialize<T>(input, options), cancellationToken);
await DrainPipeReaderToInputAsync(reader, input, cancellationToken).ConfigureAwait(false);
return await deserTask.ConfigureAwait(false);
}
/// <summary>
/// Non-generic <c>Type</c>-based counterpart to <see cref="DeserializeFromPipeReaderAsync{T}"/>.
/// For runtime-typed scenarios (MVC formatters, plugin frameworks).
/// </summary>
public static async Task<object?> DeserializeFromPipeReaderAsync(PipeReader reader, [DynamicallyAccessedMembers(TypeMetadataBase.RequiredMembers)] Type targetType, AcBinarySerializerOptions options, CancellationToken cancellationToken = default)
{
if (reader is null) throw new ArgumentNullException(nameof(reader));
using var input = new AsyncPipeReaderInput(options.BufferWriterChunkSize * 2);
var deserTask = Task.Run(() => Deserialize(input, targetType, options), cancellationToken);
await DrainPipeReaderToInputAsync(reader, input, cancellationToken).ConfigureAwait(false);
return await deserTask.ConfigureAwait(false);
}
/// <summary>
/// Pumps a <see cref="PipeReader"/> into a <see cref="AsyncPipeReaderInput"/> via repeated
/// <see cref="AsyncPipeReaderInput.Feed"/> calls; signals <see cref="AsyncPipeReaderInput.Complete"/>
/// at end-of-stream (in finally so consumer always wakes up on cancellation / exception).
/// </summary>
private static async Task DrainPipeReaderToInputAsync(PipeReader reader, AsyncPipeReaderInput input, CancellationToken cancellationToken)
{
try
{
while (true)
{
var result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
foreach (var segment in result.Buffer) input.Feed(segment.Span);
reader.AdvanceTo(result.Buffer.End);
if (result.IsCompleted) break;
}
}
finally
{
input.Complete();
}
}
/// <summary>
/// Internal: Deserialize with any TInput (multi-segment or other future input types).
/// </summary>
@ -1026,11 +1099,22 @@ public static partial class AcBinaryDeserializer
propInfo.SetValue(target, length == 0 ? string.Empty : context.ReadStringUtf8(length));
return true;
}
if (BinaryTypeCode.IsFixStrAscii(typeCode))
{
var length = BinaryTypeCode.DecodeFixStrAsciiLength(typeCode);
propInfo.SetValue(target, length == 0 ? string.Empty : context.ReadAsciiBytesAsString(length));
return true;
}
if (typeCode == BinaryTypeCode.String)
{
propInfo.SetValue(target, ReadPlainString(context));
return true;
}
if (typeCode == BinaryTypeCode.StringAscii)
{
propInfo.SetValue(target, ReadPlainStringAscii(context));
return true;
}
if (typeCode == BinaryTypeCode.StringEmpty)
{
propInfo.SetValue(target, string.Empty);
@ -1090,6 +1174,13 @@ public static partial class AcBinaryDeserializer
return length == 0 ? string.Empty : context.ReadStringUtf8(length);
}
// Handle FixStrAscii (short ASCII strings — byte→char widen, no UTF-8 decode)
if (BinaryTypeCode.IsFixStrAscii(typeCode))
{
var length = BinaryTypeCode.DecodeFixStrAsciiLength(typeCode);
return length == 0 ? string.Empty : context.ReadAsciiBytesAsString(length);
}
var reader = TypeReaderTable<TInput>.Readers[typeCode];
if (reader != null)
{
@ -1113,6 +1204,19 @@ public static partial class AcBinaryDeserializer
return context.ReadStringUtf8(length);
}
/// <summary>
/// Reads a long ASCII string payload (after the <c>StringAscii</c> marker has been consumed).
/// Wire format: <c>[VarUInt byteCount][ASCII bytes]</c>. Byte→char widen, no UTF-8 decode.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ReadPlainStringAscii<TInput>(BinaryDeserializationContext<TInput> context)
where TInput : struct, IBinaryInputBase
{
var length = (int)context.ReadVarUInt();
if (length == 0) return string.Empty;
return context.ReadAsciiBytesAsString(length);
}
/// <summary>
/// Read interned string (StringInternFirst marker) and register in cache at specified index.
/// Wire format: [StringInternFirst][VarUInt cacheIndex][VarUInt length][UTF8 bytes]
@ -1989,6 +2093,15 @@ public static partial class AcBinaryDeserializer
return;
}
// Handle FixStrAscii (short ASCII strings — same skip layout as FixStr, just different marker range)
if (BinaryTypeCode.IsFixStrAscii(typeCode))
{
var length = BinaryTypeCode.DecodeFixStrAsciiLength(typeCode);
if (length > 0)
context.Skip(length);
return;
}
switch (typeCode)
{
case BinaryTypeCode.True:
@ -2034,6 +2147,8 @@ public static partial class AcBinaryDeserializer
context.Skip(16);
return;
case BinaryTypeCode.String:
case BinaryTypeCode.StringAscii:
// Same skip layout: [VarUInt byteCount][bytes]. ASCII vs UTF-8 distinction is content-only.
SkipPlainString(context);
return;
case BinaryTypeCode.StringInterned:

View File

@ -713,6 +713,112 @@ public static partial class AcBinarySerializer
_position += bytesWritten;
}
/// <summary>
/// Writes a non-empty string with marker-dispatch: detects ASCII vs UTF-8 in-place from the
/// encoder's byte count and emits the appropriate wire marker (<c>FixStrAscii</c>,
/// <c>FixStr</c>, <c>StringAscii</c>, or <c>String</c>). The reader uses the marker as an
/// ASCII-validity contract — pure-ASCII payloads skip UTF-8 decode entirely (byte→char widen).
/// </summary>
/// <remarks>
/// Layout (Compact wire): <c>[marker: 1 byte][optional VarUInt byteCount][encoded bytes]</c>
/// — VarUInt is omitted for FixStr/FixStrAscii (length is encoded in the marker).
///
/// ASCII detection is free: <c>bytesWritten == charLength</c> after a UTF-8 encode is a
/// necessary AND sufficient condition for the input being pure ASCII (every UTF-16 char
/// &lt; 0x80 produces exactly 1 UTF-8 byte; non-ASCII chars always produce 2-4 bytes).
///
/// Caller MUST guarantee non-empty input (<c>value.Length &gt; 0</c>) — empty strings are
/// handled by the higher-level <c>WriteString</c> via the <c>StringEmpty</c> marker.
/// </remarks>
public void WriteStringWithDispatch(string value)
{
if (FastWire)
{
// FastWire: char count (VarUInt) + raw UTF-16 memcopy. ASCII detection adds no value
// here — the wire size is identical (2 bytes/char) and the read path is memcpy-based,
// so the encoder/decoder UTF-8 cost (which the ASCII marker would skip) doesn't apply.
WriteByte(BinaryTypeCode.String);
var charLenF = value.Length;
var byteLenF = charLenF * 2;
WriteVarUInt((uint)charLenF);
EnsureCapacity(byteLenF);
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(_buffer.AsSpan(_position, byteLenF));
_position += byteLenF;
return;
}
var charLength = value.Length;
// Hot-path split: encode position is chosen to MINIMIZE post-encode shifts.
//
// • charLength ≤ 31 → MIGHT be FixStr (bytesWritten ≤ 31) or long String (multibyte
// expansion). Encode optimistically at savedPos+1 (FixStr position). FixStr hit ⇒ 0 shift,
// only marker byte write. Long-fallback (rare, requires Hungarian/CJK chars in a
// short-char string AND post-expand size > 31) ⇒ shift bytes RIGHT by 1 (since the
// long lane needs 1 VarUInt byte after the marker; charLength ≤ 31 ⇒ maxBytes ≤ 124
// ⇒ VarUInt size = 1).
//
// • charLength > 31 → ALWAYS long String (bytesWritten ≥ charLength > 31). Use full
// D-2 layout [marker][reserveVarUInt][bytes], encode at savedPos+1+reserveVarUInt.
// Backfill compacts only when actual VarUInt size < reserved (rare).
if (charLength <= BinaryTypeCode.FixStrMaxLength)
{
var maxBytesShort = charLength * 4; // ≤ 124, fits in 1-byte VarUInt
EnsureCapacity(2 + maxBytesShort); // marker + 1-byte VarUInt + bytes (worst case)
var savedPosShort = _position;
var bytesWrittenShort = EncodeUtf8SinglePass(
value.AsSpan(),
_buffer.AsSpan(savedPosShort + 1, maxBytesShort));
var isAsciiShort = bytesWrittenShort == charLength;
if (bytesWrittenShort <= BinaryTypeCode.FixStrMaxLength)
{
// Hot path: FixStr hit → bytes already at savedPos+1, no shift.
_buffer[savedPosShort] = isAsciiShort
? BinaryTypeCode.EncodeFixStrAscii(bytesWrittenShort)
: BinaryTypeCode.EncodeFixStr(bytesWrittenShort);
_position = savedPosShort + 1 + bytesWrittenShort;
}
else
{
// Cold: multibyte expansion pushed bytes > 31 → become long String/StringAscii.
// Shift bytes right by 1 to insert the 1-byte VarUInt slot.
_buffer.AsSpan(savedPosShort + 1, bytesWrittenShort)
.CopyTo(_buffer.AsSpan(savedPosShort + 2, bytesWrittenShort));
_buffer[savedPosShort] = isAsciiShort ? BinaryTypeCode.StringAscii : BinaryTypeCode.String;
_position = savedPosShort + 1;
WriteVarUIntUnsafe((uint)bytesWrittenShort);
_position += bytesWrittenShort;
}
return;
}
// Long path: charLength > 31 ⇒ bytesWritten > 31 ⇒ always String / StringAscii.
// D-2 layout [marker:1][VarUInt slot:reserveVarUInt][bytes], encode at savedPos+1+reserveVarUInt.
var maxBytes = charLength * 4;
var reserveVarUInt = VarUIntSize((uint)maxBytes);
EnsureCapacity(1 + reserveVarUInt + maxBytes);
var savedPos = _position;
var encodeStart = savedPos + 1 + reserveVarUInt;
var bytesWritten = EncodeUtf8SinglePass(value.AsSpan(), _buffer.AsSpan(encodeStart, maxBytes));
var isAscii = bytesWritten == charLength;
_buffer[savedPos] = isAscii ? BinaryTypeCode.StringAscii : BinaryTypeCode.String;
var actualVarUIntSize = VarUIntSize((uint)bytesWritten);
if (actualVarUIntSize < reserveVarUInt)
{
var shift = reserveVarUInt - actualVarUIntSize;
_buffer.AsSpan(encodeStart, bytesWritten).CopyTo(_buffer.AsSpan(encodeStart - shift, bytesWritten));
}
_position = savedPos + 1;
WriteVarUIntUnsafe((uint)bytesWritten);
_position += bytesWritten;
}
public void WriteFixStr(string value)
{
var length = value.Length;

View File

@ -352,6 +352,51 @@ public static partial class AcBinarySerializer
}
}
/// <summary>
/// Non-generic <c>Type</c>-based <see cref="Serialize{T}(T, AcBinarySerializerOptions)"/>. For
/// runtime-typed scenarios (plugin frameworks, ASP.NET ModelBinding, MVC formatters). The
/// <paramref name="type"/> parameter is the declared-type hint; the body uses
/// <c>value.GetType()</c> for the runtime polymorphism path, identical to the generic version.
/// </summary>
public static byte[] Serialize(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, AcBinarySerializerOptions options)
{
if (value == null) return [BinaryTypeCode.Null];
var runtimeType = value.GetType();
var context = AcquireArrayOutputContext(options);
try
{
if (options.UseGeneratedCode)
{
var wrapper = context.GetWrapper(runtimeType);
if (wrapper.GeneratedWriter != null)
{
ScanForDuplicates(value, runtimeType, context);
context.WriteHeader();
WriteObject(value, wrapper, context, 0);
if (options.UseCompression != Lz4CompressionMode.None)
return Lz4.Compress(context.Output.AsSpan(context._buffer, context._position), options.UseCompression);
return context.Output.ToArray(context._buffer, context._position);
}
}
var actualValue = ConvertExpressionValue(value, ref runtimeType);
ScanForDuplicates(actualValue, runtimeType, context);
context.WriteHeader();
WriteValue(actualValue, runtimeType, context, 0);
if (options.UseCompression != Lz4CompressionMode.None)
return Lz4.Compress(context.Output.AsSpan(context._buffer, context._position), options.UseCompression);
return context.Output.ToArray(context._buffer, context._position);
}
finally
{
ReturnContext(context, options);
}
}
/// <summary>
/// Runs only the scan pass (ScanForDuplicates) without writing.
/// For benchmarking scan pass overhead in isolation.
@ -433,6 +478,65 @@ public static partial class AcBinarySerializer
}
}
/// <summary>
/// Non-generic <c>Type</c>-based counterpart to
/// <see cref="Serialize{T}(T, IBufferWriter{byte}, AcBinarySerializerOptions)"/>. Body identical
/// — <paramref name="type"/> is a declared-type hint only; runtime polymorphism uses <c>value.GetType()</c>.
/// </summary>
public static int Serialize(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, IBufferWriter<byte> writer, AcBinarySerializerOptions options)
{
if (value == null)
{
var span = writer.GetSpan(1);
span[0] = BinaryTypeCode.Null;
writer.Advance(1);
return 1;
}
var runtimeType = value.GetType();
var context = BinarySerializationContextPool<BufferWriterBinaryOutput>.Get(options);
context.Output = new BufferWriterBinaryOutput(writer, options.BufferWriterChunkSize);
context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
try
{
if (options.UseGeneratedCode)
{
var wrapper = context.GetWrapper(runtimeType);
if (wrapper.GeneratedWriter != null)
{
ScanForDuplicates(value, runtimeType, context);
context.WriteHeader();
WriteObject(value, wrapper, context, 0);
if (options.UseCompression != Lz4CompressionMode.None)
ThrowCompressionNotSupportedWithBufferWriter(context);
var bytesWritten = context.Output.GetTotalPosition(context._position);
context.Output.Flush(context._buffer, context._position);
return bytesWritten;
}
}
var actualValue = ConvertExpressionValue(value, ref runtimeType);
ScanForDuplicates(actualValue, runtimeType, context);
context.WriteHeader();
WriteValue(actualValue, runtimeType, context, 0);
if (options.UseCompression != Lz4CompressionMode.None)
ThrowCompressionNotSupportedWithBufferWriter(context);
var totalBytesWritten = context.Output.GetTotalPosition(context._position);
context.Output.Flush(context._buffer, context._position);
return totalBytesWritten;
}
finally
{
context.Output = default;
ReturnContext(context, options);
}
}
/// <summary>
/// Serialize to a <see cref="System.IO.Pipelines.Pipe"/> as a chunked stream — pure AcBinary
/// bytes are written via <see cref="AsyncPipeWriterOutput"/> in raw mode (no per-chunk header).
@ -494,6 +598,14 @@ public static partial class AcBinarySerializer
public static int SerializeChunked<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false);
/// <summary>
/// Non-generic <c>Type</c>-based counterpart to
/// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.
/// For runtime-typed scenarios (MVC formatters, plugin frameworks).
/// </summary>
public static int SerializeChunked(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore<object?>(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false);
/// <summary>
/// Serialize a value into a chunked stream where each chunk carries a self-describing
/// frame header — <c>[201][UINT16 size][data]</c> per chunk, with a final <c>[202]</c>
@ -539,6 +651,13 @@ public static partial class AcBinarySerializer
public static int SerializeChunkedFramed<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true);
/// <summary>
/// Non-generic <c>Type</c>-based counterpart to
/// <see cref="SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.
/// </summary>
public static int SerializeChunkedFramed(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore<object?>(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true);
/// <summary>
/// Internal flush-tunable framed PipeWriter overload — used by <c>AyCode.Services</c>
/// (SignalR hub protocol) on Kestrel transport output, which is parallel-capable. External
@ -1325,32 +1444,15 @@ public static partial class AcBinarySerializer
#endif
}
// FastWire: skip FixStr optimization (UTF-8 specific), write String marker + UTF-16 data
if (context.FastWire)
{
context.WriteByte(BinaryTypeCode.String);
context.WriteStringUtf8(value);
return;
}
// BASELINE TEMP: ASCII fast paths disabled — every string takes the pure UTF-8 D-2 path
// (String marker + VarUInt byte count + UTF-8 bytes). Used to measure custom UTF-8 decoder
// performance in isolation, without FixStr-vs-String dispatch interference. Re-enable the
// FixStr dispatch below once the decoder optimization is benchmarked and verified.
//
//// Fast path for short strings: check length first (cheap), then ASCII
//// FixStr encodes type+length in single byte for strings <= 31 chars
//var length = value.Length;
//if (length <= BinaryTypeCode.FixStrMaxLength)
//{
// // For short strings, use direct ASCII copy (avoids double validation)
// context.WriteFixStrDirect(value);
// return;
//}
// All strings (short and long) — standard UTF-8 encoding via D-2 single-pass path
context.WriteByte(BinaryTypeCode.String);
context.WriteStringUtf8(value);
// Marker-dispatch (ACCORE-BIN-T-M3R7): WriteStringWithDispatch encodes UTF-8 once, detects
// ASCII via bytesWritten == charLength, and emits the optimal wire marker:
// • bytesWritten ≤ 31 + ASCII → FixStrAscii (single byte marker + length, ASCII payload)
// • bytesWritten ≤ 31 + UTF-8 → FixStr (single byte marker + length, UTF-8 payload)
// • bytesWritten > 31 + ASCII → StringAscii (marker + VarUInt length + ASCII payload)
// • bytesWritten > 31 + UTF-8 → String (marker + VarUInt length + UTF-8 payload)
// Reader dispatches on the ASCII marker to skip UTF-8 decode (byte→char widen only).
// FastWire path is handled inside WriteStringWithDispatch (no marker dispatch — UTF-16 raw).
context.WriteStringWithDispatch(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]

View File

@ -361,7 +361,23 @@ Symmetric to T8K3's analysis:
- `leaveOpen` parameter behaves per the System.Text.Json / MessagePack convention across all three modes.
## ACCORE-BIN-T-N9G6: Add non-generic `Type`-based `Serialize(object, Type, ...)` overloads
**Priority:** P2 · **Type:** Feature · **Related:** `ACCORE-BIN-T-T8K3`
**Priority:** P2 · **Type:** Feature · **Status:** Closed (2026-05-04) · **Related:** `ACCORE-BIN-T-T8K3`
### Resolution
Added in `AcBinarySerializer.cs`:
- `Serialize(object?, Type, opts)``byte[]`
- `Serialize(object?, Type, IBufferWriter<byte>, opts)``int`
- `SerializeChunked(object?, Type, PipeWriter, opts)``int`
- `SerializeChunkedFramed(object?, Type, PipeWriter, opts)``int`
Added in `AcBinaryDeserializer.cs`:
- `DeserializeFromPipeReaderAsync<T>(PipeReader, opts, ct)``Task<T?>`
- `DeserializeFromPipeReaderAsync(PipeReader, Type, opts, ct)``Task<object?>`
The `Deserialize(byte[], Type, opts)` / `Deserialize(ReadOnlySequence<byte>, Type, opts)` / `Deserialize(AsyncPipeReaderInput, Type, opts)` overloads already existed.
Consumed by ASP.NET Core MVC formatter package (`AyCode.Services/Mvc/`) — `AcBinaryInputFormatter`, `AcBinaryOutputFormatter`, `AddAcBinaryFormatters` extension. Media type: `application/vnd.acbinary`.
Plugin frameworks, ASP.NET ModelBinding, DI middleware, and DataContractSerializer-style "generic-API container" use-cases need to serialize an `object` whose type is known only at runtime. Current AcBinary surface forces a reflection trampoline through the generic `Serialize<T>`:

View File

@ -6,6 +6,7 @@
<Import Project="..//AyCode.Core.targets" />
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.11" />

View File

@ -0,0 +1,56 @@
using System.IO.Pipelines;
using AyCode.Core.Serializers.Binaries;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.Logging;
namespace AyCode.Services.Mvc;
/// <summary>
/// ASP.NET Core MVC InputFormatter for AcBinary wire format. Reads request body via PipeReader,
/// drains into AsyncPipeReaderInput, deserializes to ModelType. Standard ProblemDetails error
/// flow on failure (ModelState.AddModelError → 400 + application/problem+json).
/// </summary>
public class AcBinaryInputFormatter : InputFormatter
{
/// <summary>Vendor media type. <c>application/vnd.acbinary</c> by default.</summary>
public const string DefaultMediaType = "application/vnd.acbinary";
private readonly AcBinarySerializerOptions _options;
private readonly ILogger<AcBinaryInputFormatter>? _logger;
public AcBinaryInputFormatter(AcBinarySerializerOptions? options = null, ILogger<AcBinaryInputFormatter>? logger = null)
{
_options = options ?? AcBinarySerializerOptions.Default;
_logger = logger;
SupportedMediaTypes.Add(DefaultMediaType);
}
public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
{
if (context is null) throw new ArgumentNullException(nameof(context));
var ct = context.HttpContext.RequestAborted;
var reader = PipeReader.Create(context.HttpContext.Request.Body);
try
{
var model = await AcBinaryDeserializer.DeserializeFromPipeReaderAsync(reader, context.ModelType, _options, ct).ConfigureAwait(false);
return await InputFormatterResult.SuccessAsync(model).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "AcBinary deserialization failed for type {ModelType}", context.ModelType);
context.ModelState.TryAddModelError(context.ModelName ?? string.Empty, ex.Message);
return await InputFormatterResult.FailureAsync().ConfigureAwait(false);
}
finally
{
await reader.CompleteAsync().ConfigureAwait(false);
}
}
protected override bool CanReadType(Type type) => true;
}

View File

@ -0,0 +1,58 @@
using AyCode.Core.Serializers.Binaries;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
namespace AyCode.Services.Mvc;
/// <summary>
/// MVC builder extensions to register AcBinary input/output formatters.
/// Inserts at index 0 — AcBinary is preferred when the client's Accept header allows.
/// </summary>
public static class AcBinaryMvcBuilderExtensions
{
/// <summary>
/// Registers AcBinary input + output formatters on the MVC pipeline. Pass <paramref name="configure"/>
/// to customize <see cref="AcBinarySerializerOptions"/>; otherwise
/// <see cref="AcBinarySerializerOptions.Default"/> is used.
/// </summary>
public static IMvcBuilder AddAcBinaryFormatters(this IMvcBuilder builder, Action<AcBinarySerializerOptions>? configure = null)
{
if (builder is null) throw new ArgumentNullException(nameof(builder));
var options = AcBinarySerializerOptions.Default;
if (configure != null)
{
options = new AcBinarySerializerOptions();
configure(options);
}
builder.Services.Configure<MvcOptions>(opts =>
{
opts.InputFormatters.Insert(0, new AcBinaryInputFormatter(options));
opts.OutputFormatters.Insert(0, new AcBinaryOutputFormatter(options));
});
return builder;
}
/// <inheritdoc cref="AddAcBinaryFormatters(IMvcBuilder, Action{AcBinarySerializerOptions}?)"/>
public static IMvcCoreBuilder AddAcBinaryFormatters(this IMvcCoreBuilder builder, Action<AcBinarySerializerOptions>? configure = null)
{
if (builder is null) throw new ArgumentNullException(nameof(builder));
var options = AcBinarySerializerOptions.Default;
if (configure != null)
{
options = new AcBinarySerializerOptions();
configure(options);
}
builder.Services.Configure<MvcOptions>(opts =>
{
opts.InputFormatters.Insert(0, new AcBinaryInputFormatter(options));
opts.OutputFormatters.Insert(0, new AcBinaryOutputFormatter(options));
});
return builder;
}
}

View File

@ -0,0 +1,43 @@
using System.IO.Pipelines;
using AyCode.Core.Serializers.Binaries;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace AyCode.Services.Mvc;
/// <summary>
/// ASP.NET Core MVC OutputFormatter for AcBinary wire format. Wraps Response.Body as PipeWriter,
/// serializes via AcBinarySerializer.SerializeChunked (raw mode — pure AcBinary bytes, no per-chunk
/// framing). Auto-detected flush strategy on the underlying StreamPipeWriter (sequential per chunk).
/// </summary>
public class AcBinaryOutputFormatter : OutputFormatter
{
/// <summary>Vendor media type. <c>application/vnd.acbinary</c> by default.</summary>
public const string DefaultMediaType = "application/vnd.acbinary";
private readonly AcBinarySerializerOptions _options;
public AcBinaryOutputFormatter(AcBinarySerializerOptions? options = null)
{
_options = options ?? AcBinarySerializerOptions.Default;
SupportedMediaTypes.Add(DefaultMediaType);
}
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
{
if (context is null) throw new ArgumentNullException(nameof(context));
var ct = context.HttpContext.RequestAborted;
var pipeWriter = PipeWriter.Create(context.HttpContext.Response.Body);
try
{
AcBinarySerializer.SerializeChunked(context.Object, context.ObjectType ?? typeof(object), pipeWriter, _options);
}
finally
{
await pipeWriter.CompleteAsync().ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
}
}
protected override bool CanWriteType(Type? type) => true;
}