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:
parent
e928c5dd02
commit
41c1a59392
7 changed files with 485 additions and 31 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
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))
|
||||
/// <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)
|
||||
{
|
||||
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<byte> 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<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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue