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:
Erik 2026-04-11 14:38:14 +02:00
parent 44c335469a
commit 6cda431eae
2 changed files with 207 additions and 0 deletions

View file

@ -115,6 +115,104 @@ public static class PacketCodec
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>
/// Hash32 of a single fragment = Hash32(header 16 bytes) + Hash32(payload).
/// Matches ACE's ClientPacketFragment.CalculateHash32. Public so callers