perf(net): borrow inbound packet storage
Decode headers, optional fields, fragments, and single-fragment messages directly over pooled datagrams. Copy only fragment state that crosses a datagram lifetime, preserve synchronous dispatch and ACK ordering, and lock the path to the owned decoder with differential and zero-allocation tests.
This commit is contained in:
parent
7211bb1bf7
commit
e928c5dd02
18 changed files with 1329 additions and 66 deletions
|
|
@ -142,9 +142,10 @@ H-c is executed as three independently reversible units:
|
||||||
datagrams, unchanged blocking handshake cadence, and direct span sends.
|
datagrams, unchanged blocking handshake cadence, and direct span sends.
|
||||||
Evidence:
|
Evidence:
|
||||||
[`../research/2026-07-25-slice-h-c1-pooled-receive-owner.md`](../research/2026-07-25-slice-h-c1-pooled-receive-owner.md).
|
[`../research/2026-07-25-slice-h-c1-pooled-receive-owner.md`](../research/2026-07-25-slice-h-c1-pooled-receive-owner.md).
|
||||||
2. **H-c2 — borrowed decode — ACTIVE.** Parse and verify headers, optional
|
2. **H-c2 — borrowed decode — COMPLETE.** Parse and verify headers, optional
|
||||||
fields, and fragments as borrowed views. Copy only multi-fragment state
|
fields, and fragments as borrowed views. Copy only multi-fragment state
|
||||||
that must survive the current datagram.
|
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 — PENDING.** Write packet and fragment
|
||||||
framing into caller storage and remove intermediate payload arrays.
|
framing into caller storage and remove intermediate payload arrays.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Slice H-c2 — borrowed packet decode
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
The production receive path no longer materializes an owned `Packet`,
|
||||||
|
`PacketHeaderOptional`, `BodyBytes`, fragment list, or single-fragment payload
|
||||||
|
array for every accepted UDP datagram. It now decodes one borrowed view over
|
||||||
|
the right-sized pooled datagram:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pooled datagram
|
||||||
|
-> borrowed packet/header/optional views
|
||||||
|
-> allocation-free fragment enumeration
|
||||||
|
-> single-fragment message borrows the same storage
|
||||||
|
-> synchronous message/event parse and dispatch
|
||||||
|
-> pooled datagram returned
|
||||||
|
```
|
||||||
|
|
||||||
|
Only a multi-fragment logical message copies its fragment payloads. That copy
|
||||||
|
is required because those bytes must survive beyond the current datagram while
|
||||||
|
the remaining fragments arrive.
|
||||||
|
|
||||||
|
## Lifetime contract
|
||||||
|
|
||||||
|
`BorrowedPacket`, `BorrowedOptionalHeader`, and `BorrowedMessageFragment` are
|
||||||
|
internal types. Their memory is valid only while the caller retains the
|
||||||
|
datagram owner. `WorldSession.ProcessDatagram` performs parsing and event
|
||||||
|
dispatch synchronously before returning that owner.
|
||||||
|
|
||||||
|
`GameEventEnvelope.TryParseBorrowed` is likewise internal. Registered event
|
||||||
|
handlers must parse or copy durable state during `Dispatch`; they must not
|
||||||
|
retain the envelope or payload. The existing public byte-array parser remains
|
||||||
|
available for independently owned fixtures and callers.
|
||||||
|
|
||||||
|
Multi-fragment assembly copies on first acceptance, not on completion, so
|
||||||
|
returning either source datagram cannot invalidate the partial message.
|
||||||
|
Conflicting `Count` or `Queue` values for a reused sequence cannot corrupt an
|
||||||
|
accepted partial. Duplicate fragments remain idempotent.
|
||||||
|
|
||||||
|
## Behavior preserved
|
||||||
|
|
||||||
|
- optional-header field order exactly matches `PacketHeaderOptional.Parse`;
|
||||||
|
- unencrypted and ISAAC checksum verification consume the same bytes and key;
|
||||||
|
- packet ACK and last-heard placement in `WorldSession` are unchanged;
|
||||||
|
- fragment grouping remains keyed by message `Sequence`;
|
||||||
|
- complete messages are delivered in the same fragment-index order;
|
||||||
|
- game-event handlers remain synchronous on the update/render thread;
|
||||||
|
- malformed zero-count and out-of-range-index fragments are rejected before
|
||||||
|
assembly rather than indexing invalid storage.
|
||||||
|
|
||||||
|
Named-retail `CNetLayerPacket` remains the packet-size reference. ACE's packet
|
||||||
|
parser and Holtburger's inbound fragment path are the wire/order
|
||||||
|
cross-references; this slice changes .NET storage ownership only.
|
||||||
|
|
||||||
|
## Deterministic evidence
|
||||||
|
|
||||||
|
The differential suite feeds the owned and borrowed decoders identical
|
||||||
|
datagrams and compares:
|
||||||
|
|
||||||
|
- all optional fields in their retail wire order;
|
||||||
|
- retransmit sequence order;
|
||||||
|
- login payload coverage;
|
||||||
|
- one and multiple fragments;
|
||||||
|
- body, optional, fragment-header, and fragment-payload bytes;
|
||||||
|
- matching seeded ISAAC checksum consumption;
|
||||||
|
- every malformed-packet error category touched by this slice.
|
||||||
|
|
||||||
|
Fragment tests prove that a single-fragment result aliases its source storage,
|
||||||
|
while a multi-fragment result survives mutations of both source datagrams.
|
||||||
|
The warmed single-fragment decode/enumeration unit test measures zero
|
||||||
|
current-thread allocation over 1,000 iterations.
|
||||||
|
|
||||||
|
A Release microbenchmark over 500,000 valid 48-byte, single-fragment packets
|
||||||
|
measured:
|
||||||
|
|
||||||
|
| Decoder | Time | Throughput cost | Allocated |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| owned | 246.372 ms | 492.7 ns/packet | 200,000,040 bytes |
|
||||||
|
| borrowed | 28.431 ms | 56.9 ns/packet | 40 bytes |
|
||||||
|
|
||||||
|
That is an 8.666x decoder microbenchmark speedup and removes the warmed
|
||||||
|
per-packet allocation (the reported 40 bytes are the benchmark timer).
|
||||||
|
|
||||||
|
## Remaining H-c work
|
||||||
|
|
||||||
|
H-c3 removes outbound packet/fragment intermediate arrays by writing the exact
|
||||||
|
wire framing directly into caller-provided storage. H-c closeout then runs the
|
||||||
|
connected lifecycle, reconnect, portal, interaction, and soak gates.
|
||||||
|
|
@ -35,16 +35,16 @@ public static class EmoteText
|
||||||
string SenderName,
|
string SenderName,
|
||||||
string Text);
|
string Text);
|
||||||
|
|
||||||
public static Parsed? TryParse(byte[] body)
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||||
{
|
{
|
||||||
if (body is null || body.Length < 8) return null;
|
if (body.Length < 8) return null;
|
||||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||||
if (opcode != Opcode) return null;
|
if (opcode != Opcode) return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
int pos = 4;
|
int pos = 4;
|
||||||
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos));
|
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
string senderName = StringReader.ReadString16L(body, ref pos);
|
string senderName = StringReader.ReadString16L(body, ref pos);
|
||||||
string text = StringReader.ReadString16L(body, ref pos);
|
string text = StringReader.ReadString16L(body, ref pos);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ namespace AcDream.Core.Net.Messages;
|
||||||
/// Handlers are invoked synchronously on the thread that called
|
/// Handlers are invoked synchronously on the thread that called
|
||||||
/// <see cref="Dispatch"/> — normally the render thread since
|
/// <see cref="Dispatch"/> — normally the render thread since
|
||||||
/// <see cref="WorldSession"/>'s decode path runs there in the current
|
/// <see cref="WorldSession"/>'s decode path runs there in the current
|
||||||
/// architecture. Handlers must not block.
|
/// architecture. Handlers must not block or retain the envelope/payload:
|
||||||
|
/// production payload memory borrows a pooled inbound datagram and expires
|
||||||
|
/// when the synchronous dispatch call returns. Handlers must parse or copy
|
||||||
|
/// any state they need to keep.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GameEventDispatcher
|
public sealed class GameEventDispatcher
|
||||||
|
|
@ -86,7 +89,8 @@ public sealed class GameEventDispatcher
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Route an envelope to its handler, or log as unhandled. Exceptions
|
/// Route an envelope to its handler, or log as unhandled. Exceptions
|
||||||
/// inside handlers are swallowed to keep the decode loop alive — a
|
/// inside handlers are swallowed to keep the decode loop alive — a
|
||||||
/// malformed event from the server should not crash the client.
|
/// malformed event from the server should not crash the client. The
|
||||||
|
/// handler must not retain <paramref name="envelope"/> or its payload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispatch(GameEventEnvelope envelope)
|
public void Dispatch(GameEventEnvelope envelope)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -42,16 +42,28 @@ public readonly record struct GameEventEnvelope(
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static GameEventEnvelope? TryParse(byte[] body)
|
public static GameEventEnvelope? TryParse(byte[] body)
|
||||||
{
|
{
|
||||||
if (body is null || body.Length < HeaderSize) return null;
|
ArgumentNullException.ThrowIfNull(body);
|
||||||
|
return TryParseBorrowed(body);
|
||||||
|
}
|
||||||
|
|
||||||
uint outer = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
/// <summary>
|
||||||
|
/// Internal receive-path parser. The returned payload borrows
|
||||||
|
/// <paramref name="body"/> and must not escape synchronous dispatch.
|
||||||
|
/// </summary>
|
||||||
|
internal static GameEventEnvelope? TryParseBorrowed(
|
||||||
|
ReadOnlyMemory<byte> body)
|
||||||
|
{
|
||||||
|
if (body.Length < HeaderSize) return null;
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> span = body.Span;
|
||||||
|
uint outer = BinaryPrimitives.ReadUInt32LittleEndian(span);
|
||||||
if (outer != Opcode) return null;
|
if (outer != Opcode) return null;
|
||||||
|
|
||||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
|
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(4));
|
||||||
uint sequence = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
|
uint sequence = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(8));
|
||||||
uint eventType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
|
uint eventType = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(12));
|
||||||
|
|
||||||
var payload = body.AsMemory(HeaderSize);
|
ReadOnlyMemory<byte> payload = body.Slice(HeaderSize);
|
||||||
return new GameEventEnvelope(guid, sequence, (GameEventType)eventType, payload);
|
return new GameEventEnvelope(guid, sequence, (GameEventType)eventType, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,9 @@ public static class HearSpeech
|
||||||
uint ChatType,
|
uint ChatType,
|
||||||
bool IsRanged);
|
bool IsRanged);
|
||||||
|
|
||||||
public static Parsed? TryParse(byte[] body)
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||||
{
|
{
|
||||||
if (body is null || body.Length < 16) return null;
|
if (body.Length < 16) return null;
|
||||||
|
|
||||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||||
bool isRanged;
|
bool isRanged;
|
||||||
|
|
@ -62,8 +62,8 @@ public static class HearSpeech
|
||||||
string text = ReadString16L(body, ref pos);
|
string text = ReadString16L(body, ref pos);
|
||||||
string sender = ReadString16L(body, ref pos);
|
string sender = ReadString16L(body, ref pos);
|
||||||
if (body.Length - pos < 8) return null;
|
if (body.Length - pos < 8) return null;
|
||||||
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); pos += 4;
|
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4;
|
||||||
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); pos += 4;
|
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4;
|
||||||
return new Parsed(text, sender, senderGuid, chatType, isRanged);
|
return new Parsed(text, sender, senderGuid, chatType, isRanged);
|
||||||
}
|
}
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@ public static class PlayerKilled
|
||||||
uint VictimGuid,
|
uint VictimGuid,
|
||||||
uint KillerGuid);
|
uint KillerGuid);
|
||||||
|
|
||||||
public static Parsed? TryParse(byte[] body)
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||||
{
|
{
|
||||||
if (body is null || body.Length < 4) return null;
|
if (body.Length < 4) return null;
|
||||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||||
if (opcode != Opcode) return null;
|
if (opcode != Opcode) return null;
|
||||||
|
|
||||||
|
|
@ -41,9 +41,9 @@ public static class PlayerKilled
|
||||||
int pos = 4;
|
int pos = 4;
|
||||||
string deathMessage = StringReader.ReadString16L(body, ref pos);
|
string deathMessage = StringReader.ReadString16L(body, ref pos);
|
||||||
if (body.Length - pos < 8) return null;
|
if (body.Length - pos < 8) return null;
|
||||||
uint victimGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos));
|
uint victimGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
uint killerGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos));
|
uint killerGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
return new Parsed(deathMessage, victimGuid, killerGuid);
|
return new Parsed(deathMessage, victimGuid, killerGuid);
|
||||||
}
|
}
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,9 @@ public static class ServerMessage
|
||||||
|
|
||||||
public readonly record struct Parsed(string Message, uint ChatType);
|
public readonly record struct Parsed(string Message, uint ChatType);
|
||||||
|
|
||||||
public static Parsed? TryParse(byte[] body)
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||||
{
|
{
|
||||||
if (body is null || body.Length < 8) return null;
|
if (body.Length < 8) return null;
|
||||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||||
if (opcode != Opcode) return null;
|
if (opcode != Opcode) return null;
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ public static class ServerMessage
|
||||||
int pos = 4;
|
int pos = 4;
|
||||||
string message = StringReader.ReadString16L(body, ref pos);
|
string message = StringReader.ReadString16L(body, ref pos);
|
||||||
if (body.Length - pos < 4) return null;
|
if (body.Length - pos < 4) return null;
|
||||||
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos));
|
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
return new Parsed(message, chatType);
|
return new Parsed(message, chatType);
|
||||||
}
|
}
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
|
|
|
||||||
|
|
@ -31,16 +31,16 @@ public static class SoulEmote
|
||||||
string SenderName,
|
string SenderName,
|
||||||
string Text);
|
string Text);
|
||||||
|
|
||||||
public static Parsed? TryParse(byte[] body)
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||||
{
|
{
|
||||||
if (body is null || body.Length < 8) return null;
|
if (body.Length < 8) return null;
|
||||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||||
if (opcode != Opcode) return null;
|
if (opcode != Opcode) return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
int pos = 4;
|
int pos = 4;
|
||||||
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos));
|
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
string senderName = StringReader.ReadString16L(body, ref pos);
|
string senderName = StringReader.ReadString16L(body, ref pos);
|
||||||
string text = StringReader.ReadString16L(body, ref pos);
|
string text = StringReader.ReadString16L(body, ref pos);
|
||||||
|
|
|
||||||
|
|
@ -143,9 +143,9 @@ public static class TurbineChat
|
||||||
/// for any structural error (truncation, unknown blob_type, ACE
|
/// for any structural error (truncation, unknown blob_type, ACE
|
||||||
/// id-mismatch in RequestSendToRoomById, malformed UTF-16 string).
|
/// id-mismatch in RequestSendToRoomById, malformed UTF-16 string).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Parsed? TryParse(byte[] body)
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||||
{
|
{
|
||||||
if (body is null || body.Length < 4 + HeaderSize) return null;
|
if (body.Length < 4 + HeaderSize) return null;
|
||||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||||
if (opcode != Opcode) return null;
|
if (opcode != Opcode) return null;
|
||||||
|
|
||||||
|
|
@ -259,7 +259,7 @@ public static class TurbineChat
|
||||||
int remaining = expectedPayload;
|
int remaining = expectedPayload;
|
||||||
if (body.Length - pos < remaining) return null;
|
if (body.Length - pos < remaining) return null;
|
||||||
var bytes = new byte[remaining];
|
var bytes = new byte[remaining];
|
||||||
Array.Copy(body, pos, bytes, 0, remaining);
|
body.Slice(pos, remaining).CopyTo(bytes);
|
||||||
pos += remaining;
|
pos += remaining;
|
||||||
payload = new Payload.Unknown(bytes);
|
payload = new Payload.Unknown(bytes);
|
||||||
break;
|
break;
|
||||||
|
|
@ -403,7 +403,9 @@ public static class TurbineChat
|
||||||
/// the high bit of the first byte is set the prefix is two bytes
|
/// the high bit of the first byte is set the prefix is two bytes
|
||||||
/// (high 7 bits of byte 0 + all 8 bits of byte 1).
|
/// (high 7 bits of byte 0 + all 8 bits of byte 1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string ReadTurbineString(byte[] data, ref int pos)
|
public static string ReadTurbineString(
|
||||||
|
ReadOnlySpan<byte> data,
|
||||||
|
ref int pos)
|
||||||
{
|
{
|
||||||
if (data.Length - pos < 1) throw new FormatException("turbine str: truncated len");
|
if (data.Length - pos < 1) throw new FormatException("turbine str: truncated len");
|
||||||
int chars = data[pos];
|
int chars = data[pos];
|
||||||
|
|
@ -422,7 +424,8 @@ public static class TurbineChat
|
||||||
|
|
||||||
// String.from_utf16 in Rust validates surrogate pairs; .NET's
|
// String.from_utf16 in Rust validates surrogate pairs; .NET's
|
||||||
// Encoding.Unicode.GetString matches that semantics.
|
// Encoding.Unicode.GetString matches that semantics.
|
||||||
string s = Encoding.Unicode.GetString(data, pos, (int)bytesLen);
|
string s = Encoding.Unicode.GetString(
|
||||||
|
data.Slice(pos, (int)bytesLen));
|
||||||
pos += (int)bytesLen;
|
pos += (int)bytesLen;
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
@ -463,9 +466,12 @@ public static class TurbineChat
|
||||||
// Helpers
|
// Helpers
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static uint ReadU32(byte[] data, ref int pos)
|
private static uint ReadU32(
|
||||||
|
ReadOnlySpan<byte> data,
|
||||||
|
ref int pos)
|
||||||
{
|
{
|
||||||
uint v = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(pos, 4));
|
uint v = BinaryPrimitives.ReadUInt32LittleEndian(
|
||||||
|
data.Slice(pos, 4));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
src/AcDream.Core.Net/Packets/BorrowedPacket.cs
Normal file
74
src/AcDream.Core.Net/Packets/BorrowedPacket.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
namespace AcDream.Core.Net.Packets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allocation-free decoded packet view. Every memory member borrows the
|
||||||
|
/// caller's datagram and is valid only while that owner retains the buffer.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct BorrowedPacket(
|
||||||
|
PacketHeader Header,
|
||||||
|
BorrowedOptionalHeader Optional,
|
||||||
|
ReadOnlyMemory<byte> Body,
|
||||||
|
ReadOnlyMemory<byte> FragmentBytes,
|
||||||
|
int FragmentCount)
|
||||||
|
{
|
||||||
|
public BorrowedFragmentEnumerable Fragments =>
|
||||||
|
new(FragmentBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct BorrowedOptionalHeader(
|
||||||
|
uint AckSequence,
|
||||||
|
double TimeSync,
|
||||||
|
float EchoRequestClientTime,
|
||||||
|
uint FlowBytes,
|
||||||
|
ushort FlowInterval,
|
||||||
|
double ConnectRequestServerTime,
|
||||||
|
ulong ConnectRequestCookie,
|
||||||
|
uint ConnectRequestClientId,
|
||||||
|
uint ConnectRequestServerSeed,
|
||||||
|
uint ConnectRequestClientSeed,
|
||||||
|
ReadOnlyMemory<byte> RawBytes,
|
||||||
|
ReadOnlyMemory<byte> RetransmitRequestBytes,
|
||||||
|
int RetransmitRequestCount);
|
||||||
|
|
||||||
|
internal readonly record struct BorrowedPacketDecodeResult(
|
||||||
|
BorrowedPacket Packet,
|
||||||
|
PacketCodec.DecodeError Error)
|
||||||
|
{
|
||||||
|
public bool IsOk => Error == PacketCodec.DecodeError.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct BorrowedMessageFragment(
|
||||||
|
MessageFragmentHeader Header,
|
||||||
|
ReadOnlyMemory<byte> Payload);
|
||||||
|
|
||||||
|
internal readonly struct BorrowedFragmentEnumerable(
|
||||||
|
ReadOnlyMemory<byte> encoded)
|
||||||
|
{
|
||||||
|
public Enumerator GetEnumerator() => new(encoded);
|
||||||
|
|
||||||
|
internal struct Enumerator(ReadOnlyMemory<byte> remaining)
|
||||||
|
{
|
||||||
|
private ReadOnlyMemory<byte> _remaining = remaining;
|
||||||
|
|
||||||
|
public BorrowedMessageFragment Current { get; private set; }
|
||||||
|
|
||||||
|
public bool MoveNext()
|
||||||
|
{
|
||||||
|
if (_remaining.IsEmpty)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!MessageFragment.TryParseBorrowed(
|
||||||
|
_remaining,
|
||||||
|
out BorrowedMessageFragment fragment,
|
||||||
|
out int consumed))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"validated packet contained an invalid fragment");
|
||||||
|
}
|
||||||
|
|
||||||
|
Current = fragment;
|
||||||
|
_remaining = _remaining.Slice(consumed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -95,6 +95,80 @@ public sealed class FragmentAssembler
|
||||||
return combined;
|
return combined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production borrowed-memory path. A complete single-fragment message
|
||||||
|
/// is returned as a view into the current datagram. Multi-fragment
|
||||||
|
/// payloads are copied only because they must survive that datagram's
|
||||||
|
/// pooled lifetime.
|
||||||
|
/// </summary>
|
||||||
|
internal bool TryIngest(
|
||||||
|
in BorrowedMessageFragment fragment,
|
||||||
|
out ReadOnlyMemory<byte> message,
|
||||||
|
out ushort messageQueue)
|
||||||
|
{
|
||||||
|
MessageFragmentHeader header = fragment.Header;
|
||||||
|
message = ReadOnlyMemory<byte>.Empty;
|
||||||
|
messageQueue = 0;
|
||||||
|
|
||||||
|
if (header.Count == 1 && header.Index == 0)
|
||||||
|
{
|
||||||
|
message = fragment.Payload;
|
||||||
|
messageQueue = header.Queue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_inFlight.TryGetValue(
|
||||||
|
header.Sequence,
|
||||||
|
out PartialMessage? partial))
|
||||||
|
{
|
||||||
|
partial = new PartialMessage(
|
||||||
|
header.Count,
|
||||||
|
header.Queue);
|
||||||
|
_inFlight[header.Sequence] = partial;
|
||||||
|
}
|
||||||
|
else if (partial.TotalFragments != header.Count
|
||||||
|
|| partial.Queue != header.Queue)
|
||||||
|
{
|
||||||
|
// Same sequence with conflicting identity is malformed. Preserve
|
||||||
|
// the first accepted partial instead of corrupting its layout.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partial.Fragments[header.Index] is null)
|
||||||
|
{
|
||||||
|
partial.Fragments[header.Index] =
|
||||||
|
fragment.Payload.ToArray();
|
||||||
|
partial.ReceivedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partial.ReceivedCount < partial.TotalFragments)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
int totalBytes = 0;
|
||||||
|
for (int index = 0;
|
||||||
|
index < partial.TotalFragments;
|
||||||
|
index++)
|
||||||
|
{
|
||||||
|
totalBytes += partial.Fragments[index]!.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
var combined = new byte[totalBytes];
|
||||||
|
int offset = 0;
|
||||||
|
for (int index = 0;
|
||||||
|
index < partial.TotalFragments;
|
||||||
|
index++)
|
||||||
|
{
|
||||||
|
byte[] payload = partial.Fragments[index]!;
|
||||||
|
payload.CopyTo(combined, offset);
|
||||||
|
offset += payload.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
_inFlight.Remove(header.Sequence);
|
||||||
|
message = combined;
|
||||||
|
messageQueue = partial.Queue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Discard all in-flight partial messages.</summary>
|
/// <summary>Discard all in-flight partial messages.</summary>
|
||||||
public void DropAll() => _inFlight.Clear();
|
public void DropAll() => _inFlight.Clear();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,25 +20,75 @@ public readonly record struct MessageFragment(MessageFragmentHeader Header, byte
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static (MessageFragment? fragment, int consumed) TryParse(ReadOnlySpan<byte> source)
|
public static (MessageFragment? fragment, int consumed) TryParse(ReadOnlySpan<byte> source)
|
||||||
{
|
{
|
||||||
if (source.Length < MessageFragmentHeader.Size)
|
if (!TryParseLayout(
|
||||||
|
source,
|
||||||
|
out MessageFragmentHeader header,
|
||||||
|
out int payloadLength,
|
||||||
|
out int consumed))
|
||||||
|
{
|
||||||
return (null, 0);
|
return (null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
var header = MessageFragmentHeader.Unpack(source);
|
byte[] payload = source
|
||||||
|
.Slice(MessageFragmentHeader.Size, payloadLength)
|
||||||
|
.ToArray();
|
||||||
|
return (new MessageFragment(header, payload), consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryParseBorrowed(
|
||||||
|
ReadOnlyMemory<byte> source,
|
||||||
|
out BorrowedMessageFragment fragment,
|
||||||
|
out int consumed)
|
||||||
|
{
|
||||||
|
if (!TryParseLayout(
|
||||||
|
source.Span,
|
||||||
|
out MessageFragmentHeader header,
|
||||||
|
out int payloadLength,
|
||||||
|
out consumed))
|
||||||
|
{
|
||||||
|
fragment = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment = new BorrowedMessageFragment(
|
||||||
|
header,
|
||||||
|
source.Slice(
|
||||||
|
MessageFragmentHeader.Size,
|
||||||
|
payloadLength));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryParseLayout(
|
||||||
|
ReadOnlySpan<byte> source,
|
||||||
|
out MessageFragmentHeader header,
|
||||||
|
out int payloadLength,
|
||||||
|
out int consumed)
|
||||||
|
{
|
||||||
|
header = default;
|
||||||
|
payloadLength = 0;
|
||||||
|
consumed = 0;
|
||||||
|
if (source.Length < MessageFragmentHeader.Size)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
header = MessageFragmentHeader.Unpack(source);
|
||||||
|
|
||||||
// TotalSize is the fragment's own size including its header. Anything
|
// TotalSize is the fragment's own size including its header. Anything
|
||||||
// smaller than the header or larger than the max fragment size is
|
// smaller than the header or larger than the max fragment size is
|
||||||
// wire corruption and we refuse to parse.
|
// wire corruption and we refuse to parse.
|
||||||
if (header.TotalSize < MessageFragmentHeader.Size
|
if (header.TotalSize < MessageFragmentHeader.Size
|
||||||
|| header.TotalSize > MessageFragmentHeader.MaxFragmentSize)
|
|| header.TotalSize > MessageFragmentHeader.MaxFragmentSize
|
||||||
|
|| header.Count == 0
|
||||||
|
|| header.Index >= header.Count)
|
||||||
{
|
{
|
||||||
return (null, 0);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int payloadLength = header.TotalSize - MessageFragmentHeader.Size;
|
payloadLength =
|
||||||
|
header.TotalSize - MessageFragmentHeader.Size;
|
||||||
if (source.Length < header.TotalSize)
|
if (source.Length < header.TotalSize)
|
||||||
return (null, 0);
|
return false;
|
||||||
|
|
||||||
var payload = source.Slice(MessageFragmentHeader.Size, payloadLength).ToArray();
|
consumed = header.TotalSize;
|
||||||
return (new MessageFragment(header, payload), header.TotalSize);
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,373 @@ public static class PacketCodec
|
||||||
return new PacketDecodeResult(packet, DecodeError.None);
|
return new PacketDecodeResult(packet, DecodeError.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode and verify one packet without materializing packet, optional-
|
||||||
|
/// header, fragment-list, body, or fragment-payload objects. Returned
|
||||||
|
/// memory borrows <paramref name="datagram"/> and must not outlive it.
|
||||||
|
/// </summary>
|
||||||
|
internal static BorrowedPacketDecodeResult TryDecodeBorrowed(
|
||||||
|
ReadOnlyMemory<byte> datagram,
|
||||||
|
IsaacRandom? inboundIsaac)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<byte> wire = datagram.Span;
|
||||||
|
if (wire.Length < PacketHeader.Size)
|
||||||
|
{
|
||||||
|
return new BorrowedPacketDecodeResult(
|
||||||
|
default,
|
||||||
|
DecodeError.TooShort);
|
||||||
|
}
|
||||||
|
|
||||||
|
PacketHeader header = PacketHeader.Unpack(wire);
|
||||||
|
int bodyLength = header.DataSize;
|
||||||
|
if (wire.Length - PacketHeader.Size < bodyLength)
|
||||||
|
{
|
||||||
|
return new BorrowedPacketDecodeResult(
|
||||||
|
default,
|
||||||
|
DecodeError.HeaderSizeExceedsBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlyMemory<byte> body = datagram.Slice(
|
||||||
|
PacketHeader.Size,
|
||||||
|
bodyLength);
|
||||||
|
if (!TryParseBorrowedOptional(
|
||||||
|
body,
|
||||||
|
header.Flags,
|
||||||
|
out BorrowedOptionalHeader optional,
|
||||||
|
out int optionalConsumed))
|
||||||
|
{
|
||||||
|
return new BorrowedPacketDecodeResult(
|
||||||
|
default,
|
||||||
|
DecodeError.InvalidOptionalHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlyMemory<byte> fragmentBytes =
|
||||||
|
ReadOnlyMemory<byte>.Empty;
|
||||||
|
int fragmentCount = 0;
|
||||||
|
uint fragmentHash = 0;
|
||||||
|
if (header.HasFlag(PacketHeaderFlags.BlobFragments))
|
||||||
|
{
|
||||||
|
fragmentBytes = body.Slice(optionalConsumed);
|
||||||
|
ReadOnlySpan<byte> remaining = fragmentBytes.Span;
|
||||||
|
while (!remaining.IsEmpty)
|
||||||
|
{
|
||||||
|
if (!MessageFragment.TryParseLayout(
|
||||||
|
remaining,
|
||||||
|
out _,
|
||||||
|
out int payloadLength,
|
||||||
|
out int consumed))
|
||||||
|
{
|
||||||
|
return new BorrowedPacketDecodeResult(
|
||||||
|
default,
|
||||||
|
DecodeError.InvalidFragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
fragmentHash +=
|
||||||
|
Hash32.Calculate(
|
||||||
|
remaining.Slice(
|
||||||
|
0,
|
||||||
|
MessageFragmentHeader.Size))
|
||||||
|
+ Hash32.Calculate(
|
||||||
|
remaining.Slice(
|
||||||
|
MessageFragmentHeader.Size,
|
||||||
|
payloadLength));
|
||||||
|
fragmentCount++;
|
||||||
|
remaining = remaining.Slice(consumed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint headerHash = header.CalculateHeaderHash32();
|
||||||
|
uint optionalHash = Hash32.Calculate(
|
||||||
|
optional.RawBytes.Span);
|
||||||
|
uint payloadHash = optionalHash + fragmentHash;
|
||||||
|
if (header.HasFlag(
|
||||||
|
PacketHeaderFlags.EncryptedChecksum))
|
||||||
|
{
|
||||||
|
if (inboundIsaac is null)
|
||||||
|
{
|
||||||
|
return new BorrowedPacketDecodeResult(
|
||||||
|
default,
|
||||||
|
DecodeError.ChecksumMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint expectedKey =
|
||||||
|
(header.Checksum - headerHash) ^ payloadHash;
|
||||||
|
uint isaacKey = inboundIsaac.Next();
|
||||||
|
if (expectedKey != isaacKey)
|
||||||
|
{
|
||||||
|
return new BorrowedPacketDecodeResult(
|
||||||
|
default,
|
||||||
|
DecodeError.ChecksumMismatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (header.Checksum != headerHash + payloadHash)
|
||||||
|
{
|
||||||
|
return new BorrowedPacketDecodeResult(
|
||||||
|
default,
|
||||||
|
DecodeError.ChecksumMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BorrowedPacketDecodeResult(
|
||||||
|
new BorrowedPacket(
|
||||||
|
header,
|
||||||
|
optional,
|
||||||
|
body,
|
||||||
|
fragmentBytes,
|
||||||
|
fragmentCount),
|
||||||
|
DecodeError.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseBorrowedOptional(
|
||||||
|
ReadOnlyMemory<byte> bodyMemory,
|
||||||
|
PacketHeaderFlags flags,
|
||||||
|
out BorrowedOptionalHeader optional,
|
||||||
|
out int consumed)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<byte> body = bodyMemory.Span;
|
||||||
|
int position = 0;
|
||||||
|
uint ackSequence = 0;
|
||||||
|
double timeSync = 0;
|
||||||
|
float echoRequestClientTime = 0;
|
||||||
|
uint flowBytes = 0;
|
||||||
|
ushort flowInterval = 0;
|
||||||
|
double connectRequestServerTime = 0;
|
||||||
|
ulong connectRequestCookie = 0;
|
||||||
|
uint connectRequestClientId = 0;
|
||||||
|
uint connectRequestServerSeed = 0;
|
||||||
|
uint connectRequestClientSeed = 0;
|
||||||
|
int retransmitOffset = 0;
|
||||||
|
int retransmitCount = 0;
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.ServerSwitch)
|
||||||
|
&& !Take(body, ref position, 8))
|
||||||
|
{
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.RequestRetransmit))
|
||||||
|
{
|
||||||
|
if (!Take(body, ref position, 4))
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
uint count = System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadUInt32LittleEndian(
|
||||||
|
body.Slice(position - 4));
|
||||||
|
if (count > 1024
|
||||||
|
|| body.Length - position < (int)count * 4)
|
||||||
|
{
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
retransmitOffset = position;
|
||||||
|
retransmitCount = checked((int)count);
|
||||||
|
position += retransmitCount * 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.RejectRetransmit))
|
||||||
|
{
|
||||||
|
if (!Take(body, ref position, 4))
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
uint count = System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadUInt32LittleEndian(
|
||||||
|
body.Slice(position - 4));
|
||||||
|
if (count > 1024
|
||||||
|
|| body.Length - position < (int)count * 4)
|
||||||
|
{
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
}
|
||||||
|
position += checked((int)count * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.AckSequence))
|
||||||
|
{
|
||||||
|
if (!Take(body, ref position, 4))
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
ackSequence =
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadUInt32LittleEndian(
|
||||||
|
body.Slice(position - 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.LoginRequest))
|
||||||
|
{
|
||||||
|
position = body.Length;
|
||||||
|
optional = BuildOptional(
|
||||||
|
bodyMemory,
|
||||||
|
position,
|
||||||
|
ackSequence,
|
||||||
|
timeSync,
|
||||||
|
echoRequestClientTime,
|
||||||
|
flowBytes,
|
||||||
|
flowInterval,
|
||||||
|
connectRequestServerTime,
|
||||||
|
connectRequestCookie,
|
||||||
|
connectRequestClientId,
|
||||||
|
connectRequestServerSeed,
|
||||||
|
connectRequestClientSeed,
|
||||||
|
retransmitOffset,
|
||||||
|
retransmitCount);
|
||||||
|
consumed = position;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.WorldLoginRequest)
|
||||||
|
&& !Take(body, ref position, 8))
|
||||||
|
{
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.ConnectRequest))
|
||||||
|
{
|
||||||
|
if (body.Length - position < 32)
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
|
||||||
|
connectRequestServerTime =
|
||||||
|
BitConverter.Int64BitsToDouble(
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadInt64LittleEndian(
|
||||||
|
body.Slice(position)));
|
||||||
|
connectRequestCookie =
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadUInt64LittleEndian(
|
||||||
|
body.Slice(position + 8));
|
||||||
|
connectRequestClientId =
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadUInt32LittleEndian(
|
||||||
|
body.Slice(position + 16));
|
||||||
|
connectRequestServerSeed =
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadUInt32LittleEndian(
|
||||||
|
body.Slice(position + 20));
|
||||||
|
connectRequestClientSeed =
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadUInt32LittleEndian(
|
||||||
|
body.Slice(position + 24));
|
||||||
|
position += 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.ConnectResponse)
|
||||||
|
&& !Take(body, ref position, 8))
|
||||||
|
{
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.CICMDCommand)
|
||||||
|
&& !Take(body, ref position, 8))
|
||||||
|
{
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.TimeSync))
|
||||||
|
{
|
||||||
|
if (!Take(body, ref position, 8))
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
timeSync = BitConverter.Int64BitsToDouble(
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadInt64LittleEndian(
|
||||||
|
body.Slice(position - 8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.EchoRequest))
|
||||||
|
{
|
||||||
|
if (!Take(body, ref position, 4))
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
echoRequestClientTime =
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadSingleLittleEndian(
|
||||||
|
body.Slice(position - 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasFlag(flags, PacketHeaderFlags.Flow))
|
||||||
|
{
|
||||||
|
if (!Take(body, ref position, 6))
|
||||||
|
return Invalid(out optional, out consumed);
|
||||||
|
flowBytes =
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadUInt32LittleEndian(
|
||||||
|
body.Slice(position - 6));
|
||||||
|
flowInterval =
|
||||||
|
System.Buffers.Binary.BinaryPrimitives
|
||||||
|
.ReadUInt16LittleEndian(
|
||||||
|
body.Slice(position - 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
optional = BuildOptional(
|
||||||
|
bodyMemory,
|
||||||
|
position,
|
||||||
|
ackSequence,
|
||||||
|
timeSync,
|
||||||
|
echoRequestClientTime,
|
||||||
|
flowBytes,
|
||||||
|
flowInterval,
|
||||||
|
connectRequestServerTime,
|
||||||
|
connectRequestCookie,
|
||||||
|
connectRequestClientId,
|
||||||
|
connectRequestServerSeed,
|
||||||
|
connectRequestClientSeed,
|
||||||
|
retransmitOffset,
|
||||||
|
retransmitCount);
|
||||||
|
consumed = position;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BorrowedOptionalHeader BuildOptional(
|
||||||
|
ReadOnlyMemory<byte> body,
|
||||||
|
int consumed,
|
||||||
|
uint ackSequence,
|
||||||
|
double timeSync,
|
||||||
|
float echoRequestClientTime,
|
||||||
|
uint flowBytes,
|
||||||
|
ushort flowInterval,
|
||||||
|
double connectRequestServerTime,
|
||||||
|
ulong connectRequestCookie,
|
||||||
|
uint connectRequestClientId,
|
||||||
|
uint connectRequestServerSeed,
|
||||||
|
uint connectRequestClientSeed,
|
||||||
|
int retransmitOffset,
|
||||||
|
int retransmitCount) =>
|
||||||
|
new(
|
||||||
|
ackSequence,
|
||||||
|
timeSync,
|
||||||
|
echoRequestClientTime,
|
||||||
|
flowBytes,
|
||||||
|
flowInterval,
|
||||||
|
connectRequestServerTime,
|
||||||
|
connectRequestCookie,
|
||||||
|
connectRequestClientId,
|
||||||
|
connectRequestServerSeed,
|
||||||
|
connectRequestClientSeed,
|
||||||
|
body.Slice(0, consumed),
|
||||||
|
retransmitCount == 0
|
||||||
|
? ReadOnlyMemory<byte>.Empty
|
||||||
|
: body.Slice(
|
||||||
|
retransmitOffset,
|
||||||
|
retransmitCount * 4),
|
||||||
|
retransmitCount);
|
||||||
|
|
||||||
|
private static bool Invalid(
|
||||||
|
out BorrowedOptionalHeader optional,
|
||||||
|
out int consumed)
|
||||||
|
{
|
||||||
|
optional = default;
|
||||||
|
consumed = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasFlag(
|
||||||
|
PacketHeaderFlags all,
|
||||||
|
PacketHeaderFlags bit) =>
|
||||||
|
(all & bit) != 0;
|
||||||
|
|
||||||
|
private static bool Take(
|
||||||
|
ReadOnlySpan<byte> body,
|
||||||
|
ref int position,
|
||||||
|
int count)
|
||||||
|
{
|
||||||
|
if (body.Length - position < count)
|
||||||
|
return false;
|
||||||
|
position += count;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Assemble a datagram from a header and an optional-section body.
|
/// Assemble a datagram from a header and an optional-section body.
|
||||||
/// Computes the checksum (both unencrypted and ISAAC-encrypted forms)
|
/// Computes the checksum (both unencrypted and ISAAC-encrypted forms)
|
||||||
|
|
|
||||||
|
|
@ -801,8 +801,11 @@ public sealed class WorldSession : IDisposable
|
||||||
_net.Send(PacketCodec.Encode(loginHeader, loginPayload, null));
|
_net.Send(PacketCodec.Encode(loginHeader, loginPayload, null));
|
||||||
|
|
||||||
// Step 2: wait for ConnectRequest
|
// Step 2: wait for ConnectRequest
|
||||||
Packet? cr = null;
|
bool connectRequestReceived = false;
|
||||||
while (DateTime.UtcNow < deadline && cr is null)
|
BorrowedOptionalHeader connectRequest = default;
|
||||||
|
ushort connectRequestIteration = 0;
|
||||||
|
while (DateTime.UtcNow < deadline
|
||||||
|
&& !connectRequestReceived)
|
||||||
{
|
{
|
||||||
PooledInboundDatagram? received =
|
PooledInboundDatagram? received =
|
||||||
ReceiveBlocking(deadline - DateTime.UtcNow);
|
ReceiveBlocking(deadline - DateTime.UtcNow);
|
||||||
|
|
@ -812,14 +815,23 @@ public sealed class WorldSession : IDisposable
|
||||||
PooledInboundDatagram datagram = received.Value;
|
PooledInboundDatagram datagram = received.Value;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var dec = PacketCodec.TryDecode(
|
BorrowedPacketDecodeResult dec =
|
||||||
datagram.Memory.Span,
|
PacketCodec.TryDecodeBorrowed(
|
||||||
|
datagram.Memory,
|
||||||
inboundIsaac: null);
|
inboundIsaac: null);
|
||||||
if (dec.IsOk
|
if (dec.IsOk
|
||||||
&& dec.Packet!.Header.HasFlag(
|
&& dec.Packet.Header.HasFlag(
|
||||||
PacketHeaderFlags.ConnectRequest))
|
PacketHeaderFlags.ConnectRequest))
|
||||||
{
|
{
|
||||||
cr = dec.Packet;
|
connectRequest = dec.Packet.Optional with
|
||||||
|
{
|
||||||
|
RawBytes = ReadOnlyMemory<byte>.Empty,
|
||||||
|
RetransmitRequestBytes =
|
||||||
|
ReadOnlyMemory<byte>.Empty,
|
||||||
|
};
|
||||||
|
connectRequestIteration =
|
||||||
|
dec.Packet.Header.Iteration;
|
||||||
|
connectRequestReceived = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -827,10 +839,15 @@ public sealed class WorldSession : IDisposable
|
||||||
ReturnInboundDatagram(datagram);
|
ReturnInboundDatagram(datagram);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cr is null) { Transition(State.Failed); throw new TimeoutException("ConnectRequest not received"); }
|
if (!connectRequestReceived)
|
||||||
|
{
|
||||||
|
Transition(State.Failed);
|
||||||
|
throw new TimeoutException(
|
||||||
|
"ConnectRequest not received");
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
|
// Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
|
||||||
var opt = cr.Optional;
|
BorrowedOptionalHeader opt = connectRequest;
|
||||||
|
|
||||||
// Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives
|
// Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives
|
||||||
// in the ConnectRequest optional section. Publish it to
|
// in the ConnectRequest optional section. Publish it to
|
||||||
|
|
@ -846,7 +863,7 @@ public sealed class WorldSession : IDisposable
|
||||||
// SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData
|
// SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData
|
||||||
// generation into connection-level control packets, including the
|
// generation into connection-level control packets, including the
|
||||||
// final disconnect. ACE currently emits iteration 1.
|
// final disconnect. ACE currently emits iteration 1.
|
||||||
_sessionIteration = cr.Header.Iteration;
|
_sessionIteration = connectRequestIteration;
|
||||||
_transportNegotiated = true;
|
_transportNegotiated = true;
|
||||||
_clientPacketSequence = 2;
|
_clientPacketSequence = 2;
|
||||||
|
|
||||||
|
|
@ -979,7 +996,7 @@ public sealed class WorldSession : IDisposable
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ProcessDatagram(datagram.Memory.Span);
|
ProcessDatagram(datagram.Memory);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -1142,7 +1159,7 @@ public sealed class WorldSession : IDisposable
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ProcessDatagram(
|
ProcessDatagram(
|
||||||
datagram.Memory.Span,
|
datagram.Memory,
|
||||||
opcodesThisCall);
|
opcodesThisCall);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -1153,11 +1170,14 @@ public sealed class WorldSession : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ProcessDatagram(
|
private void ProcessDatagram(
|
||||||
ReadOnlySpan<byte> bytes,
|
ReadOnlyMemory<byte> bytes,
|
||||||
List<uint>? opcodesOut = null,
|
List<uint>? opcodesOut = null,
|
||||||
bool dispatchWorldEvents = true)
|
bool dispatchWorldEvents = true)
|
||||||
{
|
{
|
||||||
var dec = PacketCodec.TryDecode(bytes, _inboundIsaac);
|
BorrowedPacketDecodeResult dec =
|
||||||
|
PacketCodec.TryDecodeBorrowed(
|
||||||
|
bytes,
|
||||||
|
_inboundIsaac);
|
||||||
if (!dec.IsOk) return;
|
if (!dec.IsOk) return;
|
||||||
|
|
||||||
// Retail LinkStatusHolder::OnHeartbeat @ 0x004113D0 updates its
|
// Retail LinkStatusHolder::OnHeartbeat @ 0x004113D0 updates its
|
||||||
|
|
@ -1172,7 +1192,7 @@ public sealed class WorldSession : IDisposable
|
||||||
// with "Network Timeout" because it sees no acks coming back —
|
// with "Network Timeout" because it sees no acks coming back —
|
||||||
// which surfaces in other clients' views as the player rendering
|
// which surfaces in other clients' views as the player rendering
|
||||||
// as a stationary purple haze (loading state).
|
// as a stationary purple haze (loading state).
|
||||||
var serverHeader = dec.Packet!.Header;
|
PacketHeader serverHeader = dec.Packet.Header;
|
||||||
if (serverHeader.Sequence > 0
|
if (serverHeader.Sequence > 0
|
||||||
&& (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0)
|
&& (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0)
|
||||||
{
|
{
|
||||||
|
|
@ -1184,7 +1204,7 @@ public sealed class WorldSession : IDisposable
|
||||||
// periodically — no explicit opcode, just the header flag.
|
// periodically — no explicit opcode, just the header flag.
|
||||||
if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0)
|
if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0)
|
||||||
{
|
{
|
||||||
double t = dec.Packet!.Optional.TimeSync;
|
double t = dec.Packet.Optional.TimeSync;
|
||||||
if (t > 0)
|
if (t > 0)
|
||||||
{
|
{
|
||||||
LastServerTimeTicks = t;
|
LastServerTimeTicks = t;
|
||||||
|
|
@ -1192,11 +1212,21 @@ public sealed class WorldSession : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var frag in dec.Packet!.Fragments)
|
foreach (BorrowedMessageFragment frag
|
||||||
|
in dec.Packet.Fragments)
|
||||||
{
|
{
|
||||||
var body = _assembler.Ingest(frag, out _);
|
if (!_assembler.TryIngest(
|
||||||
if (body is null || body.Length < 4) continue;
|
frag,
|
||||||
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
out ReadOnlyMemory<byte> bodyMemory,
|
||||||
|
out _)
|
||||||
|
|| bodyMemory.Length < 4)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> body = bodyMemory.Span;
|
||||||
|
uint op = BinaryPrimitives.ReadUInt32LittleEndian(
|
||||||
|
body);
|
||||||
opcodesOut?.Add(op);
|
opcodesOut?.Add(op);
|
||||||
|
|
||||||
// Retail waits for the server's opcode-only CharacterLogOff echo
|
// Retail waits for the server's opcode-only CharacterLogOff echo
|
||||||
|
|
@ -1335,7 +1365,9 @@ public sealed class WorldSession : IDisposable
|
||||||
&& !_setStateHexDumped)
|
&& !_setStateHexDumped)
|
||||||
{
|
{
|
||||||
_setStateHexDumped = true;
|
_setStateHexDumped = true;
|
||||||
var hex = string.Join(" ", body.Take(Math.Min(body.Length, 32))
|
var hex = string.Join(" ", body
|
||||||
|
.Slice(0, Math.Min(body.Length, 32))
|
||||||
|
.ToArray()
|
||||||
.Select(b => b.ToString("X2")));
|
.Select(b => b.ToString("X2")));
|
||||||
Console.WriteLine($"[setstate-hex] body.len={body.Length} first-{Math.Min(body.Length, 32)}-bytes: {hex}");
|
Console.WriteLine($"[setstate-hex] body.len={body.Length} first-{Math.Min(body.Length, 32)}-bytes: {hex}");
|
||||||
}
|
}
|
||||||
|
|
@ -1460,7 +1492,8 @@ public sealed class WorldSession : IDisposable
|
||||||
// header (guid + sequence + eventType) and dispatch to the
|
// header (guid + sequence + eventType) and dispatch to the
|
||||||
// registered handler for that sub-opcode. Unregistered
|
// registered handler for that sub-opcode. Unregistered
|
||||||
// types get counted for diagnostic overlays.
|
// types get counted for diagnostic overlays.
|
||||||
var env = GameEventEnvelope.TryParse(body);
|
var env = GameEventEnvelope.TryParseBorrowed(
|
||||||
|
bodyMemory);
|
||||||
if (env is not null) GameEvents.Dispatch(env.Value);
|
if (env is not null) GameEvents.Dispatch(env.Value);
|
||||||
}
|
}
|
||||||
else if (op == 0xEA60u) // AdminEnvirons — server pushes a fog preset or sound cue
|
else if (op == 0xEA60u) // AdminEnvirons — server pushes a fog preset or sound cue
|
||||||
|
|
@ -1472,7 +1505,7 @@ public sealed class WorldSession : IDisposable
|
||||||
if (body.Length >= 8)
|
if (body.Length >= 8)
|
||||||
{
|
{
|
||||||
uint envType = System.Buffers.Binary.BinaryPrimitives
|
uint envType = System.Buffers.Binary.BinaryPrimitives
|
||||||
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
|
.ReadUInt32LittleEndian(body.Slice(4, 4));
|
||||||
EnvironChanged?.Invoke(envType);
|
EnvironChanged?.Invoke(envType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1502,7 +1535,7 @@ public sealed class WorldSession : IDisposable
|
||||||
if (body.Length >= 6)
|
if (body.Length >= 6)
|
||||||
{
|
{
|
||||||
ushort sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
|
ushort sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
|
||||||
body.AsSpan(4, 2));
|
body.Slice(4, 2));
|
||||||
TeleportStarted?.Invoke(sequence);
|
TeleportStarted?.Invoke(sequence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2344,7 +2377,7 @@ public sealed class WorldSession : IDisposable
|
||||||
datagram =>
|
datagram =>
|
||||||
{
|
{
|
||||||
ProcessDatagram(
|
ProcessDatagram(
|
||||||
datagram.Memory.Span,
|
datagram.Memory,
|
||||||
dispatchWorldEvents: false);
|
dispatchWorldEvents: false);
|
||||||
return Volatile.Read(ref _characterLogOffConfirmed) != 0;
|
return Volatile.Read(ref _characterLogOffConfirmed) != 0;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
441
tests/AcDream.Core.Net.Tests/Packets/BorrowedPacketCodecTests.cs
Normal file
441
tests/AcDream.Core.Net.Tests/Packets/BorrowedPacketCodecTests.cs
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using AcDream.Core.Net.Cryptography;
|
||||||
|
using AcDream.Core.Net.Packets;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Packets;
|
||||||
|
|
||||||
|
public class BorrowedPacketCodecTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryDecodeBorrowed_AllOptionalFieldsAndFragments_MatchesOwnedDecoder()
|
||||||
|
{
|
||||||
|
const PacketHeaderFlags flags =
|
||||||
|
PacketHeaderFlags.ServerSwitch
|
||||||
|
| PacketHeaderFlags.RequestRetransmit
|
||||||
|
| PacketHeaderFlags.RejectRetransmit
|
||||||
|
| PacketHeaderFlags.AckSequence
|
||||||
|
| PacketHeaderFlags.WorldLoginRequest
|
||||||
|
| PacketHeaderFlags.ConnectRequest
|
||||||
|
| PacketHeaderFlags.ConnectResponse
|
||||||
|
| PacketHeaderFlags.CICMDCommand
|
||||||
|
| PacketHeaderFlags.TimeSync
|
||||||
|
| PacketHeaderFlags.EchoRequest
|
||||||
|
| PacketHeaderFlags.Flow
|
||||||
|
| PacketHeaderFlags.BlobFragments;
|
||||||
|
|
||||||
|
var optional = new byte[8 + 12 + 8 + 4 + 8 + 32 + 8 + 8 + 8 + 4 + 6];
|
||||||
|
int position = 0;
|
||||||
|
Fill(optional, ref position, 8, 0x11);
|
||||||
|
|
||||||
|
WriteUInt32(optional, ref position, 2);
|
||||||
|
WriteUInt32(optional, ref position, 0x10111213);
|
||||||
|
WriteUInt32(optional, ref position, 0x20212223);
|
||||||
|
|
||||||
|
WriteUInt32(optional, ref position, 1);
|
||||||
|
WriteUInt32(optional, ref position, 0x30313233);
|
||||||
|
|
||||||
|
WriteUInt32(optional, ref position, 0x40414243);
|
||||||
|
Fill(optional, ref position, 8, 0x44);
|
||||||
|
|
||||||
|
double serverTime = 12345.6789;
|
||||||
|
ulong cookie = 0xFEEDFACECAFEBABE;
|
||||||
|
uint clientId = 0x50515253;
|
||||||
|
uint serverSeed = 0x60616263;
|
||||||
|
uint clientSeed = 0x70717273;
|
||||||
|
WriteDouble(optional, ref position, serverTime);
|
||||||
|
WriteUInt64(optional, ref position, cookie);
|
||||||
|
WriteUInt32(optional, ref position, clientId);
|
||||||
|
WriteUInt32(optional, ref position, serverSeed);
|
||||||
|
WriteUInt32(optional, ref position, clientSeed);
|
||||||
|
WriteUInt32(optional, ref position, 0);
|
||||||
|
|
||||||
|
Fill(optional, ref position, 8, 0x81);
|
||||||
|
Fill(optional, ref position, 8, 0x91);
|
||||||
|
|
||||||
|
double timeSync = 9876.54321;
|
||||||
|
float echoTime = 42.25f;
|
||||||
|
WriteDouble(optional, ref position, timeSync);
|
||||||
|
WriteSingle(optional, ref position, echoTime);
|
||||||
|
WriteUInt32(optional, ref position, 0xA0A1A2A3);
|
||||||
|
WriteUInt16(optional, ref position, 0xB0B1);
|
||||||
|
Assert.Equal(optional.Length, position);
|
||||||
|
|
||||||
|
byte[] first = BuildFragment(
|
||||||
|
sequence: 99,
|
||||||
|
count: 2,
|
||||||
|
index: 0,
|
||||||
|
queue: 7,
|
||||||
|
payload: [0xC1, 0xC2]);
|
||||||
|
byte[] second = BuildFragment(
|
||||||
|
sequence: 99,
|
||||||
|
count: 2,
|
||||||
|
index: 1,
|
||||||
|
queue: 7,
|
||||||
|
payload: [0xD1, 0xD2, 0xD3]);
|
||||||
|
|
||||||
|
byte[] body = [.. optional, .. first, .. second];
|
||||||
|
byte[] datagram = Encode(flags, body);
|
||||||
|
|
||||||
|
PacketCodec.PacketDecodeResult owned =
|
||||||
|
PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||||
|
BorrowedPacketDecodeResult borrowed =
|
||||||
|
PacketCodec.TryDecodeBorrowed(datagram, inboundIsaac: null);
|
||||||
|
|
||||||
|
AssertEquivalent(owned, borrowed);
|
||||||
|
Assert.Equal(serverTime, borrowed.Packet.Optional.ConnectRequestServerTime);
|
||||||
|
Assert.Equal(cookie, borrowed.Packet.Optional.ConnectRequestCookie);
|
||||||
|
Assert.Equal(clientId, borrowed.Packet.Optional.ConnectRequestClientId);
|
||||||
|
Assert.Equal(serverSeed, borrowed.Packet.Optional.ConnectRequestServerSeed);
|
||||||
|
Assert.Equal(clientSeed, borrowed.Packet.Optional.ConnectRequestClientSeed);
|
||||||
|
Assert.Equal(timeSync, borrowed.Packet.Optional.TimeSync);
|
||||||
|
Assert.Equal(echoTime, borrowed.Packet.Optional.EchoRequestClientTime);
|
||||||
|
Assert.Equal(0xA0A1A2A3u, borrowed.Packet.Optional.FlowBytes);
|
||||||
|
Assert.Equal(0xB0B1, borrowed.Packet.Optional.FlowInterval);
|
||||||
|
Assert.Equal(
|
||||||
|
new uint[] { 0x10111213, 0x20212223 },
|
||||||
|
ReadRetransmits(borrowed.Packet.Optional));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryDecodeBorrowed_LoginPayload_MatchesOwnedDecoder()
|
||||||
|
{
|
||||||
|
byte[] payload = LoginRequest.Build("borrowed", "packet", 47);
|
||||||
|
byte[] datagram = Encode(PacketHeaderFlags.LoginRequest, payload);
|
||||||
|
|
||||||
|
PacketCodec.PacketDecodeResult owned =
|
||||||
|
PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||||
|
BorrowedPacketDecodeResult borrowed =
|
||||||
|
PacketCodec.TryDecodeBorrowed(datagram, inboundIsaac: null);
|
||||||
|
|
||||||
|
AssertEquivalent(owned, borrowed);
|
||||||
|
Assert.Equal(payload, borrowed.Packet.Optional.RawBytes.ToArray());
|
||||||
|
Assert.Equal(payload, borrowed.Packet.Body.ToArray());
|
||||||
|
Assert.Equal(0, borrowed.Packet.FragmentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryDecodeBorrowed_EncryptedChecksum_MatchesOwnedDecoder()
|
||||||
|
{
|
||||||
|
byte[] body = new byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body, 0x12345678);
|
||||||
|
byte[] seed = [0x44, 0x33, 0x22, 0x11];
|
||||||
|
byte[] datagram = Encode(
|
||||||
|
PacketHeaderFlags.AckSequence
|
||||||
|
| PacketHeaderFlags.EncryptedChecksum,
|
||||||
|
body,
|
||||||
|
new IsaacRandom(seed));
|
||||||
|
|
||||||
|
PacketCodec.PacketDecodeResult owned =
|
||||||
|
PacketCodec.TryDecode(
|
||||||
|
datagram,
|
||||||
|
new IsaacRandom(seed));
|
||||||
|
BorrowedPacketDecodeResult borrowed =
|
||||||
|
PacketCodec.TryDecodeBorrowed(
|
||||||
|
datagram,
|
||||||
|
new IsaacRandom(seed));
|
||||||
|
|
||||||
|
AssertEquivalent(owned, borrowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryDecodeBorrowed_MalformedPackets_MatchOwnedDecoderErrors()
|
||||||
|
{
|
||||||
|
byte[] shortHeader = new byte[PacketHeader.Size - 1];
|
||||||
|
AssertSameError(shortHeader);
|
||||||
|
|
||||||
|
var oversized = new byte[PacketHeader.Size + 2];
|
||||||
|
new PacketHeader { DataSize = 3 }.Pack(oversized);
|
||||||
|
AssertSameError(oversized);
|
||||||
|
|
||||||
|
byte[] shortOptional = EncodeUnchecked(
|
||||||
|
PacketHeaderFlags.TimeSync,
|
||||||
|
[0x01, 0x02, 0x03, 0x04]);
|
||||||
|
AssertSameError(shortOptional);
|
||||||
|
|
||||||
|
byte[] zeroCount = BuildFragment(
|
||||||
|
sequence: 1,
|
||||||
|
count: 0,
|
||||||
|
index: 0,
|
||||||
|
queue: 0,
|
||||||
|
payload: [0x01]);
|
||||||
|
AssertSameError(EncodeUnchecked(
|
||||||
|
PacketHeaderFlags.BlobFragments,
|
||||||
|
zeroCount));
|
||||||
|
|
||||||
|
byte[] invalidIndex = BuildFragment(
|
||||||
|
sequence: 1,
|
||||||
|
count: 1,
|
||||||
|
index: 1,
|
||||||
|
queue: 0,
|
||||||
|
payload: [0x01]);
|
||||||
|
AssertSameError(EncodeUnchecked(
|
||||||
|
PacketHeaderFlags.BlobFragments,
|
||||||
|
invalidIndex));
|
||||||
|
|
||||||
|
byte[] wrongChecksum = Encode(
|
||||||
|
PacketHeaderFlags.AckSequence,
|
||||||
|
[1, 2, 3, 4]);
|
||||||
|
wrongChecksum[8] ^= 0x80;
|
||||||
|
AssertSameError(wrongChecksum);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryDecodeBorrowed_WarmSingleFragmentPath_AllocatesNothing()
|
||||||
|
{
|
||||||
|
byte[] fragment = BuildFragment(
|
||||||
|
sequence: 77,
|
||||||
|
count: 1,
|
||||||
|
index: 0,
|
||||||
|
queue: 5,
|
||||||
|
payload: [0xAA, 0xBB, 0xCC, 0xDD]);
|
||||||
|
byte[] datagram = Encode(
|
||||||
|
PacketHeaderFlags.BlobFragments,
|
||||||
|
fragment);
|
||||||
|
|
||||||
|
int checksum = DecodeAndRead(datagram);
|
||||||
|
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
for (int iteration = 0; iteration < 1_000; iteration++)
|
||||||
|
checksum += DecodeAndRead(datagram);
|
||||||
|
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||||
|
|
||||||
|
Assert.NotEqual(0, checksum);
|
||||||
|
Assert.Equal(0, allocated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int DecodeAndRead(ReadOnlyMemory<byte> datagram)
|
||||||
|
{
|
||||||
|
BorrowedPacketDecodeResult decoded =
|
||||||
|
PacketCodec.TryDecodeBorrowed(
|
||||||
|
datagram,
|
||||||
|
inboundIsaac: null);
|
||||||
|
int checksum = decoded.Packet.FragmentCount;
|
||||||
|
foreach (BorrowedMessageFragment fragment
|
||||||
|
in decoded.Packet.Fragments)
|
||||||
|
{
|
||||||
|
checksum += fragment.Header.TotalSize;
|
||||||
|
checksum += fragment.Payload.Span[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksum;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertEquivalent(
|
||||||
|
PacketCodec.PacketDecodeResult owned,
|
||||||
|
BorrowedPacketDecodeResult borrowed)
|
||||||
|
{
|
||||||
|
Assert.Equal(owned.Error, borrowed.Error);
|
||||||
|
if (!owned.IsOk)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Packet packet = Assert.IsType<Packet>(owned.Packet);
|
||||||
|
Assert.Equal(packet.Header, borrowed.Packet.Header);
|
||||||
|
Assert.Equal(packet.BodyBytes, borrowed.Packet.Body.ToArray());
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.RawBytes,
|
||||||
|
borrowed.Packet.Optional.RawBytes.ToArray());
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.AckSequence,
|
||||||
|
borrowed.Packet.Optional.AckSequence);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.TimeSync,
|
||||||
|
borrowed.Packet.Optional.TimeSync);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.EchoRequestClientTime,
|
||||||
|
borrowed.Packet.Optional.EchoRequestClientTime);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.FlowBytes,
|
||||||
|
borrowed.Packet.Optional.FlowBytes);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.FlowInterval,
|
||||||
|
borrowed.Packet.Optional.FlowInterval);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.ConnectRequestServerTime,
|
||||||
|
borrowed.Packet.Optional.ConnectRequestServerTime);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.ConnectRequestCookie,
|
||||||
|
borrowed.Packet.Optional.ConnectRequestCookie);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.ConnectRequestClientId,
|
||||||
|
borrowed.Packet.Optional.ConnectRequestClientId);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.ConnectRequestServerSeed,
|
||||||
|
borrowed.Packet.Optional.ConnectRequestServerSeed);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.ConnectRequestClientSeed,
|
||||||
|
borrowed.Packet.Optional.ConnectRequestClientSeed);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Optional.RetransmitRequests,
|
||||||
|
ReadRetransmits(borrowed.Packet.Optional));
|
||||||
|
|
||||||
|
var fragments = new List<BorrowedMessageFragment>();
|
||||||
|
foreach (BorrowedMessageFragment fragment
|
||||||
|
in borrowed.Packet.Fragments)
|
||||||
|
{
|
||||||
|
fragments.Add(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(packet.Fragments.Count, borrowed.Packet.FragmentCount);
|
||||||
|
Assert.Equal(packet.Fragments.Count, fragments.Count);
|
||||||
|
for (int index = 0; index < fragments.Count; index++)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Fragments[index].Header,
|
||||||
|
fragments[index].Header);
|
||||||
|
Assert.Equal(
|
||||||
|
packet.Fragments[index].Payload,
|
||||||
|
fragments[index].Payload.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint[] ReadRetransmits(
|
||||||
|
BorrowedOptionalHeader optional)
|
||||||
|
{
|
||||||
|
var retransmits = new uint[optional.RetransmitRequestCount];
|
||||||
|
ReadOnlySpan<byte> bytes = optional.RetransmitRequestBytes.Span;
|
||||||
|
for (int index = 0; index < retransmits.Length; index++)
|
||||||
|
{
|
||||||
|
retransmits[index] =
|
||||||
|
BinaryPrimitives.ReadUInt32LittleEndian(
|
||||||
|
bytes.Slice(index * 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
return retransmits;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertSameError(byte[] datagram)
|
||||||
|
{
|
||||||
|
PacketCodec.PacketDecodeResult owned =
|
||||||
|
PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||||
|
BorrowedPacketDecodeResult borrowed =
|
||||||
|
PacketCodec.TryDecodeBorrowed(
|
||||||
|
datagram,
|
||||||
|
inboundIsaac: null);
|
||||||
|
Assert.Equal(owned.Error, borrowed.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Encode(
|
||||||
|
PacketHeaderFlags flags,
|
||||||
|
byte[] body,
|
||||||
|
IsaacRandom? isaac = null) =>
|
||||||
|
PacketCodec.Encode(
|
||||||
|
new PacketHeader
|
||||||
|
{
|
||||||
|
Sequence = 0x01020304,
|
||||||
|
Flags = flags,
|
||||||
|
Id = 0x0506,
|
||||||
|
Time = 0x0708,
|
||||||
|
Iteration = 0x090A,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
isaac);
|
||||||
|
|
||||||
|
private static byte[] EncodeUnchecked(
|
||||||
|
PacketHeaderFlags flags,
|
||||||
|
byte[] body)
|
||||||
|
{
|
||||||
|
var header = new PacketHeader
|
||||||
|
{
|
||||||
|
Sequence = 0x01020304,
|
||||||
|
Flags = flags,
|
||||||
|
Id = 0x0506,
|
||||||
|
Time = 0x0708,
|
||||||
|
Iteration = 0x090A,
|
||||||
|
DataSize = checked((ushort)body.Length),
|
||||||
|
};
|
||||||
|
header.Checksum =
|
||||||
|
header.CalculateHeaderHash32()
|
||||||
|
+ Hash32.Calculate(body);
|
||||||
|
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
||||||
|
header.Pack(datagram);
|
||||||
|
body.CopyTo(datagram, PacketHeader.Size);
|
||||||
|
return datagram;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BuildFragment(
|
||||||
|
uint sequence,
|
||||||
|
ushort count,
|
||||||
|
ushort index,
|
||||||
|
ushort queue,
|
||||||
|
byte[] payload)
|
||||||
|
{
|
||||||
|
var header = new MessageFragmentHeader
|
||||||
|
{
|
||||||
|
Sequence = sequence,
|
||||||
|
Id = 0x80000000,
|
||||||
|
Count = count,
|
||||||
|
TotalSize = checked((ushort)(
|
||||||
|
MessageFragmentHeader.Size + payload.Length)),
|
||||||
|
Index = index,
|
||||||
|
Queue = queue,
|
||||||
|
};
|
||||||
|
byte[] wire = new byte[header.TotalSize];
|
||||||
|
header.Pack(wire);
|
||||||
|
payload.CopyTo(wire, MessageFragmentHeader.Size);
|
||||||
|
return wire;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt16(
|
||||||
|
Span<byte> bytes,
|
||||||
|
ref int position,
|
||||||
|
ushort value)
|
||||||
|
{
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(
|
||||||
|
bytes.Slice(position),
|
||||||
|
value);
|
||||||
|
position += sizeof(ushort);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt32(
|
||||||
|
Span<byte> bytes,
|
||||||
|
ref int position,
|
||||||
|
uint value)
|
||||||
|
{
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||||
|
bytes.Slice(position),
|
||||||
|
value);
|
||||||
|
position += sizeof(uint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt64(
|
||||||
|
Span<byte> bytes,
|
||||||
|
ref int position,
|
||||||
|
ulong value)
|
||||||
|
{
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(
|
||||||
|
bytes.Slice(position),
|
||||||
|
value);
|
||||||
|
position += sizeof(ulong);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteSingle(
|
||||||
|
Span<byte> bytes,
|
||||||
|
ref int position,
|
||||||
|
float value)
|
||||||
|
{
|
||||||
|
BinaryPrimitives.WriteSingleLittleEndian(
|
||||||
|
bytes.Slice(position),
|
||||||
|
value);
|
||||||
|
position += sizeof(float);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteDouble(
|
||||||
|
Span<byte> bytes,
|
||||||
|
ref int position,
|
||||||
|
double value)
|
||||||
|
{
|
||||||
|
BinaryPrimitives.WriteInt64LittleEndian(
|
||||||
|
bytes.Slice(position),
|
||||||
|
BitConverter.DoubleToInt64Bits(value));
|
||||||
|
position += sizeof(double);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Fill(
|
||||||
|
Span<byte> bytes,
|
||||||
|
ref int position,
|
||||||
|
int count,
|
||||||
|
byte value)
|
||||||
|
{
|
||||||
|
bytes.Slice(position, count).Fill(value);
|
||||||
|
position += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -124,4 +124,81 @@ public class FragmentAssemblerTests
|
||||||
assembler.DropAll();
|
assembler.DropAll();
|
||||||
Assert.Equal(0, assembler.PartialCount);
|
Assert.Equal(0, assembler.PartialCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIngest_BorrowedSingleFragment_ReturnsOriginalMemory()
|
||||||
|
{
|
||||||
|
var assembler = new FragmentAssembler();
|
||||||
|
byte[] payload = [1, 2, 3];
|
||||||
|
var fragment = new BorrowedMessageFragment(
|
||||||
|
MakeFrag(10, 1, 0, payload, 11).Header,
|
||||||
|
payload);
|
||||||
|
|
||||||
|
bool complete = assembler.TryIngest(
|
||||||
|
fragment,
|
||||||
|
out ReadOnlyMemory<byte> message,
|
||||||
|
out ushort queue);
|
||||||
|
payload[1] = 0xAA;
|
||||||
|
|
||||||
|
Assert.True(complete);
|
||||||
|
Assert.Equal(11, queue);
|
||||||
|
Assert.Equal(0xAA, message.Span[1]);
|
||||||
|
Assert.Equal(0, assembler.PartialCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIngest_BorrowedMultiFragment_CopiesAcrossDatagrams()
|
||||||
|
{
|
||||||
|
var assembler = new FragmentAssembler();
|
||||||
|
byte[] firstPayload = [1, 2];
|
||||||
|
byte[] secondPayload = [3, 4];
|
||||||
|
var first = new BorrowedMessageFragment(
|
||||||
|
MakeFrag(20, 2, 0, firstPayload, 12).Header,
|
||||||
|
firstPayload);
|
||||||
|
var second = new BorrowedMessageFragment(
|
||||||
|
MakeFrag(20, 2, 1, secondPayload, 12).Header,
|
||||||
|
secondPayload);
|
||||||
|
|
||||||
|
Assert.False(assembler.TryIngest(
|
||||||
|
first,
|
||||||
|
out _,
|
||||||
|
out _));
|
||||||
|
firstPayload[0] = 0xFF;
|
||||||
|
Assert.True(assembler.TryIngest(
|
||||||
|
second,
|
||||||
|
out ReadOnlyMemory<byte> message,
|
||||||
|
out ushort queue));
|
||||||
|
secondPayload[0] = 0xEE;
|
||||||
|
|
||||||
|
Assert.Equal(12, queue);
|
||||||
|
Assert.Equal(new byte[] { 1, 2, 3, 4 }, message.ToArray());
|
||||||
|
Assert.Equal(0, assembler.PartialCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryIngest_ConflictingBorrowedIdentity_PreservesOriginalPartial()
|
||||||
|
{
|
||||||
|
var assembler = new FragmentAssembler();
|
||||||
|
var first = new BorrowedMessageFragment(
|
||||||
|
MakeFrag(30, 2, 0, [1], 13).Header,
|
||||||
|
new byte[] { 1 });
|
||||||
|
var conflict = new BorrowedMessageFragment(
|
||||||
|
MakeFrag(30, 3, 1, [9], 14).Header,
|
||||||
|
new byte[] { 9 });
|
||||||
|
var completion = new BorrowedMessageFragment(
|
||||||
|
MakeFrag(30, 2, 1, [2], 13).Header,
|
||||||
|
new byte[] { 2 });
|
||||||
|
|
||||||
|
Assert.False(assembler.TryIngest(first, out _, out _));
|
||||||
|
Assert.False(assembler.TryIngest(conflict, out _, out _));
|
||||||
|
Assert.Equal(1, assembler.PartialCount);
|
||||||
|
Assert.True(assembler.TryIngest(
|
||||||
|
completion,
|
||||||
|
out ReadOnlyMemory<byte> message,
|
||||||
|
out ushort queue));
|
||||||
|
|
||||||
|
Assert.Equal(13, queue);
|
||||||
|
Assert.Equal(new byte[] { 1, 2 }, message.ToArray());
|
||||||
|
Assert.Equal(0, assembler.PartialCount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,42 @@ public class MessageFragmentTests
|
||||||
Assert.Equal(0, consumed);
|
Assert.Equal(0, consumed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_ZeroFragmentCount_ReturnsNull()
|
||||||
|
{
|
||||||
|
var header = new MessageFragmentHeader
|
||||||
|
{
|
||||||
|
TotalSize = MessageFragmentHeader.Size,
|
||||||
|
Count = 0,
|
||||||
|
Index = 0,
|
||||||
|
};
|
||||||
|
byte[] buf = new byte[MessageFragmentHeader.Size];
|
||||||
|
header.Pack(buf);
|
||||||
|
|
||||||
|
var (frag, consumed) = MessageFragment.TryParse(buf);
|
||||||
|
|
||||||
|
Assert.Null(frag);
|
||||||
|
Assert.Equal(0, consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_IndexOutsideFragmentCount_ReturnsNull()
|
||||||
|
{
|
||||||
|
var header = new MessageFragmentHeader
|
||||||
|
{
|
||||||
|
TotalSize = MessageFragmentHeader.Size,
|
||||||
|
Count = 2,
|
||||||
|
Index = 2,
|
||||||
|
};
|
||||||
|
byte[] buf = new byte[MessageFragmentHeader.Size];
|
||||||
|
header.Pack(buf);
|
||||||
|
|
||||||
|
var (frag, consumed) = MessageFragment.TryParse(buf);
|
||||||
|
|
||||||
|
Assert.Null(frag);
|
||||||
|
Assert.Equal(0, consumed);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryParse_ConsumesExactlyOneFragment_LeavesRemainderForCaller()
|
public void TryParse_ConsumesExactlyOneFragment_LeavesRemainderForCaller()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue