feat(net): PacketCodec.Encode — full outbound datagram assembly
Completes the encode side of the codec so acdream can stop hand-
assembling outbound packets in tests. Given a PacketHeader (with Flags
set, DataSize ignored/overwritten) and a body byte span, Encode:
1. Overwrites header.DataSize with body.Length
2. Parses the optional section out of the body (reusing
PacketHeaderOptional.Parse as a length measurer) and hashes those bytes
3. If BlobFragments is set, walks the body tail as back-to-back
fragments and sums their Hash32s
4. For unencrypted: header.Checksum = headerHash + optionalHash + fragmentHash
5. For EncryptedChecksum: pulls one ISAAC keystream word and computes
header.Checksum = headerHash + (isaacKey XOR payloadHash)
6. Packs header + body into the final datagram
Tests (6 new, 67 total in net project, 144 across both test projects):
- Unencrypted round-trip: Encode then TryDecode recovers the AckSequence
field
- DataSize is overwritten (caller can pass garbage)
- Encrypted round-trip: two ISAACs with same seed, one encoding and
one decoding, both agree on the keystream word
- Encrypted but no ISAAC → throws InvalidOperationException
- LoginRequest end-to-end: LoginRequest.Build → Encode → TryDecode →
LoginRequest.Parse round-trips credentials exactly. This is the
single most important integration test for the outbound side —
every byte this exercises is exactly what acdream will put on the
wire when Phase 4.6 goes live.
- BlobFragments body with one embedded fragment: Encode preserves
the fragment and fragmentHash is correctly folded into the checksum
Codec is now complete end-to-end (decode + encode) and has the
LoginRequest outbound path proven against its own decoder. The next
commit will wire NetClient over real UDP sockets and connect to the
localhost ACE server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
44c335469a
commit
6cda431eae
2 changed files with 207 additions and 0 deletions
|
|
@ -115,6 +115,104 @@ public static class PacketCodec
|
||||||
return new PacketDecodeResult(packet, DecodeError.None);
|
return new PacketDecodeResult(packet, DecodeError.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assemble a datagram from a header and an optional-section body.
|
||||||
|
/// Computes the checksum (both unencrypted and ISAAC-encrypted forms)
|
||||||
|
/// and writes it into the header before packing.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Callers pass a mutable <paramref name="header"/> because
|
||||||
|
/// <c>Checksum</c> and <c>DataSize</c> are filled in by this method.
|
||||||
|
/// <paramref name="body"/> is the packet payload that goes after the
|
||||||
|
/// 20-byte header — for a LoginRequest packet this is the
|
||||||
|
/// <see cref="LoginRequest.Build"/> output; for an AckSequence packet
|
||||||
|
/// this is a 4-byte ack sequence number; and so on.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// For packets with fragments (<see cref="PacketHeaderFlags.BlobFragments"/>),
|
||||||
|
/// the caller is responsible for having pre-serialized the fragment
|
||||||
|
/// sequence into <paramref name="body"/>. A future phase can add a
|
||||||
|
/// higher-level helper that packs fragments for you.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="header">
|
||||||
|
/// Header fields the caller wants. The Checksum and DataSize fields
|
||||||
|
/// are overwritten by this method — any values the caller set there
|
||||||
|
/// are discarded.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="body">
|
||||||
|
/// Bytes that go between the 20-byte header and the end of the
|
||||||
|
/// datagram. May be empty.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="outboundIsaac">
|
||||||
|
/// ISAAC keystream used to encrypt the checksum if the header's
|
||||||
|
/// <see cref="PacketHeaderFlags.EncryptedChecksum"/> flag is set.
|
||||||
|
/// Null means unencrypted; will throw if the flag is set but no ISAAC
|
||||||
|
/// is provided.
|
||||||
|
/// </param>
|
||||||
|
public static byte[] Encode(PacketHeader header, ReadOnlySpan<byte> body, IsaacRandom? outboundIsaac)
|
||||||
|
{
|
||||||
|
header.DataSize = checked((ushort)body.Length);
|
||||||
|
|
||||||
|
// Parse the optional-section length out of the body so we can hash
|
||||||
|
// it separately from any subsequent fragments. Without the BlobFragments
|
||||||
|
// flag, the entire body IS the optional section. With BlobFragments,
|
||||||
|
// we need to know where the optional section ends.
|
||||||
|
//
|
||||||
|
// We don't actually need to parse it field-by-field — we just need to
|
||||||
|
// know how many bytes the optional section consumed. For encode we
|
||||||
|
// cheat: we ask the caller to give us the full body and hash it as
|
||||||
|
// (optional = all body bytes that come before the first fragment
|
||||||
|
// header). Since the caller built the body, they can tell us.
|
||||||
|
//
|
||||||
|
// Simpler approach for now: use PacketHeaderOptional.Parse to measure.
|
||||||
|
// This is slightly redundant work but correct and lets the existing
|
||||||
|
// Packet types stay as the single source of truth on section layout.
|
||||||
|
var optional = new PacketHeaderOptional();
|
||||||
|
int optionalLen = optional.Parse(body, header.Flags);
|
||||||
|
if (optionalLen < 0)
|
||||||
|
throw new ArgumentException("body's optional section is malformed", nameof(body));
|
||||||
|
|
||||||
|
uint optionalHash = Hash32.Calculate(body.Slice(0, optionalLen));
|
||||||
|
|
||||||
|
// Hash any fragments in the body tail.
|
||||||
|
uint fragmentHash = 0;
|
||||||
|
if (header.HasFlag(PacketHeaderFlags.BlobFragments))
|
||||||
|
{
|
||||||
|
var tail = body.Slice(optionalLen);
|
||||||
|
while (tail.Length > 0)
|
||||||
|
{
|
||||||
|
var (frag, consumed) = MessageFragment.TryParse(tail);
|
||||||
|
if (frag is null || consumed == 0)
|
||||||
|
throw new ArgumentException("body contains a malformed fragment", nameof(body));
|
||||||
|
fragmentHash += CalculateFragmentHash32(frag.Value);
|
||||||
|
tail = tail.Slice(consumed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint headerHash = header.CalculateHeaderHash32();
|
||||||
|
uint payloadHash = optionalHash + fragmentHash;
|
||||||
|
|
||||||
|
if (header.HasFlag(PacketHeaderFlags.EncryptedChecksum))
|
||||||
|
{
|
||||||
|
if (outboundIsaac is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"EncryptedChecksum flag set but no ISAAC keystream provided");
|
||||||
|
uint isaacKey = outboundIsaac.Next();
|
||||||
|
header.Checksum = headerHash + (isaacKey ^ payloadHash);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
header.Checksum = headerHash + payloadHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
||||||
|
header.Pack(datagram);
|
||||||
|
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
||||||
|
return datagram;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hash32 of a single fragment = Hash32(header 16 bytes) + Hash32(payload).
|
/// Hash32 of a single fragment = Hash32(header 16 bytes) + Hash32(payload).
|
||||||
/// Matches ACE's ClientPacketFragment.CalculateHash32. Public so callers
|
/// Matches ACE's ClientPacketFragment.CalculateHash32. Public so callers
|
||||||
|
|
|
||||||
109
tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs
Normal file
109
tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
using AcDream.Core.Net.Cryptography;
|
||||||
|
using AcDream.Core.Net.Packets;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Packets;
|
||||||
|
|
||||||
|
public class PacketCodecEncodeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Encode_Then_TryDecode_UnencryptedChecksum_RoundTrips()
|
||||||
|
{
|
||||||
|
// 4-byte ack sequence in the optional section, no fragments.
|
||||||
|
byte[] body = new byte[4] { 0x78, 0x56, 0x34, 0x12 };
|
||||||
|
var header = new PacketHeader
|
||||||
|
{
|
||||||
|
Sequence = 7,
|
||||||
|
Flags = PacketHeaderFlags.AckSequence,
|
||||||
|
Id = 1,
|
||||||
|
Time = 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
byte[] datagram = PacketCodec.Encode(header, body, outboundIsaac: null);
|
||||||
|
|
||||||
|
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||||
|
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
|
||||||
|
Assert.Equal(0x12345678u, result.Packet!.Optional.AckSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_DataSizeIsOverwritten_MatchesBodyLength()
|
||||||
|
{
|
||||||
|
// Caller sets DataSize to garbage; Encode should overwrite it with
|
||||||
|
// the actual body length.
|
||||||
|
var header = new PacketHeader { Flags = PacketHeaderFlags.AckSequence, DataSize = 9999 };
|
||||||
|
byte[] body = new byte[4];
|
||||||
|
byte[] datagram = PacketCodec.Encode(header, body, null);
|
||||||
|
|
||||||
|
var parsed = PacketHeader.Unpack(datagram);
|
||||||
|
Assert.Equal(4, parsed.DataSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_EncryptedChecksum_PairsWithDecodeUsingSameIsaac()
|
||||||
|
{
|
||||||
|
var seed = new byte[] { 0xAB, 0xCD, 0xEF, 0x01 };
|
||||||
|
var isaacSend = new IsaacRandom(seed);
|
||||||
|
var isaacRecv = new IsaacRandom(seed);
|
||||||
|
|
||||||
|
byte[] body = new byte[4] { 1, 2, 3, 4 };
|
||||||
|
var header = new PacketHeader
|
||||||
|
{
|
||||||
|
Flags = PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum,
|
||||||
|
};
|
||||||
|
|
||||||
|
byte[] datagram = PacketCodec.Encode(header, body, isaacSend);
|
||||||
|
var result = PacketCodec.TryDecode(datagram, isaacRecv);
|
||||||
|
|
||||||
|
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_EncryptedChecksum_WithoutIsaac_Throws()
|
||||||
|
{
|
||||||
|
var header = new PacketHeader { Flags = PacketHeaderFlags.EncryptedChecksum };
|
||||||
|
Assert.Throws<InvalidOperationException>(
|
||||||
|
() => PacketCodec.Encode(header, ReadOnlySpan<byte>.Empty, outboundIsaac: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_LoginRequest_DecodableAndBodyRoundTrips()
|
||||||
|
{
|
||||||
|
// End-to-end that exercises the whole outbound pipeline we care
|
||||||
|
// about for integration testing: build LoginRequest body → pack
|
||||||
|
// into a Packet with the LoginRequest flag → send through Encode →
|
||||||
|
// decode via TryDecode → re-parse the body back to credentials.
|
||||||
|
byte[] loginPayload = LoginRequest.Build("testaccount", "testpassword", 42);
|
||||||
|
var header = new PacketHeader { Flags = PacketHeaderFlags.LoginRequest };
|
||||||
|
|
||||||
|
byte[] datagram = PacketCodec.Encode(header, loginPayload, outboundIsaac: null);
|
||||||
|
|
||||||
|
var decoded = PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||||
|
Assert.Equal(PacketCodec.DecodeError.None, decoded.Error);
|
||||||
|
var parsed = LoginRequest.Parse(decoded.Packet!.BodyBytes);
|
||||||
|
Assert.Equal("testaccount", parsed.Account);
|
||||||
|
Assert.Equal("testpassword", parsed.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_FragmentBodyWithBlobFragmentsFlag_DecodeRebuildsFragment()
|
||||||
|
{
|
||||||
|
// Build a fragment body (16-byte header + 3-byte payload), set
|
||||||
|
// BlobFragments flag, encode, decode, verify fragment is intact.
|
||||||
|
var fragHeader = new MessageFragmentHeader
|
||||||
|
{
|
||||||
|
Sequence = 1, Id = 0x80000001u, Count = 1,
|
||||||
|
TotalSize = 19, Index = 0, Queue = 0,
|
||||||
|
};
|
||||||
|
byte[] body = new byte[19];
|
||||||
|
fragHeader.Pack(body);
|
||||||
|
body[16] = 0xAA; body[17] = 0xBB; body[18] = 0xCC;
|
||||||
|
|
||||||
|
var header = new PacketHeader { Flags = PacketHeaderFlags.BlobFragments };
|
||||||
|
byte[] datagram = PacketCodec.Encode(header, body, null);
|
||||||
|
|
||||||
|
var result = PacketCodec.TryDecode(datagram, null);
|
||||||
|
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
|
||||||
|
Assert.Single(result.Packet!.Fragments);
|
||||||
|
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, result.Packet.Fragments[0].Payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue