From 293584d6e84e2e14e06a64efcebac85ba58bb043 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 14:14:28 +0200 Subject: [PATCH] feat(net): AcDream.Core.Net scaffold + ISAAC keystream (Phase 4.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of Phase 4 (networking). Adds a new AcDream.Core.Net project for the AC UDP protocol implementation and a matching AcDream.Core.Net.Tests project. Keeps networking isolated from rendering and the dat layer, which also keeps the AGPL-reference-material hygiene cleaner. AcDream.Core.Net/NOTICE.md documents the attribution policy: we read ACE's AGPL network code (and holtburger's Rust ac-protocol crate) to understand AC's wire format, but we reimplement everything in acdream's own style. Wire-format facts aren't copyrightable; specific code is. This commit adds one component: IsaacRandom — AC's variant of Bob Jenkins' ISAAC PRNG, used to XOR a keystream into the CRC field of every outbound packet for authentication. Clean-room reimplementation based on reading: - references/ACE/Source/ACE.Common/Cryptography/ISAAC.cs (AGPL oracle) - Bob Jenkins' public ISAAC algorithm description Implementation notes: - 256 uint32 mm[] state, 256 uint32 rsl[] output buffer, a/b/c regs - Initialize() runs 4 golden-ratio Mix() warmup rounds then two fold-in passes over rsl[] and mm[] (fresh instance → both start as zeroes) - AC variant: seed is exactly 4 bytes, interpreted as little-endian uint32 assigned to a = b = c before the first Scramble() - Scramble() produces 256 output words in one pass; Next() consumes them backwards from offset 255 → 0, re-scrambling at offset -1 - Test seed 0x12345678 matches ACE's reference output byte-for-byte across the first 16 values (golden vectors transcribed from a throwaway oracle harness that compiled ACE's ISAAC.cs and printed its output; the harness was deleted after extracting the values) Tests (5, all passing): - Next_Seed12345678_MatchesAceGoldenVectors: 16 golden uint32 values - Next_TwoInstancesSameSeed_ProduceIdenticalSequence: 1000 outputs - Next_DifferentSeeds_ProduceDifferentFirstOutput - Next_512Calls_SpansTwoScrambleBatches: >400 distinct values in 512 outputs (catches all-zero / stuck-at-one bugs at scramble boundary) - Ctor_ShortSeed_Throws Both test projects still green: 77 core + 5 net = 82/82. Phase 4.2 (packet framing + checksum) next. Co-Authored-By: Claude Opus 4.6 (1M context) --- AcDream.slnx | 2 + src/AcDream.Core.Net/AcDream.Core.Net.csproj | 10 + .../Cryptography/IsaacRandom.cs | 172 ++++++++++++++++++ src/AcDream.Core.Net/NOTICE.md | 25 +++ .../AcDream.Core.Net.Tests.csproj | 25 +++ .../Cryptography/IsaacRandomTests.cs | 79 ++++++++ 6 files changed, 313 insertions(+) create mode 100644 src/AcDream.Core.Net/AcDream.Core.Net.csproj create mode 100644 src/AcDream.Core.Net/Cryptography/IsaacRandom.cs create mode 100644 src/AcDream.Core.Net/NOTICE.md create mode 100644 tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj create mode 100644 tests/AcDream.Core.Net.Tests/Cryptography/IsaacRandomTests.cs diff --git a/AcDream.slnx b/AcDream.slnx index fa37fc2..cd884f7 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -3,11 +3,13 @@ + + diff --git a/src/AcDream.Core.Net/AcDream.Core.Net.csproj b/src/AcDream.Core.Net/AcDream.Core.Net.csproj new file mode 100644 index 0000000..68dd27c --- /dev/null +++ b/src/AcDream.Core.Net/AcDream.Core.Net.csproj @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + latest + true + AcDream.Core.Net + + diff --git a/src/AcDream.Core.Net/Cryptography/IsaacRandom.cs b/src/AcDream.Core.Net/Cryptography/IsaacRandom.cs new file mode 100644 index 0000000..2b99bce --- /dev/null +++ b/src/AcDream.Core.Net/Cryptography/IsaacRandom.cs @@ -0,0 +1,172 @@ +namespace AcDream.Core.Net.Cryptography; + +/// +/// Asheron's Call variant of Bob Jenkins' ISAAC keystream generator. AC's +/// client and server each seed an ISAAC instance with a 4-byte value shared +/// during the connect handshake, then XOR the keystream into the CRC field of +/// each outbound packet so the peer can both verify the packet is authentic +/// and recover the CRC for the real checksum comparison. +/// +/// +/// Algorithm overview (public ISAAC from Bob Jenkins, 1996): +/// +/// Internal state is 256 uint32 mm[] words plus a, +/// b, c registers and a 256-word output buffer +/// randRsl[]. +/// Initialization mixes mm[] via 4 rounds of Jenkins' golden- +/// ratio shuffle on 8-word state, then folds in the seed-derived +/// randRsl[] and mm[] words via two more passes. +/// Scramble() produces 256 output words per call, consumed in +/// reverse order (offset 255 → 0) by Next(). When the buffer +/// is drained the scramble re-runs. +/// AC variant: seed is exactly 4 bytes (the UDP-handshake ISAAC seed +/// from the server). Those 4 bytes are interpreted as a +/// little-endian uint32 and assigned to a = b = c before the +/// first scramble. The rest of mm[] and randRsl[] +/// start at zero. +/// +/// +/// +/// +/// This is a clean-room reimplementation based on reading the public +/// algorithm description and ACE's AGPL reference implementation at +/// references/ACE/Source/ACE.Common/Cryptography/ISAAC.cs. See +/// NOTICE.md for attribution. No code is copied. +/// +/// +public sealed class IsaacRandom +{ + private const int StateSize = 256; + private const uint GoldenRatio = 0x9E3779B9u; + + private readonly uint[] _mm = new uint[StateSize]; + private readonly uint[] _rsl = new uint[StateSize]; + private uint _a; + private uint _b; + private uint _c; + + /// Current position in . + /// consumes values from index _offset down to 0 then re-scrambles. + private int _offset; + + /// + /// Construct a new keystream seeded with the 4 bytes starting at + /// [0]. The bytes are interpreted as a + /// little-endian uint32 and fed into the ISAAC initialization such that + /// two instances with the same seed produce identical output sequences. + /// + public IsaacRandom(ReadOnlySpan seedBytes) + { + if (seedBytes.Length < 4) + throw new ArgumentException("seed must be at least 4 bytes", nameof(seedBytes)); + + Initialize(); + + uint seed = (uint)seedBytes[0] + | ((uint)seedBytes[1] << 8) + | ((uint)seedBytes[2] << 16) + | ((uint)seedBytes[3] << 24); + _a = _b = _c = seed; + + Scramble(); + _offset = StateSize - 1; + } + + /// + /// Return the next uint32 of the keystream. Cheap — amortized one array + /// read per call, with a scramble round every 256 calls. + /// + public uint Next() + { + uint value = _rsl[_offset]; + if (_offset > 0) + { + _offset--; + } + else + { + Scramble(); + _offset = StateSize - 1; + } + return value; + } + + /// + /// Run the Jenkins golden-ratio shuffle on the supplied 8-word state. + /// Used during initialization to stir mm[] and randRsl[]. + /// Each line is a mix step from the reference; the constants are the + /// Jenkins-published avalanche offsets. + /// + private static void Mix(Span s) + { + s[0] ^= s[1] << 11; s[3] += s[0]; s[1] += s[2]; + s[1] ^= s[2] >> 2; s[4] += s[1]; s[2] += s[3]; + s[2] ^= s[3] << 8; s[5] += s[2]; s[3] += s[4]; + s[3] ^= s[4] >> 16; s[6] += s[3]; s[4] += s[5]; + s[4] ^= s[5] << 10; s[7] += s[4]; s[5] += s[6]; + s[5] ^= s[6] >> 4; s[0] += s[5]; s[6] += s[7]; + s[6] ^= s[7] << 8; s[1] += s[6]; s[7] += s[0]; + s[7] ^= s[0] >> 9; s[2] += s[7]; s[0] += s[1]; + } + + private void Initialize() + { + // mm[] and rsl[] start as all zeroes. + Span s = stackalloc uint[8]; + for (int i = 0; i < 8; i++) s[i] = GoldenRatio; + + // 4 warmup rounds so the initial state diverges from the golden-ratio + // pattern before we start folding in real values. + for (int i = 0; i < 4; i++) Mix(s); + + // First pass folds _rsl (zeroes on a fresh instance) into mm[]. + for (int j = 0; j < StateSize; j += 8) + { + for (int k = 0; k < 8; k++) s[k] += _rsl[j + k]; + Mix(s); + for (int k = 0; k < 8; k++) _mm[j + k] = s[k]; + } + + // Second pass folds mm[] (now populated) back into itself. + for (int j = 0; j < StateSize; j += 8) + { + for (int k = 0; k < 8; k++) s[k] += _mm[j + k]; + Mix(s); + for (int k = 0; k < 8; k++) _mm[j + k] = s[k]; + } + } + + /// + /// Produce the next 256 output words into . Consumed + /// in reverse by . Each iteration rotates a + /// through one of four avalanche shifts depending on i & 3, + /// pulls an indirection from the far half of the state, and produces the + /// next output + next state word. + /// + private void Scramble() + { + _c++; + _b += _c; + + for (int i = 0; i < StateSize; i++) + { + uint x = _mm[i]; + switch (i & 3) + { + case 0: _a ^= _a << 13; break; + case 1: _a ^= _a >> 6; break; + case 2: _a ^= _a << 2; break; + case 3: _a ^= _a >> 16; break; + } + + _a += _mm[(i + 128) & 0xFF]; + + uint y = _mm[(int)((x >> 2) & 0xFF)] + _a + _b; + _mm[i] = y; + + uint nextB = _mm[(int)((y >> 10) & 0xFF)] + x; + _rsl[i] = nextB; + _b = nextB; + } + } +} diff --git a/src/AcDream.Core.Net/NOTICE.md b/src/AcDream.Core.Net/NOTICE.md new file mode 100644 index 0000000..b0e6738 --- /dev/null +++ b/src/AcDream.Core.Net/NOTICE.md @@ -0,0 +1,25 @@ +# AcDream.Core.Net — Protocol Reference Attribution + +This project implements the Asheron's Call network protocol for acdream. +The wire format is a fact about the retail AC client/server and cannot be +copyrighted, but the specific implementation details and structure conventions +are informed by reading the following open-source projects: + +- **ACE** (ACEmulator / `references/ACE/Source/ACE.Server/Network/` and + `references/ACE/Source/ACE.Common/Cryptography/ISAAC.cs`) — AGPL-3.0. + The authoritative server-side reference for packet framing, ISAAC keystream + generation, fragment reassembly, and GameMessage opcodes. + +- **ac-protocol** (holtburger's Rust crate) — AGPL-3.0. Confirms the handshake + works in practice. + +No code is copied from these sources. Algorithms are reimplemented from scratch +in acdream's own style after reading and understanding the reference. Where +golden test vectors are derived from running the reference implementations, +those vectors are factual outputs of the algorithm, not copyrighted expression, +and appear only in test code. + +If you plan to redistribute acdream outside personal use, consult a lawyer +about the interaction between our chosen license and the AGPL heritage of the +references we read. (We didn't at the time of writing — see the project-level +licensing notes.) diff --git a/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj b/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj new file mode 100644 index 0000000..88c158a --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/AcDream.Core.Net.Tests/Cryptography/IsaacRandomTests.cs b/tests/AcDream.Core.Net.Tests/Cryptography/IsaacRandomTests.cs new file mode 100644 index 0000000..9a9719c --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Cryptography/IsaacRandomTests.cs @@ -0,0 +1,79 @@ +using AcDream.Core.Net.Cryptography; + +namespace AcDream.Core.Net.Tests.Cryptography; + +public class IsaacRandomTests +{ + /// + /// Golden vectors derived from ACE's reference ISAAC implementation + /// (references/ACE/Source/ACE.Common/Cryptography/ISAAC.cs) with seed + /// bytes [0x78, 0x56, 0x34, 0x12] (= 0x12345678 as little-endian uint32). + /// Ran a throwaway oracle harness that compiles ACE's ISAAC.cs + a Main + /// that calls Next() 16 times and prints the values; the numbers below + /// are factual outputs of that algorithm, not copied code. + /// + private static readonly uint[] GoldenVectorsSeed12345678 = + { + 0xFDD4AFEBu, 0x398311D2u, 0xC71829A4u, 0xB19DF96Au, + 0x7ED4A1E7u, 0xB718A446u, 0x5FA77DF1u, 0x5CB793D2u, + 0x52C95BACu, 0xA8F8FD7Eu, 0x18582C04u, 0xD257F506u, + 0x4D11A3F0u, 0x919DE7A7u, 0x1D809C6Bu, 0xE97F65F8u, + }; + + [Fact] + public void Next_Seed12345678_MatchesAceGoldenVectors() + { + var seed = new byte[] { 0x78, 0x56, 0x34, 0x12 }; + var isaac = new IsaacRandom(seed); + + for (int i = 0; i < GoldenVectorsSeed12345678.Length; i++) + { + Assert.Equal(GoldenVectorsSeed12345678[i], isaac.Next()); + } + } + + [Fact] + public void Next_TwoInstancesSameSeed_ProduceIdenticalSequence() + { + var seed = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD }; + var a = new IsaacRandom(seed); + var b = new IsaacRandom(seed); + + for (int i = 0; i < 1000; i++) + { + Assert.Equal(a.Next(), b.Next()); + } + } + + [Fact] + public void Next_DifferentSeeds_ProduceDifferentFirstOutput() + { + var a = new IsaacRandom(new byte[] { 1, 0, 0, 0 }); + var b = new IsaacRandom(new byte[] { 2, 0, 0, 0 }); + Assert.NotEqual(a.Next(), b.Next()); + } + + [Fact] + public void Next_512Calls_SpansTwoScrambleBatches() + { + // Two full scramble rounds (256 outputs each). Tests that the + // scramble boundary at offset 0 → offset 255 doesn't emit garbage or + // repeat values trivially. + var isaac = new IsaacRandom(new byte[] { 0x01, 0x02, 0x03, 0x04 }); + var outputs = new uint[512]; + for (int i = 0; i < 512; i++) outputs[i] = isaac.Next(); + + // Not a statistical test — just catch obvious bugs like "all zero" + // or "stuck at one value". A healthy PRNG produces far more than + // 100 distinct values in 512 calls. + var distinct = new HashSet(outputs); + Assert.True(distinct.Count > 400, + $"Expected >400 distinct values in 512 outputs, got {distinct.Count}"); + } + + [Fact] + public void Ctor_ShortSeed_Throws() + { + Assert.Throws(() => new IsaacRandom(new byte[] { 1, 2, 3 })); + } +}