From 6cda431eae60f58a2c45832055c8ba9eacf06196 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 14:38:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(net):=20PacketCodec.Encode=20=E2=80=94=20f?= =?UTF-8?q?ull=20outbound=20datagram=20assembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the encode side of the codec so acdream can stop hand- assembling outbound packets in tests. Given a PacketHeader (with Flags set, DataSize ignored/overwritten) and a body byte span, Encode: 1. Overwrites header.DataSize with body.Length 2. Parses the optional section out of the body (reusing PacketHeaderOptional.Parse as a length measurer) and hashes those bytes 3. If BlobFragments is set, walks the body tail as back-to-back fragments and sums their Hash32s 4. For unencrypted: header.Checksum = headerHash + optionalHash + fragmentHash 5. For EncryptedChecksum: pulls one ISAAC keystream word and computes header.Checksum = headerHash + (isaacKey XOR payloadHash) 6. Packs header + body into the final datagram Tests (6 new, 67 total in net project, 144 across both test projects): - Unencrypted round-trip: Encode then TryDecode recovers the AckSequence field - DataSize is overwritten (caller can pass garbage) - Encrypted round-trip: two ISAACs with same seed, one encoding and one decoding, both agree on the keystream word - Encrypted but no ISAAC → throws InvalidOperationException - LoginRequest end-to-end: LoginRequest.Build → Encode → TryDecode → LoginRequest.Parse round-trips credentials exactly. This is the single most important integration test for the outbound side — every byte this exercises is exactly what acdream will put on the wire when Phase 4.6 goes live. - BlobFragments body with one embedded fragment: Encode preserves the fragment and fragmentHash is correctly folded into the checksum Codec is now complete end-to-end (decode + encode) and has the LoginRequest outbound path proven against its own decoder. The next commit will wire NetClient over real UDP sockets and connect to the localhost ACE server. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.Core.Net/Packets/PacketCodec.cs | 98 ++++++++++++++++ .../Packets/PacketCodecEncodeTests.cs | 109 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs diff --git a/src/AcDream.Core.Net/Packets/PacketCodec.cs b/src/AcDream.Core.Net/Packets/PacketCodec.cs index 615c729..b3cd209 100644 --- a/src/AcDream.Core.Net/Packets/PacketCodec.cs +++ b/src/AcDream.Core.Net/Packets/PacketCodec.cs @@ -115,6 +115,104 @@ public static class PacketCodec return new PacketDecodeResult(packet, DecodeError.None); } + /// + /// Assemble a datagram from a header and an optional-section body. + /// Computes the checksum (both unencrypted and ISAAC-encrypted forms) + /// and writes it into the header before packing. + /// + /// + /// Callers pass a mutable because + /// Checksum and DataSize are filled in by this method. + /// is the packet payload that goes after the + /// 20-byte header — for a LoginRequest packet this is the + /// output; for an AckSequence packet + /// this is a 4-byte ack sequence number; and so on. + /// + /// + /// + /// For packets with fragments (), + /// the caller is responsible for having pre-serialized the fragment + /// sequence into . A future phase can add a + /// higher-level helper that packs fragments for you. + /// + /// + /// + /// Header fields the caller wants. The Checksum and DataSize fields + /// are overwritten by this method — any values the caller set there + /// are discarded. + /// + /// + /// Bytes that go between the 20-byte header and the end of the + /// datagram. May be empty. + /// + /// + /// ISAAC keystream used to encrypt the checksum if the header's + /// flag is set. + /// Null means unencrypted; will throw if the flag is set but no ISAAC + /// is provided. + /// + public static byte[] Encode(PacketHeader header, ReadOnlySpan body, IsaacRandom? outboundIsaac) + { + header.DataSize = checked((ushort)body.Length); + + // Parse the optional-section length out of the body so we can hash + // it separately from any subsequent fragments. Without the BlobFragments + // flag, the entire body IS the optional section. With BlobFragments, + // we need to know where the optional section ends. + // + // We don't actually need to parse it field-by-field — we just need to + // know how many bytes the optional section consumed. For encode we + // cheat: we ask the caller to give us the full body and hash it as + // (optional = all body bytes that come before the first fragment + // header). Since the caller built the body, they can tell us. + // + // Simpler approach for now: use PacketHeaderOptional.Parse to measure. + // This is slightly redundant work but correct and lets the existing + // Packet types stay as the single source of truth on section layout. + var optional = new PacketHeaderOptional(); + int optionalLen = optional.Parse(body, header.Flags); + if (optionalLen < 0) + throw new ArgumentException("body's optional section is malformed", nameof(body)); + + uint optionalHash = Hash32.Calculate(body.Slice(0, optionalLen)); + + // Hash any fragments in the body tail. + uint fragmentHash = 0; + if (header.HasFlag(PacketHeaderFlags.BlobFragments)) + { + var tail = body.Slice(optionalLen); + while (tail.Length > 0) + { + var (frag, consumed) = MessageFragment.TryParse(tail); + if (frag is null || consumed == 0) + throw new ArgumentException("body contains a malformed fragment", nameof(body)); + fragmentHash += CalculateFragmentHash32(frag.Value); + tail = tail.Slice(consumed); + } + } + + uint headerHash = header.CalculateHeaderHash32(); + uint payloadHash = optionalHash + fragmentHash; + + if (header.HasFlag(PacketHeaderFlags.EncryptedChecksum)) + { + if (outboundIsaac is null) + throw new InvalidOperationException( + "EncryptedChecksum flag set but no ISAAC keystream provided"); + uint isaacKey = outboundIsaac.Next(); + header.Checksum = headerHash + (isaacKey ^ payloadHash); + } + else + { + header.Checksum = headerHash + payloadHash; + } + + byte[] datagram = new byte[PacketHeader.Size + body.Length]; + header.Pack(datagram); + body.CopyTo(datagram.AsSpan(PacketHeader.Size)); + return datagram; + } + /// /// Hash32 of a single fragment = Hash32(header 16 bytes) + Hash32(payload). /// Matches ACE's ClientPacketFragment.CalculateHash32. Public so callers diff --git a/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs new file mode 100644 index 0000000..aa9aa74 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs @@ -0,0 +1,109 @@ +using AcDream.Core.Net.Cryptography; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Tests.Packets; + +public class PacketCodecEncodeTests +{ + [Fact] + public void Encode_Then_TryDecode_UnencryptedChecksum_RoundTrips() + { + // 4-byte ack sequence in the optional section, no fragments. + byte[] body = new byte[4] { 0x78, 0x56, 0x34, 0x12 }; + var header = new PacketHeader + { + Sequence = 7, + Flags = PacketHeaderFlags.AckSequence, + Id = 1, + Time = 100, + }; + + byte[] datagram = PacketCodec.Encode(header, body, outboundIsaac: null); + + var result = PacketCodec.TryDecode(datagram, inboundIsaac: null); + Assert.Equal(PacketCodec.DecodeError.None, result.Error); + Assert.Equal(0x12345678u, result.Packet!.Optional.AckSequence); + } + + [Fact] + public void Encode_DataSizeIsOverwritten_MatchesBodyLength() + { + // Caller sets DataSize to garbage; Encode should overwrite it with + // the actual body length. + var header = new PacketHeader { Flags = PacketHeaderFlags.AckSequence, DataSize = 9999 }; + byte[] body = new byte[4]; + byte[] datagram = PacketCodec.Encode(header, body, null); + + var parsed = PacketHeader.Unpack(datagram); + Assert.Equal(4, parsed.DataSize); + } + + [Fact] + public void Encode_EncryptedChecksum_PairsWithDecodeUsingSameIsaac() + { + var seed = new byte[] { 0xAB, 0xCD, 0xEF, 0x01 }; + var isaacSend = new IsaacRandom(seed); + var isaacRecv = new IsaacRandom(seed); + + byte[] body = new byte[4] { 1, 2, 3, 4 }; + var header = new PacketHeader + { + Flags = PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum, + }; + + byte[] datagram = PacketCodec.Encode(header, body, isaacSend); + var result = PacketCodec.TryDecode(datagram, isaacRecv); + + Assert.Equal(PacketCodec.DecodeError.None, result.Error); + } + + [Fact] + public void Encode_EncryptedChecksum_WithoutIsaac_Throws() + { + var header = new PacketHeader { Flags = PacketHeaderFlags.EncryptedChecksum }; + Assert.Throws( + () => PacketCodec.Encode(header, ReadOnlySpan.Empty, outboundIsaac: null)); + } + + [Fact] + public void Encode_LoginRequest_DecodableAndBodyRoundTrips() + { + // End-to-end that exercises the whole outbound pipeline we care + // about for integration testing: build LoginRequest body → pack + // into a Packet with the LoginRequest flag → send through Encode → + // decode via TryDecode → re-parse the body back to credentials. + byte[] loginPayload = LoginRequest.Build("testaccount", "testpassword", 42); + var header = new PacketHeader { Flags = PacketHeaderFlags.LoginRequest }; + + byte[] datagram = PacketCodec.Encode(header, loginPayload, outboundIsaac: null); + + var decoded = PacketCodec.TryDecode(datagram, inboundIsaac: null); + Assert.Equal(PacketCodec.DecodeError.None, decoded.Error); + var parsed = LoginRequest.Parse(decoded.Packet!.BodyBytes); + Assert.Equal("testaccount", parsed.Account); + Assert.Equal("testpassword", parsed.Password); + } + + [Fact] + public void Encode_FragmentBodyWithBlobFragmentsFlag_DecodeRebuildsFragment() + { + // Build a fragment body (16-byte header + 3-byte payload), set + // BlobFragments flag, encode, decode, verify fragment is intact. + var fragHeader = new MessageFragmentHeader + { + Sequence = 1, Id = 0x80000001u, Count = 1, + TotalSize = 19, Index = 0, Queue = 0, + }; + byte[] body = new byte[19]; + fragHeader.Pack(body); + body[16] = 0xAA; body[17] = 0xBB; body[18] = 0xCC; + + var header = new PacketHeader { Flags = PacketHeaderFlags.BlobFragments }; + byte[] datagram = PacketCodec.Encode(header, body, null); + + var result = PacketCodec.TryDecode(datagram, null); + Assert.Equal(PacketCodec.DecodeError.None, result.Error); + Assert.Single(result.Packet!.Fragments); + Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, result.Packet.Fragments[0].Payload); + } +}