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)); + } +}