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); + } }