feat(net): PacketHeader + PacketHeaderFlags + Hash32 checksum (Phase 4.2)
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) <noreply@anthropic.com>
This commit is contained in:
parent
293584d6e8
commit
18e308fe85
5 changed files with 371 additions and 0 deletions
75
tests/AcDream.Core.Net.Tests/Cryptography/Hash32Tests.cs
Normal file
75
tests/AcDream.Core.Net.Tests/Cryptography/Hash32Tests.cs
Normal file
|
|
@ -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<byte>.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));
|
||||
}
|
||||
}
|
||||
121
tests/AcDream.Core.Net.Tests/Packets/PacketHeaderTests.cs
Normal file
121
tests/AcDream.Core.Net.Tests/Packets/PacketHeaderTests.cs
Normal file
|
|
@ -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<byte> 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<byte> 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<ArgumentException>(() => PacketHeader.Unpack(new byte[19]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_InsufficientDestination_Throws()
|
||||
{
|
||||
var h = new PacketHeader();
|
||||
var buf = new byte[19];
|
||||
Assert.Throws<ArgumentException>(() => h.Pack(buf));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue