diff --git a/docs/plans/2026-07-25-modern-runtime-slice-h.md b/docs/plans/2026-07-25-modern-runtime-slice-h.md
index 810908dd..72fd43a0 100644
--- a/docs/plans/2026-07-25-modern-runtime-slice-h.md
+++ b/docs/plans/2026-07-25-modern-runtime-slice-h.md
@@ -146,8 +146,10 @@ H-c is executed as three independently reversible units:
fields, and fragments as borrowed views. Copy only multi-fragment state
that must survive the current datagram. Evidence:
[`../research/2026-07-25-slice-h-c2-borrowed-packet-decode.md`](../research/2026-07-25-slice-h-c2-borrowed-packet-decode.md).
-3. **H-c3 — direct outbound framing — PENDING.** Write packet and fragment
+3. **H-c3 — direct outbound framing — COMPLETE.** Write packet and fragment
framing into caller storage and remove intermediate payload arrays.
+ Evidence:
+ [`../research/2026-07-25-slice-h-c3-direct-outbound-framing.md`](../research/2026-07-25-slice-h-c3-direct-outbound-framing.md).
Retail/transport invariants:
diff --git a/docs/research/2026-07-25-slice-h-c3-direct-outbound-framing.md b/docs/research/2026-07-25-slice-h-c3-direct-outbound-framing.md
new file mode 100644
index 00000000..2c436bed
--- /dev/null
+++ b/docs/research/2026-07-25-slice-h-c3-direct-outbound-framing.md
@@ -0,0 +1,74 @@
+# Slice H-c3 — direct outbound framing
+
+## Result
+
+Recurring game messages and ACKs now go from caller bytes to the UDP socket
+without a managed framing allocation.
+
+For a normal game message, `WorldSession` reserves one 484-byte stack span:
+
+```text
+20-byte PacketHeader
+16-byte MessageFragmentHeader
+up to 448 bytes of existing GameMessage payload
+```
+
+`GameMessageFragment.WriteSingleFragment` writes the fragment header and
+payload directly after the packet-header reservation.
+`PacketCodec.FinalizeInPlace` validates and hashes that body, consumes the
+outbound ISAAC word, and writes the fixed header into the reserved prefix.
+`NetClient.Send` then passes only the populated slice to `Socket.SendTo`.
+
+ACKs use the same mechanism with one 24-byte stack span: 20 header bytes plus
+the four-byte acknowledged server sequence.
+
+The retained public `BuildSingleFragment`, `Serialize`, and `PacketCodec.Encode`
+APIs remain available for fixtures and infrequent callers. `Encode` now shares
+the span-based fragment hashing primitive, so it no longer materializes a
+fragment payload merely to calculate its checksum.
+
+## Behavior and failure ordering
+
+- packet, fragment, and game-action sequence behavior is unchanged;
+- fragment `Id`, `Count`, `Index`, `TotalSize`, and queue bytes are unchanged;
+- ACKs still reuse the most recently issued client packet sequence;
+- encrypted packets consume exactly one ISAAC word after complete structural
+ validation;
+- a malformed fragment throws before consuming the ISAAC stream;
+- the synchronous socket call completes before its stack storage expires;
+- messages above retail's 448-byte single-fragment payload limit retain the
+ existing explicit failure rather than silently truncating or inventing a
+ split policy.
+
+This is storage/mechanical work around the established retail/ACE wire
+contract. It does not change AC gameplay or packet-order behavior.
+
+## Deterministic evidence
+
+Tests prove:
+
+- the direct fragment writer is byte-identical to the existing owned
+ build/serialize path;
+- direct encrypted game-message framing is byte-identical to the owned path
+ with independently seeded ISAAC instances;
+- direct ACK framing is byte-identical to the owned path;
+- malformed input leaves the ISAAC stream untouched;
+- short destinations fail before writing past their boundary;
+- the warmed direct framing path allocates zero bytes over 1,000 iterations.
+
+A Release microbenchmark framed 500,000 representative 44-byte game messages:
+
+| Framing path | Time | Throughput cost | Allocated |
+|---|---:|---:|---:|
+| owned intermediates | 137.206 ms | 274.4 ns/message | 176,000,040 bytes |
+| direct stack span | 12.551 ms | 25.1 ns/message | 0 bytes |
+
+That is a 10.932x framing microbenchmark speedup and removes 352 managed bytes
+per representative outbound message.
+
+## Slice H-c remaining gate
+
+The code units are complete. H-c closeout requires connected login, world
+entry, ACK continuity, reconnect, portal, interaction, and graceful-disconnect
+coverage on the available RDP session. RDP is valid for network correctness
+but not for final GPU/frame-time performance acceptance.
diff --git a/src/AcDream.Core.Net/Messages/GameMessageFragment.cs b/src/AcDream.Core.Net/Messages/GameMessageFragment.cs
index 407451ad..fa3a8a25 100644
--- a/src/AcDream.Core.Net/Messages/GameMessageFragment.cs
+++ b/src/AcDream.Core.Net/Messages/GameMessageFragment.cs
@@ -54,6 +54,50 @@ public static class GameMessageFragment
return new MessageFragment(header, gameMessageBytes.ToArray());
}
+ ///
+ /// Write one complete fragment directly into caller-owned storage.
+ /// Returns the number of bytes written. This is the production send-path
+ /// primitive; it creates no intermediate payload or serialized array.
+ ///
+ internal static int WriteSingleFragment(
+ Span destination,
+ uint fragmentSequence,
+ GameMessageGroup queue,
+ ReadOnlySpan gameMessageBytes)
+ {
+ if (gameMessageBytes.Length
+ > MessageFragmentHeader.MaxFragmentDataSize)
+ {
+ throw new ArgumentException(
+ $"game message body ({gameMessageBytes.Length} bytes) exceeds single-fragment capacity "
+ + $"({MessageFragmentHeader.MaxFragmentDataSize} bytes). Multi-fragment split TBD.",
+ nameof(gameMessageBytes));
+ }
+
+ int wireSize =
+ MessageFragmentHeader.Size + gameMessageBytes.Length;
+ if (destination.Length < wireSize)
+ {
+ throw new ArgumentException(
+ $"destination must be at least {wireSize} bytes",
+ nameof(destination));
+ }
+
+ var header = new MessageFragmentHeader
+ {
+ Sequence = fragmentSequence,
+ Id = OutboundFragmentId,
+ Count = 1,
+ TotalSize = checked((ushort)wireSize),
+ Index = 0,
+ Queue = (ushort)queue,
+ };
+ header.Pack(destination);
+ gameMessageBytes.CopyTo(
+ destination.Slice(MessageFragmentHeader.Size));
+ return wireSize;
+ }
+
///
/// Concatenate a fragment's header + payload into the bytes that go
/// into a packet's body. Use when building the full body span
diff --git a/src/AcDream.Core.Net/Packets/PacketCodec.cs b/src/AcDream.Core.Net/Packets/PacketCodec.cs
index cdf537bc..dd7fdaea 100644
--- a/src/AcDream.Core.Net/Packets/PacketCodec.cs
+++ b/src/AcDream.Core.Net/Packets/PacketCodec.cs
@@ -520,8 +520,6 @@ public static class PacketCodec
///
public static byte[] Encode(PacketHeader header, ReadOnlySpan body, IsaacRandom? outboundIsaac)
{
- header.DataSize = checked((ushort)body.Length);
-
// Parse the optional-section length out of the body so we can hash
// it separately from any subsequent fragments. Without the BlobFragments
// flag, the entire body IS the optional section. With BlobFragments,
@@ -541,43 +539,129 @@ public static class PacketCodec
if (optionalLen < 0)
throw new ArgumentException("body's optional section is malformed", nameof(body));
- uint optionalHash = Hash32.Calculate(body.Slice(0, optionalLen));
+ byte[] datagram = new byte[PacketHeader.Size + body.Length];
+ body.CopyTo(datagram.AsSpan(PacketHeader.Size));
+ FinalizeInPlace(
+ header,
+ datagram,
+ body.Length,
+ optionalLen,
+ outboundIsaac);
+ return datagram;
+ }
- // Hash any fragments in the body tail.
- uint fragmentHash = 0;
- if (header.HasFlag(PacketHeaderFlags.BlobFragments))
+ ///
+ /// Finalize a packet whose body has already been written immediately
+ /// after the fixed header in . Computes the
+ /// exact optional/fragment hash, consumes the outbound ISAAC word when
+ /// required, and writes the fixed header in place.
+ ///
+ internal static int FinalizeInPlace(
+ PacketHeader header,
+ Span datagram,
+ int bodyLength,
+ int optionalLength,
+ IsaacRandom? outboundIsaac)
+ {
+ if ((uint)bodyLength > ushort.MaxValue)
{
- var tail = body.Slice(optionalLen);
- while (tail.Length > 0)
- {
- var (frag, consumed) = MessageFragment.TryParse(tail);
- if (frag is null || consumed == 0)
- throw new ArgumentException("body contains a malformed fragment", nameof(body));
- fragmentHash += CalculateFragmentHash32(frag.Value);
- tail = tail.Slice(consumed);
- }
+ throw new ArgumentOutOfRangeException(
+ nameof(bodyLength));
}
- uint headerHash = header.CalculateHeaderHash32();
- uint payloadHash = optionalHash + fragmentHash;
+ int datagramLength = checked(PacketHeader.Size + bodyLength);
+ if (datagram.Length < datagramLength)
+ {
+ throw new ArgumentException(
+ $"datagram must be at least {datagramLength} bytes",
+ nameof(datagram));
+ }
+ if ((uint)optionalLength > (uint)bodyLength)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(optionalLength));
+ }
+
+ ReadOnlySpan body = datagram.Slice(
+ PacketHeader.Size,
+ bodyLength);
+ uint payloadHash = CalculatePayloadHash(
+ body,
+ header.Flags,
+ optionalLength);
+
+ header.DataSize = checked((ushort)bodyLength);
+ uint headerHash = header.CalculateHeaderHash32();
if (header.HasFlag(PacketHeaderFlags.EncryptedChecksum))
{
if (outboundIsaac is null)
+ {
throw new InvalidOperationException(
"EncryptedChecksum flag set but no ISAAC keystream provided");
+ }
+
uint isaacKey = outboundIsaac.Next();
- header.Checksum = headerHash + (isaacKey ^ payloadHash);
+ header.Checksum =
+ headerHash + (isaacKey ^ payloadHash);
}
else
{
header.Checksum = headerHash + payloadHash;
}
- byte[] datagram = new byte[PacketHeader.Size + body.Length];
header.Pack(datagram);
- body.CopyTo(datagram.AsSpan(PacketHeader.Size));
- return datagram;
+ return datagramLength;
+ }
+
+ private static uint CalculatePayloadHash(
+ ReadOnlySpan body,
+ PacketHeaderFlags flags,
+ int optionalLength)
+ {
+ uint optionalHash = Hash32.Calculate(
+ body.Slice(0, optionalLength));
+ if ((flags & PacketHeaderFlags.BlobFragments) == 0)
+ {
+ if (optionalLength != body.Length)
+ {
+ throw new ArgumentException(
+ "non-fragment body contains bytes outside the optional section",
+ nameof(body));
+ }
+
+ return optionalHash;
+ }
+
+ uint fragmentHash = 0;
+ ReadOnlySpan remaining =
+ body.Slice(optionalLength);
+ while (!remaining.IsEmpty)
+ {
+ if (!MessageFragment.TryParseLayout(
+ remaining,
+ out _,
+ out int payloadLength,
+ out int consumed))
+ {
+ throw new ArgumentException(
+ "body contains a malformed fragment",
+ nameof(body));
+ }
+
+ fragmentHash +=
+ Hash32.Calculate(
+ remaining.Slice(
+ 0,
+ MessageFragmentHeader.Size))
+ + Hash32.Calculate(
+ remaining.Slice(
+ MessageFragmentHeader.Size,
+ payloadLength));
+ remaining = remaining.Slice(consumed);
+ }
+
+ return optionalHash + fragmentHash;
}
///
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 73f2e4de..caa0d6f3 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -2152,17 +2152,28 @@ public sealed class WorldSession : IDisposable
private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue)
{
- var fragment = GameMessageFragment.BuildSingleFragment(
- _fragmentSequence++, queue, gameMessageBody);
- byte[] packetBody = GameMessageFragment.Serialize(fragment);
+ Span datagram = stackalloc byte[
+ PacketHeader.Size
+ + MessageFragmentHeader.MaxFragmentSize];
+ int fragmentLength =
+ GameMessageFragment.WriteSingleFragment(
+ datagram.Slice(PacketHeader.Size),
+ _fragmentSequence++,
+ queue,
+ gameMessageBody);
var header = new PacketHeader
{
Sequence = _clientPacketSequence++,
Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
Id = _sessionClientId,
};
- byte[] datagram = PacketCodec.Encode(header, packetBody, _outboundIsaac);
- _net.Send(datagram);
+ int datagramLength = PacketCodec.FinalizeInPlace(
+ header,
+ datagram,
+ fragmentLength,
+ optionalLength: 0,
+ _outboundIsaac);
+ _net.Send(datagram.Slice(0, datagramLength));
}
///
@@ -2191,8 +2202,11 @@ public sealed class WorldSession : IDisposable
private void SendAck(uint serverPacketSequence)
{
// 4-byte body: little-endian u32 of the server sequence we're acking.
- Span body = stackalloc byte[4];
- BinaryPrimitives.WriteUInt32LittleEndian(body, serverPacketSequence);
+ Span datagram = stackalloc byte[
+ PacketHeader.Size + sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ datagram.Slice(PacketHeader.Size),
+ serverPacketSequence);
// Holtburger uses current_client_sequence (= packet_sequence - 1) for
// ack headers. We mirror that — acks borrow the most recently issued
@@ -2208,8 +2222,13 @@ public sealed class WorldSession : IDisposable
Id = _sessionClientId,
};
- byte[] datagram = PacketCodec.Encode(header, body, outboundIsaac: null);
- _net.Send(datagram);
+ int datagramLength = PacketCodec.FinalizeInPlace(
+ header,
+ datagram,
+ bodyLength: sizeof(uint),
+ optionalLength: sizeof(uint),
+ outboundIsaac: null);
+ _net.Send(datagram.Slice(0, datagramLength));
}
private void Transition(State next)
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs
index 0b607f0a..3968ed6b 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs
@@ -149,4 +149,41 @@ public class GameMessageFragmentTests
Assert.Equal(original.Header.Count, reparsed.Value.Header.Count);
Assert.Equal(original.Payload, reparsed.Value.Payload);
}
+
+ [Fact]
+ public void WriteSingleFragment_MatchesOwnedBuildAndSerialize()
+ {
+ byte[] message = [1, 2, 3, 4, 5, 6];
+ MessageFragment owned =
+ GameMessageFragment.BuildSingleFragment(
+ fragmentSequence: 99,
+ queue: GameMessageGroup.ControlQueue,
+ gameMessageBytes: message);
+ byte[] expected = GameMessageFragment.Serialize(owned);
+ Span destination = stackalloc byte[
+ MessageFragmentHeader.MaxFragmentSize];
+
+ int written = GameMessageFragment.WriteSingleFragment(
+ destination,
+ fragmentSequence: 99,
+ queue: GameMessageGroup.ControlQueue,
+ gameMessageBytes: message);
+
+ Assert.Equal(expected.Length, written);
+ Assert.Equal(expected, destination.Slice(0, written).ToArray());
+ }
+
+ [Fact]
+ public void WriteSingleFragment_ShortDestination_Throws()
+ {
+ byte[] message = [1, 2, 3, 4];
+ Assert.Throws(
+ () => GameMessageFragment.WriteSingleFragment(
+ new byte[
+ MessageFragmentHeader.Size
+ + message.Length - 1],
+ fragmentSequence: 1,
+ queue: GameMessageGroup.UIQueue,
+ gameMessageBytes: message));
+ }
}
diff --git a/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs
index aa9aa747..c276332c 100644
--- a/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs
@@ -1,4 +1,5 @@
using AcDream.Core.Net.Cryptography;
+using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests.Packets;
@@ -106,4 +107,197 @@ public class PacketCodecEncodeTests
Assert.Single(result.Packet!.Fragments);
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, result.Packet.Fragments[0].Payload);
}
+
+ [Fact]
+ public void FinalizeInPlace_DirectFragment_MatchesOwnedFraming()
+ {
+ byte[] message = [0x58, 0xF6, 0, 0, 1, 2, 3, 4];
+ const uint fragmentSequence = 91;
+ var ownedFragment =
+ GameMessageFragment.BuildSingleFragment(
+ fragmentSequence,
+ GameMessageGroup.UIQueue,
+ message);
+ byte[] ownedBody =
+ GameMessageFragment.Serialize(ownedFragment);
+ var header = new PacketHeader
+ {
+ Sequence = 44,
+ Flags = PacketHeaderFlags.BlobFragments
+ | PacketHeaderFlags.EncryptedChecksum,
+ Id = 12,
+ Time = 13,
+ Iteration = 14,
+ };
+ byte[] seed = [1, 2, 3, 4];
+ byte[] expected = PacketCodec.Encode(
+ header,
+ ownedBody,
+ new IsaacRandom(seed));
+
+ Span actual = stackalloc byte[
+ PacketHeader.Size
+ + MessageFragmentHeader.MaxFragmentSize];
+ int fragmentLength =
+ GameMessageFragment.WriteSingleFragment(
+ actual.Slice(PacketHeader.Size),
+ fragmentSequence,
+ GameMessageGroup.UIQueue,
+ message);
+ int datagramLength = PacketCodec.FinalizeInPlace(
+ header,
+ actual,
+ fragmentLength,
+ optionalLength: 0,
+ new IsaacRandom(seed));
+
+ Assert.Equal(expected.Length, datagramLength);
+ Assert.Equal(
+ expected,
+ actual.Slice(0, datagramLength).ToArray());
+ }
+
+ [Fact]
+ public void FinalizeInPlace_DirectAck_MatchesOwnedFraming()
+ {
+ const uint acknowledgedSequence = 0x12345678;
+ var header = new PacketHeader
+ {
+ Sequence = 22,
+ Flags = PacketHeaderFlags.AckSequence,
+ Id = 7,
+ };
+ byte[] body = new byte[sizeof(uint)];
+ System.Buffers.Binary.BinaryPrimitives
+ .WriteUInt32LittleEndian(
+ body,
+ acknowledgedSequence);
+ byte[] expected = PacketCodec.Encode(
+ header,
+ body,
+ outboundIsaac: null);
+ Span actual = stackalloc byte[
+ PacketHeader.Size + sizeof(uint)];
+ body.CopyTo(actual.Slice(PacketHeader.Size));
+
+ int datagramLength = PacketCodec.FinalizeInPlace(
+ header,
+ actual,
+ bodyLength: sizeof(uint),
+ optionalLength: sizeof(uint),
+ outboundIsaac: null);
+
+ Assert.Equal(expected, actual.Slice(0, datagramLength).ToArray());
+ }
+
+ [Fact]
+ public void FinalizeInPlace_MalformedFragment_DoesNotConsumeIsaac()
+ {
+ byte[] seed = [9, 8, 7, 6];
+ var header = new PacketHeader
+ {
+ Sequence = 33,
+ Flags = PacketHeaderFlags.BlobFragments
+ | PacketHeaderFlags.EncryptedChecksum,
+ Id = 5,
+ };
+ byte[] malformed = new byte[
+ PacketHeader.Size + MessageFragmentHeader.Size];
+ new MessageFragmentHeader
+ {
+ Count = 0,
+ TotalSize = MessageFragmentHeader.Size,
+ }.Pack(malformed.AsSpan(PacketHeader.Size));
+ var afterFailure = new IsaacRandom(seed);
+ Assert.Throws(
+ () => PacketCodec.FinalizeInPlace(
+ header,
+ malformed,
+ MessageFragmentHeader.Size,
+ optionalLength: 0,
+ afterFailure));
+
+ byte[] message = [1, 2, 3, 4];
+ Span actual = stackalloc byte[
+ PacketHeader.Size
+ + MessageFragmentHeader.MaxFragmentSize];
+ int fragmentLength =
+ GameMessageFragment.WriteSingleFragment(
+ actual.Slice(PacketHeader.Size),
+ fragmentSequence: 1,
+ GameMessageGroup.UIQueue,
+ message);
+ int actualLength = PacketCodec.FinalizeInPlace(
+ header,
+ actual,
+ fragmentLength,
+ optionalLength: 0,
+ afterFailure);
+
+ var owned = GameMessageFragment.BuildSingleFragment(
+ fragmentSequence: 1,
+ GameMessageGroup.UIQueue,
+ message);
+ byte[] expected = PacketCodec.Encode(
+ header,
+ GameMessageFragment.Serialize(owned),
+ new IsaacRandom(seed));
+ Assert.Equal(
+ expected,
+ actual.Slice(0, actualLength).ToArray());
+ }
+
+ [Fact]
+ public void DirectSingleFragmentFraming_WarmPath_AllocatesNothing()
+ {
+ byte[] message = [1, 2, 3, 4, 5, 6, 7, 8];
+ var header = new PacketHeader
+ {
+ Sequence = 1,
+ Flags = PacketHeaderFlags.BlobFragments
+ | PacketHeaderFlags.EncryptedChecksum,
+ Id = 2,
+ };
+ var isaac = new IsaacRandom([4, 3, 2, 1]);
+ Span datagram = stackalloc byte[
+ PacketHeader.Size
+ + MessageFragmentHeader.MaxFragmentSize];
+
+ WriteDirect(datagram, header, isaac, message);
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ int checksum = 0;
+ for (int iteration = 0; iteration < 1_000; iteration++)
+ {
+ checksum += WriteDirect(
+ datagram,
+ header,
+ isaac,
+ message);
+ }
+ long allocated =
+ GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.NotEqual(0, checksum);
+ Assert.Equal(0, allocated);
+ }
+
+ private static int WriteDirect(
+ Span datagram,
+ PacketHeader header,
+ IsaacRandom isaac,
+ ReadOnlySpan message)
+ {
+ int fragmentLength =
+ GameMessageFragment.WriteSingleFragment(
+ datagram.Slice(PacketHeader.Size),
+ fragmentSequence: 3,
+ GameMessageGroup.UIQueue,
+ message);
+ return PacketCodec.FinalizeInPlace(
+ header,
+ datagram,
+ fragmentLength,
+ optionalLength: 0,
+ isaac);
+ }
}