Brings the codec to end-to-end: a raw UDP datagram goes in, a parsed
Packet comes out with verified CRC (both plain and ISAAC-encrypted
variants). Synthetic packets built inside tests round-trip through
TryDecode cleanly.
Added:
- Packets/PacketHeaderOptional.cs: parses every flag-gated section
that lives between the 20-byte header and the body fragments —
AckSequence, RequestRetransmit (with count + array), RejectRetransmit,
ServerSwitch, LoginRequest (tail slurp), WorldLoginRequest,
ConnectResponse, CICMDCommand, TimeSync (double), EchoRequest (float),
Flow (FlowBytes + FlowInterval). Records the raw consumed bytes into
RawBytes so CalculateHash32 can hash them verbatim — AC's CRC requires
hashing the optional section separately from the main header and the
fragments.
- Packets/Packet.cs: a record type bundling Header, Optional, Fragments,
and the raw body bytes. Produced by the decoder, consumed by downstream
handlers in Phase 4.5.
- Packets/PacketCodec.cs: TryDecode(datagram, isaac?) that
1. Unpacks the header,
2. Bounds-checks DataSize against the buffer,
3. Parses the optional section,
4. If BlobFragments is set, walks the body tail as back-to-back
MessageFragment.TryParse calls,
5. Computes headerHash + optionalHash + fragmentHash,
6. Verifies CRC:
- Unencrypted: sum equals header.Checksum
- Encrypted: (header.Checksum - headerHash) XOR payloadHash must
equal the next ISAAC keystream word (which is consumed on match)
Returns a PacketDecodeResult(Packet?, DecodeError) so callers can log
and drop malformed packets instead of throwing.
- Public helper PacketCodec.CalculateFragmentHash32 so tests (and later
the encode path) can reuse the fragment-hash math.
Tests (7 new, 44 total in net project, 121 across both test projects):
- Minimal valid packet with AckSequence optional, no fragments, plain
checksum — verifies optional parse + CRC accept
- Wrong checksum rejected
- Buffer shorter than header → TooShort
- Header DataSize > buffer → HeaderSizeExceedsBuffer
- Packet with BlobFragments flag + one fragment: parses fragment and
validates the full headerHash + fragmentHash equals wire checksum
- Encrypted checksum ROUND TRIP: two ISAAC instances with same seed,
one encodes the checksum key, one decodes — validates the
(Header.Checksum - headerHash) XOR payloadHash == isaacNext contract
byte-for-byte
- Encrypted checksum with wrong key on the wire → rejected
Known limitation: the parser advances past WorldLoginRequest and
ConnectResponse their full 8 bytes whereas ACE "peeks" them (seek/reset).
The on-wire byte count is the same, only the read-position behavior
differs; any consumer that wanted to re-read those sections can do so
from Packet.BodyBytes.
Phase 4.5 (NetClient UDP pump + handshake state machine) next.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ports the fragment layer of the AC UDP protocol. A UDP packet's body is
zero or more message fragments back-to-back; a logical GameMessage that
doesn't fit in ~448 bytes gets split across multiple fragments sharing
the same Id with differing Index values. The assembler handles
reassembly across arbitrary arrival ordering and duplicate fragments.
Added (all reimplemented from ACE's AGPL reference, see NOTICE.md):
- Packets/MessageFragmentHeader.cs: 16-byte fragment header struct
with Pack/Unpack, constants for MaxFragmentSize (464) and
MaxFragmentDataSize (448). Bit-layout doc comment documents what
each field is for.
- Packets/MessageFragment.cs: readonly record struct bundling a
header with its payload bytes; TryParse(source) parses one fragment
from the start of a buffer and returns (fragment, consumed) for
incremental parsing of multi-fragment packets. Refuses to parse
fragments with impossible TotalSize (too small for header, too
large for the 464-byte max, or larger than the source buffer).
- Packets/FragmentAssembler.cs: buffers partial messages keyed by
fragment Id. Ingest(frag, out queue) returns the assembled byte[]
when the last fragment arrives, null while still waiting. Key
correctness properties, all tested:
* Single-fragment (Count=1) shortcut releases with no buffering
* Out-of-order arrival (e.g. 2, 0, 1) releases on last arrival
and assembles in INDEX order, not arrival order
* Duplicate-fragment idempotence (re-sending same index is a no-op)
* Missing fragments stay buffered; DropAll() forcibly clears them
* Two independent messages can be assembled in parallel without
interfering
* messageQueue captured from first-arriving fragment (it's a
property of the logical message, not individual fragments)
Tests (17 new, 37 total in net project, 114 across both test projects):
- MessageFragmentHeader (4): pack/unpack round-trip, little-endian
wire format, constants, size-check throw
- MessageFragment (6): complete parse, insufficient header, oversized
TotalSize, undersized TotalSize, incomplete body, two-back-to-back
incremental parse
- FragmentAssembler (7): single-fragment, in-order 3-fragment,
out-of-order 3-fragment (tests index-order assembly), duplicate
idempotence, missing-fragment buffered, two parallel messages,
DropAll
Phase 4.4 (GameMessage reader + opcode handlers) next.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the 20-byte AC UDP packet header struct + pack/unpack + its
checksum helper, and the Hash32 primitive the checksum uses.
Hash32 (Cryptography/Hash32.cs):
- Seeds accumulator with length << 16
- Sums input as little-endian uint32s word-aligned
- Folds any trailing 1-3 bytes via descending shift (24 → 16 → 8)
- Hand-computed golden values for 4-byte, 5-byte, and each 1/2/3
tail-byte case — no oracle needed, algorithm is simple enough to
verify by tracing
PacketHeader (Packets/PacketHeader.cs):
- Pack/Unpack: Sequence, Flags, Checksum, Id, Time, DataSize, Iteration
(20 bytes, little-endian on the wire)
- CalculateHeaderHash32: substitutes the 0xBADD70DD sentinel for the
Checksum field before hashing (matches AC retail + ACE convention —
without it the checksum would chicken-and-egg on itself). Uses a
local struct copy so the real Checksum isn't mutated on the caller.
- HasFlag for bitmask queries
PacketHeaderFlags (Packets/PacketHeaderFlags.cs):
- Full flag enum from ACE reference: Retransmission, EncryptedChecksum,
BlobFragments, ServerSwitch, ConnectRequest/Response, LoginRequest,
AckSequence, TimeSync, Disconnect, NetError, EchoRequest/Response,
Flow, and friends
Tests (15 new, 20 total in net project, 97 across both projects):
Hash32 (7):
- Empty returns 0
- 4-byte known value (hand-computed from bit layout)
- 5-byte value with one tail byte
- 1/2/3 tail-byte boundary cases (verifies 24/16/8 shift ordering)
- Determinism
PacketHeader (8):
- Pack/Unpack round-trip preserving all 7 fields
- Pack writes little-endian wire format in byte order
- HasFlag single and multi-bit
- CalculateHeaderHash32 invariance under Checksum field changes
(the critical property — verifies the BADD sentinel substitution)
- CalculateHeaderHash32 doesn't mutate
- CalculateHeaderHash32 determinism
- Unpack/Pack size-check throw
User confirmed an ACE server is running on localhost for the future
Phase 4.6 live integration step. Credentials will be read from env
vars at runtime, never committed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>