diff --git a/src/AcDream.Core.Net/Packets/PacketCodec.cs b/src/AcDream.Core.Net/Packets/PacketCodec.cs
index 615c729..b3cd209 100644
--- a/src/AcDream.Core.Net/Packets/PacketCodec.cs
+++ b/src/AcDream.Core.Net/Packets/PacketCodec.cs
@@ -115,6 +115,104 @@ public static class PacketCodec
return new PacketDecodeResult(packet, DecodeError.None);
}
+ ///
+ /// 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.
+ ///
+ ///
+ /// Callers pass a mutable because
+ /// Checksum and DataSize are filled in by this method.
+ /// is the packet payload that goes after the
+ /// 20-byte header — for a LoginRequest packet this is the
+ /// output; for an AckSequence packet
+ /// this is a 4-byte ack sequence number; and so on.
+ ///
+ ///
+ ///
+ /// For packets with fragments (),
+ /// the caller is responsible for having pre-serialized the fragment
+ /// sequence into . A future phase can add a
+ /// higher-level helper that packs fragments for you.
+ ///
+ ///
+ ///
+ /// Header fields the caller wants. The Checksum and DataSize fields
+ /// are overwritten by this method — any values the caller set there
+ /// are discarded.
+ ///
+ ///
+ /// Bytes that go between the 20-byte header and the end of the
+ /// datagram. May be empty.
+ ///
+ ///
+ /// ISAAC keystream used to encrypt the checksum if the header's
+ /// flag is set.
+ /// Null means unencrypted; will throw if the flag is set but no ISAAC
+ /// is provided.
+ ///
+ public static byte[] Encode(PacketHeader header, ReadOnlySpan 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;
+ }
+
///
/// Hash32 of a single fragment = Hash32(header 16 bytes) + Hash32(payload).
/// Matches ACE's ClientPacketFragment.CalculateHash32. Public so callers
diff --git a/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs
new file mode 100644
index 0000000..aa9aa74
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecEncodeTests.cs
@@ -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(
+ () => PacketCodec.Encode(header, ReadOnlySpan.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);
+ }
+}