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.
This commit is contained in:
Erik 2026-07-25 06:02:52 +02:00
parent e928c5dd02
commit 41c1a59392
7 changed files with 485 additions and 31 deletions

View file

@ -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:

View file

@ -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.

View file

@ -54,6 +54,50 @@ public static class GameMessageFragment
return new MessageFragment(header, gameMessageBytes.ToArray());
}
/// <summary>
/// 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.
/// </summary>
internal static int WriteSingleFragment(
Span<byte> destination,
uint fragmentSequence,
GameMessageGroup queue,
ReadOnlySpan<byte> 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;
}
/// <summary>
/// Concatenate a fragment's header + payload into the bytes that go
/// into a packet's body. Use when building the full <c>body</c> span

View file

@ -520,8 +520,6 @@ public static class PacketCodec
/// </param>
public static byte[] Encode(PacketHeader header, ReadOnlySpan<byte> 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));
// Hash any fragments in the body tail.
uint fragmentHash = 0;
if (header.HasFlag(PacketHeaderFlags.BlobFragments))
{
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);
}
byte[] datagram = new byte[PacketHeader.Size + body.Length];
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
FinalizeInPlace(
header,
datagram,
body.Length,
optionalLen,
outboundIsaac);
return datagram;
}
/// <summary>
/// Finalize a packet whose body has already been written immediately
/// after the fixed header in <paramref name="datagram"/>. Computes the
/// exact optional/fragment hash, consumes the outbound ISAAC word when
/// required, and writes the fixed header in place.
/// </summary>
internal static int FinalizeInPlace(
PacketHeader header,
Span<byte> datagram,
int bodyLength,
int optionalLength,
IsaacRandom? outboundIsaac)
{
if ((uint)bodyLength > ushort.MaxValue)
{
throw new ArgumentOutOfRangeException(
nameof(bodyLength));
}
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<byte> body = datagram.Slice(
PacketHeader.Size,
bodyLength);
uint payloadHash = CalculatePayloadHash(
body,
header.Flags,
optionalLength);
header.DataSize = checked((ushort)bodyLength);
uint headerHash = header.CalculateHeaderHash32();
uint payloadHash = optionalHash + fragmentHash;
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<byte> 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<byte> 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;
}
/// <summary>

View file

@ -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<byte> 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));
}
/// <summary>
@ -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<byte> body = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(body, serverPacketSequence);
Span<byte> 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)

View file

@ -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<byte> 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<ArgumentException>(
() => GameMessageFragment.WriteSingleFragment(
new byte[
MessageFragmentHeader.Size
+ message.Length - 1],
fragmentSequence: 1,
queue: GameMessageGroup.UIQueue,
gameMessageBytes: message));
}
}

View file

@ -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<byte> 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<byte> 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<ArgumentException>(
() => PacketCodec.FinalizeInPlace(
header,
malformed,
MessageFragmentHeader.Size,
optionalLength: 0,
afterFailure));
byte[] message = [1, 2, 3, 4];
Span<byte> 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<byte> 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<byte> datagram,
PacketHeader header,
IsaacRandom isaac,
ReadOnlySpan<byte> message)
{
int fragmentLength =
GameMessageFragment.WriteSingleFragment(
datagram.Slice(PacketHeader.Size),
fragmentSequence: 3,
GameMessageGroup.UIQueue,
message);
return PacketCodec.FinalizeInPlace(
header,
datagram,
fragmentLength,
optionalLength: 0,
isaac);
}
}