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:
parent
e0dfecdf23
commit
293584d6e8
6 changed files with 313 additions and 0 deletions
|
|
@ -3,11 +3,13 @@
|
|||
<Project Path="src/AcDream.App/AcDream.App.csproj" />
|
||||
<Project Path="src/AcDream.Cli/AcDream.Cli.csproj" />
|
||||
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
||||
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
||||
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
||||
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
||||
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
|
|
|||
10
src/AcDream.Core.Net/AcDream.Core.Net.csproj
Normal file
10
src/AcDream.Core.Net/AcDream.Core.Net.csproj
Normal 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>
|
||||
172
src/AcDream.Core.Net/Cryptography/IsaacRandom.cs
Normal file
172
src/AcDream.Core.Net/Cryptography/IsaacRandom.cs
Normal 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 & 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/AcDream.Core.Net/NOTICE.md
Normal file
25
src/AcDream.Core.Net/NOTICE.md
Normal 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.)
|
||||
25
tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj
Normal file
25
tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.Core.Net\AcDream.Core.Net.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
using AcDream.Core.Net.Cryptography;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Cryptography;
|
||||
|
||||
public class IsaacRandomTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<uint>(outputs);
|
||||
Assert.True(distinct.Count > 400,
|
||||
$"Expected >400 distinct values in 512 outputs, got {distinct.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_ShortSeed_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new IsaacRandom(new byte[] { 1, 2, 3 }));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue