diff --git a/docs/plans/2026-07-25-modern-runtime-slice-h.md b/docs/plans/2026-07-25-modern-runtime-slice-h.md index 7d2ab057..810908dd 100644 --- a/docs/plans/2026-07-25-modern-runtime-slice-h.md +++ b/docs/plans/2026-07-25-modern-runtime-slice-h.md @@ -142,9 +142,10 @@ H-c is executed as three independently reversible units: datagrams, unchanged blocking handshake cadence, and direct span sends. Evidence: [`../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 - 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 framing into caller storage and remove intermediate payload arrays. diff --git a/docs/research/2026-07-25-slice-h-c2-borrowed-packet-decode.md b/docs/research/2026-07-25-slice-h-c2-borrowed-packet-decode.md new file mode 100644 index 00000000..711fb04c --- /dev/null +++ b/docs/research/2026-07-25-slice-h-c2-borrowed-packet-decode.md @@ -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. diff --git a/src/AcDream.Core.Net/Messages/EmoteText.cs b/src/AcDream.Core.Net/Messages/EmoteText.cs index 2931a3cc..ffad7c76 100644 --- a/src/AcDream.Core.Net/Messages/EmoteText.cs +++ b/src/AcDream.Core.Net/Messages/EmoteText.cs @@ -35,16 +35,16 @@ public static class EmoteText string SenderName, string Text); - public static Parsed? TryParse(byte[] body) + public static Parsed? TryParse(ReadOnlySpan body) { - if (body is null || body.Length < 8) return null; + if (body.Length < 8) return null; uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); if (opcode != Opcode) return null; try { int pos = 4; - uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; string senderName = StringReader.ReadString16L(body, ref pos); string text = StringReader.ReadString16L(body, ref pos); diff --git a/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs b/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs index 1a1abfdc..246725c9 100644 --- a/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs +++ b/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs @@ -17,7 +17,10 @@ namespace AcDream.Core.Net.Messages; /// Handlers are invoked synchronously on the thread that called /// — normally the render thread since /// '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. /// /// public sealed class GameEventDispatcher @@ -86,7 +89,8 @@ public sealed class GameEventDispatcher /// /// Route an envelope to its handler, or log as unhandled. Exceptions /// 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 or its payload. /// public void Dispatch(GameEventEnvelope envelope) { diff --git a/src/AcDream.Core.Net/Messages/GameEventEnvelope.cs b/src/AcDream.Core.Net/Messages/GameEventEnvelope.cs index 9ef9f2a3..ed17ee81 100644 --- a/src/AcDream.Core.Net/Messages/GameEventEnvelope.cs +++ b/src/AcDream.Core.Net/Messages/GameEventEnvelope.cs @@ -42,16 +42,28 @@ public readonly record struct GameEventEnvelope( /// 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); + /// + /// Internal receive-path parser. The returned payload borrows + /// and must not escape synchronous dispatch. + /// + internal static GameEventEnvelope? TryParseBorrowed( + ReadOnlyMemory body) + { + if (body.Length < HeaderSize) return null; + + ReadOnlySpan span = body.Span; + uint outer = BinaryPrimitives.ReadUInt32LittleEndian(span); if (outer != Opcode) return null; - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); - uint sequence = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); - uint eventType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(4)); + uint sequence = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(8)); + uint eventType = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(12)); - var payload = body.AsMemory(HeaderSize); + ReadOnlyMemory payload = body.Slice(HeaderSize); return new GameEventEnvelope(guid, sequence, (GameEventType)eventType, payload); } } diff --git a/src/AcDream.Core.Net/Messages/HearSpeech.cs b/src/AcDream.Core.Net/Messages/HearSpeech.cs index 138af629..75a709de 100644 --- a/src/AcDream.Core.Net/Messages/HearSpeech.cs +++ b/src/AcDream.Core.Net/Messages/HearSpeech.cs @@ -46,9 +46,9 @@ public static class HearSpeech uint ChatType, bool IsRanged); - public static Parsed? TryParse(byte[] body) + public static Parsed? TryParse(ReadOnlySpan body) { - if (body is null || body.Length < 16) return null; + if (body.Length < 16) return null; uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); bool isRanged; @@ -62,8 +62,8 @@ public static class HearSpeech string text = ReadString16L(body, ref pos); string sender = ReadString16L(body, ref pos); if (body.Length - pos < 8) return null; - uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); pos += 4; - uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); pos += 4; + uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; + uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; return new Parsed(text, sender, senderGuid, chatType, isRanged); } catch { return null; } diff --git a/src/AcDream.Core.Net/Messages/PlayerKilled.cs b/src/AcDream.Core.Net/Messages/PlayerKilled.cs index e35a6c45..a7f134ab 100644 --- a/src/AcDream.Core.Net/Messages/PlayerKilled.cs +++ b/src/AcDream.Core.Net/Messages/PlayerKilled.cs @@ -30,9 +30,9 @@ public static class PlayerKilled uint VictimGuid, uint KillerGuid); - public static Parsed? TryParse(byte[] body) + public static Parsed? TryParse(ReadOnlySpan body) { - if (body is null || body.Length < 4) return null; + if (body.Length < 4) return null; uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); if (opcode != Opcode) return null; @@ -41,9 +41,9 @@ public static class PlayerKilled int pos = 4; string deathMessage = StringReader.ReadString16L(body, ref pos); if (body.Length - pos < 8) return null; - uint victimGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + uint victimGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; - uint killerGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + uint killerGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); return new Parsed(deathMessage, victimGuid, killerGuid); } catch { return null; } diff --git a/src/AcDream.Core.Net/Messages/ServerMessage.cs b/src/AcDream.Core.Net/Messages/ServerMessage.cs index 3ccf0b8d..df7a85bf 100644 --- a/src/AcDream.Core.Net/Messages/ServerMessage.cs +++ b/src/AcDream.Core.Net/Messages/ServerMessage.cs @@ -32,9 +32,9 @@ public static class ServerMessage public readonly record struct Parsed(string Message, uint ChatType); - public static Parsed? TryParse(byte[] body) + public static Parsed? TryParse(ReadOnlySpan body) { - if (body is null || body.Length < 8) return null; + if (body.Length < 8) return null; uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); if (opcode != Opcode) return null; @@ -43,7 +43,7 @@ public static class ServerMessage int pos = 4; string message = StringReader.ReadString16L(body, ref pos); 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); } catch { return null; } diff --git a/src/AcDream.Core.Net/Messages/SoulEmote.cs b/src/AcDream.Core.Net/Messages/SoulEmote.cs index cdc46f8f..d61d8ce2 100644 --- a/src/AcDream.Core.Net/Messages/SoulEmote.cs +++ b/src/AcDream.Core.Net/Messages/SoulEmote.cs @@ -31,16 +31,16 @@ public static class SoulEmote string SenderName, string Text); - public static Parsed? TryParse(byte[] body) + public static Parsed? TryParse(ReadOnlySpan body) { - if (body is null || body.Length < 8) return null; + if (body.Length < 8) return null; uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); if (opcode != Opcode) return null; try { int pos = 4; - uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; string senderName = StringReader.ReadString16L(body, ref pos); string text = StringReader.ReadString16L(body, ref pos); diff --git a/src/AcDream.Core.Net/Messages/TurbineChat.cs b/src/AcDream.Core.Net/Messages/TurbineChat.cs index bc301d85..877c7174 100644 --- a/src/AcDream.Core.Net/Messages/TurbineChat.cs +++ b/src/AcDream.Core.Net/Messages/TurbineChat.cs @@ -143,9 +143,9 @@ public static class TurbineChat /// for any structural error (truncation, unknown blob_type, ACE /// id-mismatch in RequestSendToRoomById, malformed UTF-16 string). /// - public static Parsed? TryParse(byte[] body) + public static Parsed? TryParse(ReadOnlySpan body) { - if (body is null || body.Length < 4 + HeaderSize) return null; + if (body.Length < 4 + HeaderSize) return null; uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); if (opcode != Opcode) return null; @@ -259,7 +259,7 @@ public static class TurbineChat int remaining = expectedPayload; if (body.Length - pos < remaining) return null; var bytes = new byte[remaining]; - Array.Copy(body, pos, bytes, 0, remaining); + body.Slice(pos, remaining).CopyTo(bytes); pos += remaining; payload = new Payload.Unknown(bytes); break; @@ -403,7 +403,9 @@ public static class TurbineChat /// 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). /// - public static string ReadTurbineString(byte[] data, ref int pos) + public static string ReadTurbineString( + ReadOnlySpan data, + ref int pos) { if (data.Length - pos < 1) throw new FormatException("turbine str: truncated len"); int chars = data[pos]; @@ -422,7 +424,8 @@ public static class TurbineChat // String.from_utf16 in Rust validates surrogate pairs; .NET's // 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; return s; } @@ -463,9 +466,12 @@ public static class TurbineChat // Helpers // ────────────────────────────────────────────────────────────── - private static uint ReadU32(byte[] data, ref int pos) + private static uint ReadU32( + ReadOnlySpan data, + ref int pos) { - uint v = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(pos, 4)); + uint v = BinaryPrimitives.ReadUInt32LittleEndian( + data.Slice(pos, 4)); pos += 4; return v; } diff --git a/src/AcDream.Core.Net/Packets/BorrowedPacket.cs b/src/AcDream.Core.Net/Packets/BorrowedPacket.cs new file mode 100644 index 00000000..b369b7d6 --- /dev/null +++ b/src/AcDream.Core.Net/Packets/BorrowedPacket.cs @@ -0,0 +1,74 @@ +namespace AcDream.Core.Net.Packets; + +/// +/// Allocation-free decoded packet view. Every memory member borrows the +/// caller's datagram and is valid only while that owner retains the buffer. +/// +internal readonly record struct BorrowedPacket( + PacketHeader Header, + BorrowedOptionalHeader Optional, + ReadOnlyMemory Body, + ReadOnlyMemory 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 RawBytes, + ReadOnlyMemory 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 Payload); + +internal readonly struct BorrowedFragmentEnumerable( + ReadOnlyMemory encoded) +{ + public Enumerator GetEnumerator() => new(encoded); + + internal struct Enumerator(ReadOnlyMemory remaining) + { + private ReadOnlyMemory _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; + } + } +} diff --git a/src/AcDream.Core.Net/Packets/FragmentAssembler.cs b/src/AcDream.Core.Net/Packets/FragmentAssembler.cs index f82fdb08..2fedf43f 100644 --- a/src/AcDream.Core.Net/Packets/FragmentAssembler.cs +++ b/src/AcDream.Core.Net/Packets/FragmentAssembler.cs @@ -95,6 +95,80 @@ public sealed class FragmentAssembler return combined; } + /// + /// 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. + /// + internal bool TryIngest( + in BorrowedMessageFragment fragment, + out ReadOnlyMemory message, + out ushort messageQueue) + { + MessageFragmentHeader header = fragment.Header; + message = ReadOnlyMemory.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; + } + /// Discard all in-flight partial messages. public void DropAll() => _inFlight.Clear(); diff --git a/src/AcDream.Core.Net/Packets/MessageFragment.cs b/src/AcDream.Core.Net/Packets/MessageFragment.cs index d86b1571..60db895f 100644 --- a/src/AcDream.Core.Net/Packets/MessageFragment.cs +++ b/src/AcDream.Core.Net/Packets/MessageFragment.cs @@ -20,25 +20,75 @@ public readonly record struct MessageFragment(MessageFragmentHeader Header, byte /// public static (MessageFragment? fragment, int consumed) TryParse(ReadOnlySpan source) { - if (source.Length < MessageFragmentHeader.Size) + if (!TryParseLayout( + source, + out MessageFragmentHeader header, + out int payloadLength, + out int consumed)) + { 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 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 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 // smaller than the header or larger than the max fragment size is // wire corruption and we refuse to parse. 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) - return (null, 0); + return false; - var payload = source.Slice(MessageFragmentHeader.Size, payloadLength).ToArray(); - return (new MessageFragment(header, payload), header.TotalSize); + consumed = header.TotalSize; + return true; } } diff --git a/src/AcDream.Core.Net/Packets/PacketCodec.cs b/src/AcDream.Core.Net/Packets/PacketCodec.cs index b3cd209e..cdf537bc 100644 --- a/src/AcDream.Core.Net/Packets/PacketCodec.cs +++ b/src/AcDream.Core.Net/Packets/PacketCodec.cs @@ -115,6 +115,373 @@ public static class PacketCodec return new PacketDecodeResult(packet, DecodeError.None); } + /// + /// Decode and verify one packet without materializing packet, optional- + /// header, fragment-list, body, or fragment-payload objects. Returned + /// memory borrows and must not outlive it. + /// + internal static BorrowedPacketDecodeResult TryDecodeBorrowed( + ReadOnlyMemory datagram, + IsaacRandom? inboundIsaac) + { + ReadOnlySpan 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 body = datagram.Slice( + PacketHeader.Size, + bodyLength); + if (!TryParseBorrowedOptional( + body, + header.Flags, + out BorrowedOptionalHeader optional, + out int optionalConsumed)) + { + return new BorrowedPacketDecodeResult( + default, + DecodeError.InvalidOptionalHeader); + } + + ReadOnlyMemory fragmentBytes = + ReadOnlyMemory.Empty; + int fragmentCount = 0; + uint fragmentHash = 0; + if (header.HasFlag(PacketHeaderFlags.BlobFragments)) + { + fragmentBytes = body.Slice(optionalConsumed); + ReadOnlySpan 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 bodyMemory, + PacketHeaderFlags flags, + out BorrowedOptionalHeader optional, + out int consumed) + { + ReadOnlySpan 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 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.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 body, + ref int position, + int count) + { + if (body.Length - position < count) + return false; + position += count; + return true; + } + /// /// Assemble a datagram from a header and an optional-section body. /// Computes the checksum (both unencrypted and ISAAC-encrypted forms) diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index d1c551dd..73f2e4de 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -801,8 +801,11 @@ public sealed class WorldSession : IDisposable _net.Send(PacketCodec.Encode(loginHeader, loginPayload, null)); // Step 2: wait for ConnectRequest - Packet? cr = null; - while (DateTime.UtcNow < deadline && cr is null) + bool connectRequestReceived = false; + BorrowedOptionalHeader connectRequest = default; + ushort connectRequestIteration = 0; + while (DateTime.UtcNow < deadline + && !connectRequestReceived) { PooledInboundDatagram? received = ReceiveBlocking(deadline - DateTime.UtcNow); @@ -812,14 +815,23 @@ public sealed class WorldSession : IDisposable PooledInboundDatagram datagram = received.Value; try { - var dec = PacketCodec.TryDecode( - datagram.Memory.Span, + BorrowedPacketDecodeResult dec = + PacketCodec.TryDecodeBorrowed( + datagram.Memory, inboundIsaac: null); if (dec.IsOk - && dec.Packet!.Header.HasFlag( + && dec.Packet.Header.HasFlag( PacketHeaderFlags.ConnectRequest)) { - cr = dec.Packet; + connectRequest = dec.Packet.Optional with + { + RawBytes = ReadOnlyMemory.Empty, + RetransmitRequestBytes = + ReadOnlyMemory.Empty, + }; + connectRequestIteration = + dec.Packet.Header.Iteration; + connectRequestReceived = true; } } finally @@ -827,10 +839,15 @@ public sealed class WorldSession : IDisposable 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 - var opt = cr.Optional; + BorrowedOptionalHeader opt = connectRequest; // Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives // in the ConnectRequest optional section. Publish it to @@ -846,7 +863,7 @@ public sealed class WorldSession : IDisposable // SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData // generation into connection-level control packets, including the // final disconnect. ACE currently emits iteration 1. - _sessionIteration = cr.Header.Iteration; + _sessionIteration = connectRequestIteration; _transportNegotiated = true; _clientPacketSequence = 2; @@ -979,7 +996,7 @@ public sealed class WorldSession : IDisposable { try { - ProcessDatagram(datagram.Memory.Span); + ProcessDatagram(datagram.Memory); } finally { @@ -1142,7 +1159,7 @@ public sealed class WorldSession : IDisposable try { ProcessDatagram( - datagram.Memory.Span, + datagram.Memory, opcodesThisCall); return true; } @@ -1153,11 +1170,14 @@ public sealed class WorldSession : IDisposable } private void ProcessDatagram( - ReadOnlySpan bytes, + ReadOnlyMemory bytes, List? opcodesOut = null, bool dispatchWorldEvents = true) { - var dec = PacketCodec.TryDecode(bytes, _inboundIsaac); + BorrowedPacketDecodeResult dec = + PacketCodec.TryDecodeBorrowed( + bytes, + _inboundIsaac); if (!dec.IsOk) return; // 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 — // which surfaces in other clients' views as the player rendering // as a stationary purple haze (loading state). - var serverHeader = dec.Packet!.Header; + PacketHeader serverHeader = dec.Packet.Header; if (serverHeader.Sequence > 0 && (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0) { @@ -1184,7 +1204,7 @@ public sealed class WorldSession : IDisposable // periodically — no explicit opcode, just the header flag. if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0) { - double t = dec.Packet!.Optional.TimeSync; + double t = dec.Packet.Optional.TimeSync; if (t > 0) { 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 (body is null || body.Length < 4) continue; - uint op = BinaryPrimitives.ReadUInt32LittleEndian(body); + if (!_assembler.TryIngest( + frag, + out ReadOnlyMemory bodyMemory, + out _) + || bodyMemory.Length < 4) + { + continue; + } + + ReadOnlySpan body = bodyMemory.Span; + uint op = BinaryPrimitives.ReadUInt32LittleEndian( + body); opcodesOut?.Add(op); // Retail waits for the server's opcode-only CharacterLogOff echo @@ -1335,7 +1365,9 @@ public sealed class WorldSession : IDisposable && !_setStateHexDumped) { _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"))); 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 // registered handler for that sub-opcode. Unregistered // 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); } 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) { uint envType = System.Buffers.Binary.BinaryPrimitives - .ReadUInt32LittleEndian(body.AsSpan(4, 4)); + .ReadUInt32LittleEndian(body.Slice(4, 4)); EnvironChanged?.Invoke(envType); } } @@ -1502,7 +1535,7 @@ public sealed class WorldSession : IDisposable if (body.Length >= 6) { ushort sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian( - body.AsSpan(4, 2)); + body.Slice(4, 2)); TeleportStarted?.Invoke(sequence); } } @@ -2344,7 +2377,7 @@ public sealed class WorldSession : IDisposable datagram => { ProcessDatagram( - datagram.Memory.Span, + datagram.Memory, dispatchWorldEvents: false); return Volatile.Read(ref _characterLogOffConfirmed) != 0; }, diff --git a/tests/AcDream.Core.Net.Tests/Packets/BorrowedPacketCodecTests.cs b/tests/AcDream.Core.Net.Tests/Packets/BorrowedPacketCodecTests.cs new file mode 100644 index 00000000..4aeec1c4 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Packets/BorrowedPacketCodecTests.cs @@ -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 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(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(); + 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 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 bytes, + ref int position, + ushort value) + { + BinaryPrimitives.WriteUInt16LittleEndian( + bytes.Slice(position), + value); + position += sizeof(ushort); + } + + private static void WriteUInt32( + Span bytes, + ref int position, + uint value) + { + BinaryPrimitives.WriteUInt32LittleEndian( + bytes.Slice(position), + value); + position += sizeof(uint); + } + + private static void WriteUInt64( + Span bytes, + ref int position, + ulong value) + { + BinaryPrimitives.WriteUInt64LittleEndian( + bytes.Slice(position), + value); + position += sizeof(ulong); + } + + private static void WriteSingle( + Span bytes, + ref int position, + float value) + { + BinaryPrimitives.WriteSingleLittleEndian( + bytes.Slice(position), + value); + position += sizeof(float); + } + + private static void WriteDouble( + Span bytes, + ref int position, + double value) + { + BinaryPrimitives.WriteInt64LittleEndian( + bytes.Slice(position), + BitConverter.DoubleToInt64Bits(value)); + position += sizeof(double); + } + + private static void Fill( + Span bytes, + ref int position, + int count, + byte value) + { + bytes.Slice(position, count).Fill(value); + position += count; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs b/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs index d80ce9cf..dc55cd87 100644 --- a/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs +++ b/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs @@ -124,4 +124,81 @@ public class FragmentAssemblerTests assembler.DropAll(); 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 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 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 message, + out ushort queue)); + + Assert.Equal(13, queue); + Assert.Equal(new byte[] { 1, 2 }, message.ToArray()); + Assert.Equal(0, assembler.PartialCount); + } } diff --git a/tests/AcDream.Core.Net.Tests/Packets/MessageFragmentTests.cs b/tests/AcDream.Core.Net.Tests/Packets/MessageFragmentTests.cs index 8eae892c..8016b719 100644 --- a/tests/AcDream.Core.Net.Tests/Packets/MessageFragmentTests.cs +++ b/tests/AcDream.Core.Net.Tests/Packets/MessageFragmentTests.cs @@ -75,6 +75,42 @@ public class MessageFragmentTests 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] public void TryParse_ConsumesExactlyOneFragment_LeavesRemainderForCaller() {