feat(net): AcDream.Core.Net scaffold + ISAAC keystream (Phase 4.1)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:14:28 +02:00
parent e0dfecdf23
commit 293584d6e8
6 changed files with 313 additions and 0 deletions

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>AcDream.Core.Net</RootNamespace>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,172 @@
namespace AcDream.Core.Net.Cryptography;
/// <summary>
/// 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.
///
/// <para>
/// Algorithm overview (public ISAAC from Bob Jenkins, 1996):
/// <list type="number">
/// <item>Internal state is 256 uint32 <c>mm[]</c> words plus <c>a</c>,
/// <c>b</c>, <c>c</c> registers and a 256-word output buffer
/// <c>randRsl[]</c>.</item>
/// <item>Initialization mixes <c>mm[]</c> via 4 rounds of Jenkins' golden-
/// ratio shuffle on 8-word state, then folds in the seed-derived
/// <c>randRsl[]</c> and <c>mm[]</c> words via two more passes.</item>
/// <item><c>Scramble()</c> produces 256 output words per call, consumed in
/// reverse order (offset 255 → 0) by <c>Next()</c>. When the buffer
/// is drained the scramble re-runs.</item>
/// <item>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 <c>a = b = c</c> before the
/// first scramble. The rest of <c>mm[]</c> and <c>randRsl[]</c>
/// start at zero.</item>
/// </list>
/// </para>
///
/// <para>
/// This is a clean-room reimplementation based on reading the public
/// algorithm description and ACE's AGPL reference implementation at
/// <c>references/ACE/Source/ACE.Common/Cryptography/ISAAC.cs</c>. See
/// <c>NOTICE.md</c> for attribution. No code is copied.
/// </para>
/// </summary>
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;
/// <summary>Current position in <see cref="_rsl"/>. <see cref="Next"/>
/// consumes values from index <c>_offset</c> down to 0 then re-scrambles.</summary>
private int _offset;
/// <summary>
/// Construct a new keystream seeded with the 4 bytes starting at
/// <paramref name="seedBytes"/>[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.
/// </summary>
public IsaacRandom(ReadOnlySpan<byte> 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;
}
/// <summary>
/// Return the next uint32 of the keystream. Cheap — amortized one array
/// read per call, with a scramble round every 256 calls.
/// </summary>
public uint Next()
{
uint value = _rsl[_offset];
if (_offset > 0)
{
_offset--;
}
else
{
Scramble();
_offset = StateSize - 1;
}
return value;
}
/// <summary>
/// Run the Jenkins golden-ratio shuffle on the supplied 8-word state.
/// Used during initialization to stir <c>mm[]</c> and <c>randRsl[]</c>.
/// Each line is a mix step from the reference; the constants are the
/// Jenkins-published avalanche offsets.
/// </summary>
private static void Mix(Span<uint> 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<uint> 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];
}
}
/// <summary>
/// Produce the next 256 output words into <see cref="_rsl"/>. Consumed
/// in reverse by <see cref="Next"/>. Each iteration rotates <c>a</c>
/// through one of four avalanche shifts depending on <c>i &amp; 3</c>,
/// pulls an indirection from the far half of the state, and produces the
/// next output + next state word.
/// </summary>
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;
}
}
}

View file

@ -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.)