From 41c1a593927fcf71ef83b4825c659a4970e9b858 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 06:02:52 +0200 Subject: [PATCH] perf(net): frame recurring sends in place Write normal game-message and ACK packet framing directly into bounded stack spans, hash fragments without materialization, and send the populated slice to the socket. Preserve exact wire bytes and ISAAC failure ordering with differential and zero-allocation tests. --- .../2026-07-25-modern-runtime-slice-h.md | 4 +- ...7-25-slice-h-c3-direct-outbound-framing.md | 74 +++++++ .../Messages/GameMessageFragment.cs | 44 ++++ src/AcDream.Core.Net/Packets/PacketCodec.cs | 126 ++++++++++-- src/AcDream.Core.Net/WorldSession.cs | 37 +++- .../Messages/CharacterEnterWorldTests.cs | 37 ++++ .../Packets/PacketCodecEncodeTests.cs | 194 ++++++++++++++++++ 7 files changed, 485 insertions(+), 31 deletions(-) create mode 100644 docs/research/2026-07-25-slice-h-c3-direct-outbound-framing.md 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); + } }