From 18e308fe85850b0cfdd850894f1bdd26ce820c2e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 14:17:37 +0200 Subject: [PATCH] feat(net): PacketHeader + PacketHeaderFlags + Hash32 checksum (Phase 4.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 20-byte AC UDP packet header struct + pack/unpack + its checksum helper, and the Hash32 primitive the checksum uses. Hash32 (Cryptography/Hash32.cs): - Seeds accumulator with length << 16 - Sums input as little-endian uint32s word-aligned - Folds any trailing 1-3 bytes via descending shift (24 → 16 → 8) - Hand-computed golden values for 4-byte, 5-byte, and each 1/2/3 tail-byte case — no oracle needed, algorithm is simple enough to verify by tracing PacketHeader (Packets/PacketHeader.cs): - Pack/Unpack: Sequence, Flags, Checksum, Id, Time, DataSize, Iteration (20 bytes, little-endian on the wire) - CalculateHeaderHash32: substitutes the 0xBADD70DD sentinel for the Checksum field before hashing (matches AC retail + ACE convention — without it the checksum would chicken-and-egg on itself). Uses a local struct copy so the real Checksum isn't mutated on the caller. - HasFlag for bitmask queries PacketHeaderFlags (Packets/PacketHeaderFlags.cs): - Full flag enum from ACE reference: Retransmission, EncryptedChecksum, BlobFragments, ServerSwitch, ConnectRequest/Response, LoginRequest, AckSequence, TimeSync, Disconnect, NetError, EchoRequest/Response, Flow, and friends Tests (15 new, 20 total in net project, 97 across both projects): Hash32 (7): - Empty returns 0 - 4-byte known value (hand-computed from bit layout) - 5-byte value with one tail byte - 1/2/3 tail-byte boundary cases (verifies 24/16/8 shift ordering) - Determinism PacketHeader (8): - Pack/Unpack round-trip preserving all 7 fields - Pack writes little-endian wire format in byte order - HasFlag single and multi-bit - CalculateHeaderHash32 invariance under Checksum field changes (the critical property — verifies the BADD sentinel substitution) - CalculateHeaderHash32 doesn't mutate - CalculateHeaderHash32 determinism - Unpack/Pack size-check throw User confirmed an ACE server is running on localhost for the future Phase 4.6 live integration step. Credentials will be read from env vars at runtime, never committed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.Core.Net/Cryptography/Hash32.cs | 41 ++++++ src/AcDream.Core.Net/Packets/PacketHeader.cs | 99 ++++++++++++++ .../Packets/PacketHeaderFlags.cs | 35 +++++ .../Cryptography/Hash32Tests.cs | 75 +++++++++++ .../Packets/PacketHeaderTests.cs | 121 ++++++++++++++++++ 5 files changed, 371 insertions(+) create mode 100644 src/AcDream.Core.Net/Cryptography/Hash32.cs create mode 100644 src/AcDream.Core.Net/Packets/PacketHeader.cs create mode 100644 src/AcDream.Core.Net/Packets/PacketHeaderFlags.cs create mode 100644 tests/AcDream.Core.Net.Tests/Cryptography/Hash32Tests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Packets/PacketHeaderTests.cs diff --git a/src/AcDream.Core.Net/Cryptography/Hash32.cs b/src/AcDream.Core.Net/Cryptography/Hash32.cs new file mode 100644 index 0000000..5a66ac9 --- /dev/null +++ b/src/AcDream.Core.Net/Cryptography/Hash32.cs @@ -0,0 +1,41 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Cryptography; + +/// +/// Simple 32-bit checksum used by the AC packet header. The algorithm is: +/// +/// Seed the accumulator with length << 16. +/// Sum the input as little-endian uint32s, four bytes at a time. +/// Fold any tail bytes (length not divisible by 4) into the top of +/// the accumulator with a descending left-shift per byte (24, 16, 8, 0). +/// +/// Reimplemented from reading ACE's AGPL reference +/// (references/ACE/Source/ACE.Common/Cryptography/Hash32.cs). See +/// NOTICE.md for the attribution policy. +/// +public static class Hash32 +{ + public static uint Calculate(ReadOnlySpan data) + { + int length = data.Length; + uint checksum = (uint)length << 16; + + int wordAligned = length & ~3; + for (int i = 0; i < wordAligned; i += 4) + { + checksum += BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i)); + } + + // Tail bytes (0, 1, 2, or 3 bytes) with descending shift: first tail + // byte goes into bits 24..31, second into 16..23, third into 8..15. + int shift = 24; + for (int j = wordAligned; j < length; j++) + { + checksum += (uint)data[j] << shift; + shift -= 8; + } + + return checksum; + } +} diff --git a/src/AcDream.Core.Net/Packets/PacketHeader.cs b/src/AcDream.Core.Net/Packets/PacketHeader.cs new file mode 100644 index 0000000..f2d9fe8 --- /dev/null +++ b/src/AcDream.Core.Net/Packets/PacketHeader.cs @@ -0,0 +1,99 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Cryptography; + +namespace AcDream.Core.Net.Packets; + +/// +/// The fixed 20-byte header that prefixes every AC UDP packet. Fields are +/// little-endian on the wire. +/// +/// +/// Layout (byte offsets): +/// +/// 0 Sequence uint32 Packet sequence number (client- or server-side monotonic) +/// 4 Flags uint32 PacketHeaderFlags bitmask +/// 8 Checksum uint32 Header+body checksum (XOR'd with ISAAC keystream when the +/// EncryptedChecksum flag is set) +/// 12 Id uint16 Session/connection id +/// 14 Time uint16 Server time echo (used for latency tracking) +/// 16 Size uint16 Body length in bytes (excludes this 20-byte header) +/// 18 Iteration uint16 Retransmit iteration counter +/// +/// +/// +/// Reimplemented from ACE's AGPL reference; see NOTICE.md. +/// +public struct PacketHeader +{ + public const int Size = 20; + + /// + /// Placeholder checksum value that every header uses while its own Hash32 + /// checksum is being calculated. The real checksum replaces this sentinel + /// before transmit. Matches AC's retail + ACE's convention byte-for-byte. + /// + public const uint ChecksumPlaceholder = 0xBADD70DDu; + + public uint Sequence; + public PacketHeaderFlags Flags; + public uint Checksum; + public ushort Id; + public ushort Time; + public ushort DataSize; + public ushort Iteration; + + /// True if the header's Flags field has any of the bits in . + public readonly bool HasFlag(PacketHeaderFlags flags) => (Flags & flags) != 0; + + /// Write this header into the first 20 bytes of . + public readonly void Pack(Span destination) + { + if (destination.Length < Size) + throw new ArgumentException($"destination must be at least {Size} bytes", nameof(destination)); + + BinaryPrimitives.WriteUInt32LittleEndian(destination.Slice(0), Sequence); + BinaryPrimitives.WriteUInt32LittleEndian(destination.Slice(4), (uint)Flags); + BinaryPrimitives.WriteUInt32LittleEndian(destination.Slice(8), Checksum); + BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(12), Id); + BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(14), Time); + BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(16), DataSize); + BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(18), Iteration); + } + + /// Read a header from the first 20 bytes of . + public static PacketHeader Unpack(ReadOnlySpan source) + { + if (source.Length < Size) + throw new ArgumentException($"source must be at least {Size} bytes", nameof(source)); + + return new PacketHeader + { + Sequence = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(0)), + Flags = (PacketHeaderFlags)BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(4)), + Checksum = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(8)), + Id = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(12)), + Time = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(14)), + DataSize = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(16)), + Iteration = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(18)), + }; + } + + /// + /// Compute the Hash32 of this header with the Checksum field replaced by + /// . Used by both the transmit path (to + /// fill in the real checksum before sending) and the receive path (to + /// verify a packet's checksum matches the one we'd compute). + /// + /// Returned value is the header's contribution to the overall packet + /// checksum; the full packet checksum also folds in body fragments. + /// + public readonly uint CalculateHeaderHash32() + { + Span buffer = stackalloc byte[Size]; + // Save and restore Checksum via a local copy rather than mutating this. + var saved = this; + saved.Checksum = ChecksumPlaceholder; + saved.Pack(buffer); + return Hash32.Calculate(buffer); + } +} diff --git a/src/AcDream.Core.Net/Packets/PacketHeaderFlags.cs b/src/AcDream.Core.Net/Packets/PacketHeaderFlags.cs new file mode 100644 index 0000000..5aef33f --- /dev/null +++ b/src/AcDream.Core.Net/Packets/PacketHeaderFlags.cs @@ -0,0 +1,35 @@ +namespace AcDream.Core.Net.Packets; + +/// +/// Flags field in a 20-byte . Each flag gates the +/// presence of an optional body section or signals a handshake/control +/// operation. Reimplemented from ACE's AGPL reference; values are +/// wire-format facts about AC's retail protocol. +/// +[Flags] +public enum PacketHeaderFlags : uint +{ + None = 0x00000000, + Retransmission = 0x00000001, + EncryptedChecksum = 0x00000002, + BlobFragments = 0x00000004, + ServerSwitch = 0x00000100, + LogonServerAddr = 0x00000200, + EmptyHeader1 = 0x00000400, + Referral = 0x00000800, + RequestRetransmit = 0x00001000, + RejectRetransmit = 0x00002000, + AckSequence = 0x00004000, + Disconnect = 0x00008000, + LoginRequest = 0x00010000, + WorldLoginRequest = 0x00020000, + ConnectRequest = 0x00040000, + ConnectResponse = 0x00080000, + NetError = 0x00100000, + NetErrorDisconnect = 0x00200000, + CICMDCommand = 0x00400000, + TimeSync = 0x01000000, + EchoRequest = 0x02000000, + EchoResponse = 0x04000000, + Flow = 0x08000000, +} diff --git a/tests/AcDream.Core.Net.Tests/Cryptography/Hash32Tests.cs b/tests/AcDream.Core.Net.Tests/Cryptography/Hash32Tests.cs new file mode 100644 index 0000000..9021377 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Cryptography/Hash32Tests.cs @@ -0,0 +1,75 @@ +using AcDream.Core.Net.Cryptography; + +namespace AcDream.Core.Net.Tests.Cryptography; + +public class Hash32Tests +{ + [Fact] + public void Calculate_EmptyInput_ReturnsZero() + { + Assert.Equal(0u, Hash32.Calculate(ReadOnlySpan.Empty)); + } + + [Fact] + public void Calculate_FourBytes_HandComputedValue() + { + // length=4 → seed = 4 << 16 = 0x00040000 + // word = LE(0x01,0x02,0x03,0x04) = 0x04030201 + // checksum = 0x00040000 + 0x04030201 = 0x04070201 + var data = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + Assert.Equal(0x04070201u, Hash32.Calculate(data)); + } + + [Fact] + public void Calculate_FiveBytes_TailByteGoesToHighNibble() + { + // length=5 → seed = 0x00050000 + // word0 = LE(0xAA,0xBB,0xCC,0xDD) = 0xDDCCBBAA + // checksum after word = 0x00050000 + 0xDDCCBBAA = 0xDDD1BBAA + // tail byte: data[4]=0xEE << 24 = 0xEE000000 + // checksum += 0xEE000000 → 0xDDD1BBAA + 0xEE000000 = 0xCBD1BBAA (wrap) + var data = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE }; + Assert.Equal(0xCBD1BBAAu, Hash32.Calculate(data)); + } + + [Fact] + public void Calculate_SingleTailByte_ShiftsInto24() + { + // length=1 → seed = 0x00010000 + // tail: 0x42 << 24 = 0x42000000 + // checksum = 0x42010000 + var data = new byte[] { 0x42 }; + Assert.Equal(0x42010000u, Hash32.Calculate(data)); + } + + [Fact] + public void Calculate_TwoTailBytes_ShiftInto24And16() + { + // length=2 → seed = 0x00020000 + // tail0 (0x42) << 24 = 0x42000000 + // tail1 (0x7F) << 16 = 0x007F0000 + // total = 0x42020000 + 0x007F0000 = 0x42810000 + // Wait — order matters. First tail byte shifts by 24 (most significant), + // second by 16. So: seed + 0x42000000 + 0x007F0000 = 0x42810000 + var data = new byte[] { 0x42, 0x7F }; + Assert.Equal(0x42810000u, Hash32.Calculate(data)); + } + + [Fact] + public void Calculate_ThreeTailBytes_ShiftInto24And16And8() + { + // length=3 → seed = 0x00030000 + // tail bytes (0x01, 0x02, 0x03) shifted by 24, 16, 8 respectively: + // 0x01000000 + 0x00020000 + 0x00000300 = 0x01020300 + // total = 0x00030000 + 0x01020300 = 0x01050300 + var data = new byte[] { 0x01, 0x02, 0x03 }; + Assert.Equal(0x01050300u, Hash32.Calculate(data)); + } + + [Fact] + public void Calculate_IsDeterministic() + { + var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + Assert.Equal(Hash32.Calculate(data), Hash32.Calculate(data)); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Packets/PacketHeaderTests.cs b/tests/AcDream.Core.Net.Tests/Packets/PacketHeaderTests.cs new file mode 100644 index 0000000..3b1cb0d --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Packets/PacketHeaderTests.cs @@ -0,0 +1,121 @@ +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Tests.Packets; + +public class PacketHeaderTests +{ + [Fact] + public void PackUnpack_RoundTrip_PreservesAllFields() + { + var original = new PacketHeader + { + Sequence = 0x11223344u, + Flags = PacketHeaderFlags.EncryptedChecksum | PacketHeaderFlags.LoginRequest, + Checksum = 0xDEADBEEFu, + Id = 0x1234, + Time = 0x5678, + DataSize = 256, + Iteration = 3, + }; + + Span buffer = stackalloc byte[PacketHeader.Size]; + original.Pack(buffer); + var decoded = PacketHeader.Unpack(buffer); + + Assert.Equal(original.Sequence, decoded.Sequence); + Assert.Equal(original.Flags, decoded.Flags); + Assert.Equal(original.Checksum, decoded.Checksum); + Assert.Equal(original.Id, decoded.Id); + Assert.Equal(original.Time, decoded.Time); + Assert.Equal(original.DataSize, decoded.DataSize); + Assert.Equal(original.Iteration, decoded.Iteration); + } + + [Fact] + public void Pack_WritesLittleEndianWireFormat() + { + var header = new PacketHeader + { + Sequence = 0x04030201u, // LE wire: 01 02 03 04 + Flags = (PacketHeaderFlags)0x08070605u, // LE: 05 06 07 08 + Checksum = 0x0C0B0A09u, // LE: 09 0A 0B 0C + Id = 0x0E0D, // LE: 0D 0E + Time = 0x100F, // LE: 0F 10 + DataSize = 0x1211, // LE: 11 12 + Iteration = 0x1413, // LE: 13 14 + }; + + Span buf = stackalloc byte[20]; + header.Pack(buf); + + byte[] expected = new byte[] + { + 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, + 0x0F, 0x10, + 0x11, 0x12, + 0x13, 0x14, + }; + Assert.Equal(expected, buf.ToArray()); + } + + [Fact] + public void HasFlag_DetectsSingleBit() + { + var h = new PacketHeader { Flags = PacketHeaderFlags.EncryptedChecksum | PacketHeaderFlags.TimeSync }; + Assert.True(h.HasFlag(PacketHeaderFlags.EncryptedChecksum)); + Assert.True(h.HasFlag(PacketHeaderFlags.TimeSync)); + Assert.False(h.HasFlag(PacketHeaderFlags.LoginRequest)); + } + + [Fact] + public void CalculateHeaderHash32_UsesBaddSentinelNotActualChecksum() + { + // The key property: two headers that differ ONLY in the Checksum field + // MUST produce the same CalculateHeaderHash32 output — the function + // substitutes the sentinel BADD70DD value before hashing. + var a = new PacketHeader { Sequence = 42, Flags = PacketHeaderFlags.None, Checksum = 0x11111111u }; + var b = new PacketHeader { Sequence = 42, Flags = PacketHeaderFlags.None, Checksum = 0xDEADBEEFu }; + Assert.Equal(a.CalculateHeaderHash32(), b.CalculateHeaderHash32()); + } + + [Fact] + public void CalculateHeaderHash32_DoesNotMutateHeaderChecksum() + { + var h = new PacketHeader { Checksum = 0x11111111u }; + _ = h.CalculateHeaderHash32(); + Assert.Equal(0x11111111u, h.Checksum); + } + + [Fact] + public void CalculateHeaderHash32_DeterministicForSameInput() + { + var h = new PacketHeader + { + Sequence = 1, + Flags = PacketHeaderFlags.EncryptedChecksum, + Checksum = 0xDEADBEEFu, + Id = 0x1234, + Time = 0x5678, + DataSize = 32, + Iteration = 0, + }; + Assert.Equal(h.CalculateHeaderHash32(), h.CalculateHeaderHash32()); + } + + [Fact] + public void Unpack_InsufficientData_Throws() + { + Assert.Throws(() => PacketHeader.Unpack(new byte[19])); + } + + [Fact] + public void Pack_InsufficientDestination_Throws() + { + var h = new PacketHeader(); + var buf = new byte[19]; + Assert.Throws(() => h.Pack(buf)); + } +}