diff --git a/src/AcDream.Core.Net/Messages/CharacterEnterWorld.cs b/src/AcDream.Core.Net/Messages/CharacterEnterWorld.cs
new file mode 100644
index 0000000..dfe46cb
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/CharacterEnterWorld.cs
@@ -0,0 +1,56 @@
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Outbound GameMessages for the character-select → in-world transition.
+/// The client sends two messages in order after receiving
+/// :
+///
+///
+/// - CharacterEnterWorldRequest (opcode 0xF7C8) —
+/// empty body beyond the 4-byte opcode. "Hi server, I'm about
+/// to try to enter the world, anything I should know?"
+/// - Server replies with
+/// CharacterEnterWorldServerReady (0xF7DF).
+/// - CharacterEnterWorld (opcode 0xF657) — u32 GUID
+/// of the chosen character + String16L account name. Server
+/// validates ownership and begins spawning the player + world
+/// entities into our session, which is where the CreateObject
+/// flood starts.
+///
+///
+/// Both messages are tiny enough to fit in one fragment each. They go on
+/// the queue per ACE's
+/// CharacterHandler annotations.
+///
+public static class CharacterEnterWorld
+{
+ public const uint EnterWorldRequestOpcode = 0xF7C8u;
+ public const uint EnterWorldOpcode = 0xF657u;
+
+ ///
+ /// Build the body bytes for an outbound CharacterEnterWorldRequest.
+ /// Just the 4-byte opcode, nothing else.
+ ///
+ public static byte[] BuildEnterWorldRequestBody()
+ {
+ var w = new PacketWriter(8);
+ w.WriteUInt32(EnterWorldRequestOpcode);
+ return w.ToArray();
+ }
+
+ ///
+ /// Build the body bytes for an outbound CharacterEnterWorld.
+ /// Layout: opcode(4) + characterGuid(4) + String16L(accountName).
+ ///
+ public static byte[] BuildEnterWorldBody(uint characterGuid, string accountName)
+ {
+ ArgumentNullException.ThrowIfNull(accountName);
+ var w = new PacketWriter(32);
+ w.WriteUInt32(EnterWorldOpcode);
+ w.WriteUInt32(characterGuid);
+ w.WriteString16L(accountName);
+ return w.ToArray();
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/CharacterList.cs b/src/AcDream.Core.Net/Messages/CharacterList.cs
new file mode 100644
index 0000000..6fc3af1
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/CharacterList.cs
@@ -0,0 +1,102 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Inbound CharacterList GameMessage (opcode 0xF658).
+/// The server sends one of these right after ConnectResponse completes
+/// the handshake — it's the client's "pick which character to log in"
+/// data. Contains one entry per character on the account plus account-
+/// level settings (slot count, Turbine chat toggle, ToD flag).
+///
+///
+/// Wire layout (ported from ACE's GameMessageCharacterList.cs
+/// writer — see NOTICE.md for attribution):
+///
+///
+///
+/// u32 0 (leading padding, always 0)
+/// u32 characterCount
+/// for each character:
+/// u32 characterId (GUID)
+/// String16L name (prefixed with '+' for admin chars)
+/// u32 deleteTimeDelta (0 if not scheduled for deletion)
+/// u32 0 (trailing padding)
+/// u32 slotCount (max characters per account)
+/// String16L accountName
+/// u32 useTurbineChat (bool)
+/// u32 hasThroneOfDestiny (bool — always 1 on retail-era ACE)
+///
+///
+public static class CharacterList
+{
+ public const uint Opcode = 0xF658u;
+
+ public readonly record struct Character(uint Id, string Name, uint DeleteTimeDelta);
+
+ public sealed record Parsed(
+ IReadOnlyList Characters,
+ uint SlotCount,
+ string AccountName,
+ bool UseTurbineChat,
+ bool HasThroneOfDestiny);
+
+ ///
+ /// Parse a CharacterList body. must start with
+ /// the 4-byte opcode (0xF658) — i.e. pass the full reassembled
+ /// GameMessage output from .
+ ///
+ public static Parsed Parse(ReadOnlySpan body)
+ {
+ int pos = 0;
+
+ uint opcode = ReadU32(body, ref pos);
+ if (opcode != Opcode)
+ throw new FormatException($"expected CharacterList opcode 0x{Opcode:X4}, got 0x{opcode:X8}");
+
+ _ = ReadU32(body, ref pos); // leading 0
+ uint count = ReadU32(body, ref pos);
+ if (count > 255)
+ throw new FormatException($"character count {count} exceeds sanity limit");
+
+ var characters = new Character[count];
+ for (int i = 0; i < count; i++)
+ {
+ uint id = ReadU32(body, ref pos);
+ string name = ReadString16L(body, ref pos);
+ uint deleteDelta = ReadU32(body, ref pos);
+ characters[i] = new Character(id, name, deleteDelta);
+ }
+
+ _ = ReadU32(body, ref pos); // trailing 0
+ uint slotCount = ReadU32(body, ref pos);
+ string accountName = ReadString16L(body, ref pos);
+ bool useTurbineChat = ReadU32(body, ref pos) != 0;
+ bool hasThroneOfDestiny = ReadU32(body, ref pos) != 0;
+
+ return new Parsed(characters, slotCount, accountName, useTurbineChat, hasThroneOfDestiny);
+ }
+
+ private static uint ReadU32(ReadOnlySpan source, ref int pos)
+ {
+ if (source.Length - pos < 4) throw new FormatException("truncated u32");
+ uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
+ pos += 4;
+ return value;
+ }
+
+ private static string ReadString16L(ReadOnlySpan source, ref int pos)
+ {
+ if (source.Length - pos < 2) throw new FormatException("truncated String16L length");
+ ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
+ pos += 2;
+ if (source.Length - pos < length) throw new FormatException("truncated String16L body");
+ string result = Encoding.ASCII.GetString(source.Slice(pos, length));
+ pos += length;
+ int recordSize = 2 + length;
+ int padding = (4 - (recordSize & 3)) & 3;
+ pos += padding;
+ return result;
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/GameMessageFragment.cs b/src/AcDream.Core.Net/Messages/GameMessageFragment.cs
new file mode 100644
index 0000000..407451a
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/GameMessageFragment.cs
@@ -0,0 +1,89 @@
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Helper to wrap a single GameMessage (opcode + body) in one
+/// . Only handles the common "small message
+/// fits in one fragment" case — the >448-byte multi-fragment split is
+/// deferred until we actually need it for outbound traffic.
+///
+///
+/// Callers build the message body via
+/// (starting with a 4-byte opcode, then the fields), pick a
+/// for the queue, pick a fragment sequence
+/// number for this message, and receive a fully-formed
+/// ready to embed in a packet body.
+///
+///
+public static class GameMessageFragment
+{
+ ///
+ /// Constant Id used on every outbound fragment. ACE's server-side
+ /// code uses the same literal — the per-message uniqueness lives in
+ /// the Sequence field, not the Id field.
+ ///
+ public const uint OutboundFragmentId = 0x80000000u;
+
+ ///
+ /// Build a single fragment that carries an entire GameMessage in its
+ /// payload. Throws if the payload exceeds the max single-fragment
+ /// body size (448 bytes).
+ ///
+ public static MessageFragment BuildSingleFragment(
+ uint fragmentSequence,
+ GameMessageGroup queue,
+ ReadOnlySpan gameMessageBytes)
+ {
+ if (gameMessageBytes.Length > MessageFragmentHeader.MaxFragmentDataSize)
+ throw new ArgumentException(
+ $"game message body ({gameMessageBytes.Length} bytes) exceeds single-fragment capacity " +
+ $"({MessageFragmentHeader.MaxFragmentDataSize} bytes). Multi-fragment split TBD.",
+ nameof(gameMessageBytes));
+
+ var header = new MessageFragmentHeader
+ {
+ Sequence = fragmentSequence,
+ Id = OutboundFragmentId,
+ Count = 1,
+ TotalSize = (ushort)(MessageFragmentHeader.Size + gameMessageBytes.Length),
+ Index = 0,
+ Queue = (ushort)queue,
+ };
+
+ return new MessageFragment(header, gameMessageBytes.ToArray());
+ }
+
+ ///
+ /// Concatenate a fragment's header + payload into the bytes that go
+ /// into a packet's body. Use when building the full body span
+ /// passed to .
+ ///
+ public static byte[] Serialize(in MessageFragment fragment)
+ {
+ byte[] buffer = new byte[MessageFragmentHeader.Size + fragment.Payload.Length];
+ fragment.Header.Pack(buffer);
+ fragment.Payload.CopyTo(buffer.AsSpan(MessageFragmentHeader.Size));
+ return buffer;
+ }
+}
+
+///
+/// AC's per-queue routing for GameMessages. Matches ACE's GameMessageGroup
+/// enum byte-for-byte so ported handlers are unambiguous.
+///
+public enum GameMessageGroup : ushort
+{
+ InvalidQueue = 0x00,
+ EventQueue = 0x01,
+ ControlQueue = 0x02,
+ WeenieQueue = 0x03,
+ LoginQueue = 0x04,
+ DatabaseQueue = 0x05,
+ SecureControlQueue = 0x06,
+ SecureWeenieQueue = 0x07,
+ SecureLoginQueue = 0x08,
+ UIQueue = 0x09,
+ SmartboxQueue = 0x0A,
+ ObserverQueue = 0x0B,
+}
diff --git a/src/AcDream.Core.Net/Packets/FragmentAssembler.cs b/src/AcDream.Core.Net/Packets/FragmentAssembler.cs
index 6f8b554..f82fdb0 100644
--- a/src/AcDream.Core.Net/Packets/FragmentAssembler.cs
+++ b/src/AcDream.Core.Net/Packets/FragmentAssembler.cs
@@ -3,9 +3,11 @@ namespace AcDream.Core.Net.Packets;
///
/// Reassembles multi-fragment GameMessages. UDP packets can arrive in any
/// order and individual fragments within a logical message can be split
-/// across packets, so we buffer partial messages keyed by fragment Id and
-/// only yield a complete byte stream once every Count fragment for
-/// that Id has arrived.
+/// across packets, so we buffer partial messages keyed by fragment
+/// Sequence (the actual unique identifier — ACE's outbound fragment
+/// Id field is always the constant 0x80000000; the
+/// per-message-unique value is the Sequence, matching how ACE's
+/// own NetworkSession.HandleFragment keys its partialFragments dict).
///
///
/// Correctness properties:
@@ -14,11 +16,11 @@ namespace AcDream.Core.Net.Packets;
/// the full message is released on the last fragment regardless of
/// its index.
/// - Duplicate-fragment idempotence: receiving index N twice for the
-/// same Id is harmless — the second copy is silently ignored.
+/// same Sequence is harmless — the second copy is silently ignored.
/// - Single-fragment messages: Count=1 releases immediately on
/// that one fragment with no buffering.
-/// - Orphaned partials: if fragments for an Id arrive but the message
-/// never completes, they stay buffered until
+///
- Orphaned partials: if fragments for a Sequence arrive but the
+/// message never completes, they stay buffered until
/// is called or the assembler is disposed.
/// A future phase will add a TTL-based eviction.
///
@@ -56,10 +58,12 @@ public sealed class FragmentAssembler
return fragment.Payload;
}
- if (!_inFlight.TryGetValue(h.Id, out var partial))
+ // Key on Sequence, not Id — ACE's outbound Id is a constant and
+ // its own inbound assembler keys on Sequence for the same reason.
+ if (!_inFlight.TryGetValue(h.Sequence, out var partial))
{
partial = new PartialMessage(h.Count, h.Queue);
- _inFlight[h.Id] = partial;
+ _inFlight[h.Sequence] = partial;
}
// Idempotent: receiving the same index twice is not an error.
@@ -86,7 +90,7 @@ public sealed class FragmentAssembler
offset += p.Length;
}
- _inFlight.Remove(h.Id);
+ _inFlight.Remove(h.Sequence);
messageQueue = partial.Queue;
return combined;
}
diff --git a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
index 44f4258..082ce3f 100644
--- a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
+++ b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
@@ -2,6 +2,7 @@ using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net;
using AcDream.Core.Net.Cryptography;
+using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests;
@@ -256,4 +257,190 @@ public class LiveHandshakeTests
// a seed-direction mismatch or a CRC math bug this will be 0 and
// we'll see checksumFailures > 0.
}
+
+ [Fact]
+ public void Live_CharacterEnterWorld_ReceivesCreateObjectFlood()
+ {
+ if (Environment.GetEnvironmentVariable("ACDREAM_LIVE") != "1")
+ return;
+
+ var host = Environment.GetEnvironmentVariable("ACDREAM_TEST_HOST") ?? "127.0.0.1";
+ var portStr = Environment.GetEnvironmentVariable("ACDREAM_TEST_PORT") ?? "9000";
+ var user = Environment.GetEnvironmentVariable("ACDREAM_TEST_USER")!;
+ var pass = Environment.GetEnvironmentVariable("ACDREAM_TEST_PASS")!;
+
+ int loginPort = int.Parse(portStr);
+ var loginEndpoint = new IPEndPoint(IPAddress.Parse(host), loginPort);
+ var connectEndpoint = new IPEndPoint(IPAddress.Parse(host), loginPort + 1);
+ using var net = new NetClient(loginEndpoint);
+
+ // ---- Step 1-3: complete the handshake (copied from the prior test,
+ // factored into a helper would be nice but inline is clearer here). ----
+ uint timestamp = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ byte[] loginPayload = LoginRequest.Build(user, pass, timestamp);
+ var loginHeader = new PacketHeader { Flags = PacketHeaderFlags.LoginRequest };
+ net.Send(PacketCodec.Encode(loginHeader, loginPayload, null));
+ Console.WriteLine("[live] step 1: LoginRequest sent");
+
+ Packet? cr = null;
+ var d1 = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ while (DateTime.UtcNow < d1)
+ {
+ var b = net.Receive(d1 - DateTime.UtcNow, out _);
+ if (b is null) break;
+ var dec = PacketCodec.TryDecode(b, null);
+ if (dec.IsOk && dec.Packet!.Header.HasFlag(PacketHeaderFlags.ConnectRequest))
+ { cr = dec.Packet; break; }
+ }
+ Assert.NotNull(cr);
+ var opt = cr!.Optional;
+ Console.WriteLine($"[live] step 2: ConnectRequest received (cookie=0x{opt.ConnectRequestCookie:X16} clientId=0x{opt.ConnectRequestClientId:X8})");
+
+ byte[] crBody = new byte[8];
+ BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
+ var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 };
+ Thread.Sleep(200); // ACE handshake race delay
+ net.Send(connectEndpoint, PacketCodec.Encode(crHeader, crBody, null));
+ Console.WriteLine("[live] step 3: ConnectResponse sent to 9001");
+
+ // Seed the ISAAC pair. Server seed decrypts inbound; client seed encrypts outbound.
+ byte[] serverSeedBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
+ byte[] clientSeedBytes = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(clientSeedBytes, opt.ConnectRequestClientSeed);
+ var inboundIsaac = new IsaacRandom(serverSeedBytes);
+ var outboundIsaac = new IsaacRandom(clientSeedBytes);
+ ushort sessionClientId = (ushort)opt.ConnectRequestClientId;
+
+ // Packet sequence counter. LoginRequest=0, ConnectResponse=1, next=2.
+ uint clientPacketSequence = 2;
+
+ // Fragment sequence counter. Starts at 1 per holtburger's
+ // api.rs (fragment_sequence: 1, pre-increment use-then-bump).
+ uint fragmentSequence = 1;
+
+ // ---- Step 4: drain the initial CharacterList stream to find our character. ----
+ var assembler = new FragmentAssembler();
+ CharacterList.Parsed? charList = null;
+ var d4 = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ while (DateTime.UtcNow < d4 && charList is null)
+ {
+ var b = net.Receive(d4 - DateTime.UtcNow, out _);
+ if (b is null) break;
+ var dec = PacketCodec.TryDecode(b, inboundIsaac);
+ if (!dec.IsOk) continue;
+ foreach (var frag in dec.Packet!.Fragments)
+ {
+ var body = assembler.Ingest(frag, out _);
+ if (body is null || body.Length < 4) continue;
+ uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
+ if (op == CharacterList.Opcode)
+ {
+ charList = CharacterList.Parse(body);
+ Console.WriteLine($"[live] step 4: CharacterList received " +
+ $"account={charList.AccountName} count={charList.Characters.Count}");
+ foreach (var c in charList.Characters)
+ Console.WriteLine($"[live] character: id=0x{c.Id:X8} name={c.Name}");
+ }
+ }
+ }
+
+ Assert.NotNull(charList);
+ Assert.NotEmpty(charList!.Characters);
+ var chosen = charList.Characters[0];
+ Console.WriteLine($"[live] choosing character: 0x{chosen.Id:X8} {chosen.Name}");
+
+ // ---- Step 5: send CharacterEnterWorldRequest (UIQueue, encrypted checksum) ----
+ void SendGameMessage(byte[] gameMessageBody, string label)
+ {
+ var fragment = GameMessageFragment.BuildSingleFragment(
+ fragmentSequence++, GameMessageGroup.UIQueue, gameMessageBody);
+ byte[] packetBody = GameMessageFragment.Serialize(fragment);
+ var header = new PacketHeader
+ {
+ Sequence = clientPacketSequence++,
+ Flags = PacketHeaderFlags.BlobFragments | PacketHeaderFlags.EncryptedChecksum,
+ Id = sessionClientId,
+ };
+ byte[] datagram = PacketCodec.Encode(header, packetBody, outboundIsaac);
+ net.Send(datagram);
+ Console.WriteLine($"[live] sent {label}: packet.seq={header.Sequence} " +
+ $"frag.seq={fragment.Header.Sequence} bytes={datagram.Length}");
+ }
+
+ SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody(),
+ "CharacterEnterWorldRequest");
+
+ // ---- Step 6: wait for CharacterEnterWorldServerReady (0xF7DF). ----
+ bool sawServerReady = false;
+ var d6 = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ while (DateTime.UtcNow < d6 && !sawServerReady)
+ {
+ var b = net.Receive(d6 - DateTime.UtcNow, out _);
+ if (b is null) break;
+ var dec = PacketCodec.TryDecode(b, inboundIsaac);
+ if (!dec.IsOk)
+ {
+ Console.WriteLine($"[live] step 6: decode error {dec.Error}");
+ continue;
+ }
+ foreach (var frag in dec.Packet!.Fragments)
+ {
+ var body = assembler.Ingest(frag, out _);
+ if (body is null || body.Length < 4) continue;
+ uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
+ Console.WriteLine($"[live] step 6: got GameMessage opcode=0x{op:X8}");
+ if (op == 0xF7DFu)
+ {
+ sawServerReady = true;
+ Console.WriteLine("[live] step 6: CharacterEnterWorldServerReady received");
+ break;
+ }
+ }
+ }
+
+ Assert.True(sawServerReady, "Server did not send CharacterEnterWorldServerReady");
+
+ // ---- Step 7: send CharacterEnterWorld with the chosen GUID. ----
+ SendGameMessage(
+ CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, user),
+ $"CharacterEnterWorld(guid=0x{chosen.Id:X8})");
+
+ // ---- Step 8: receive the CreateObject flood. ----
+ int totalMessages = 0;
+ int createObjectCount = 0;
+ var seenOpcodes = new HashSet();
+ var d8 = DateTime.UtcNow + TimeSpan.FromSeconds(10);
+ while (DateTime.UtcNow < d8)
+ {
+ var b = net.Receive(d8 - DateTime.UtcNow, out _);
+ if (b is null) break;
+ var dec = PacketCodec.TryDecode(b, inboundIsaac);
+ if (!dec.IsOk)
+ {
+ Console.WriteLine($"[live] step 8: decode error {dec.Error} on {b.Length}-byte packet");
+ continue;
+ }
+ foreach (var frag in dec.Packet!.Fragments)
+ {
+ var body = assembler.Ingest(frag, out _);
+ if (body is null || body.Length < 4) continue;
+ uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
+ seenOpcodes.Add(op);
+ totalMessages++;
+ if (op == 0xF745u) // CreateObject
+ createObjectCount++;
+ }
+ }
+
+ Console.WriteLine($"[live] step 8 summary: {totalMessages} GameMessages assembled, " +
+ $"{createObjectCount} CreateObject");
+ Console.WriteLine("[live] unique opcodes seen: " +
+ string.Join(", ", seenOpcodes.Select(o => $"0x{o:X8}")));
+
+ // MILESTONE: if we got at least one CreateObject, the server considers
+ // us logged into the world. That's the Phase 4.7 win condition.
+ Assert.True(createObjectCount > 0,
+ $"Expected at least one CreateObject message post-login, got {createObjectCount}");
+ }
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs
new file mode 100644
index 0000000..0fa1d26
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs
@@ -0,0 +1,90 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public class CharacterEnterWorldTests
+{
+ [Fact]
+ public void BuildEnterWorldRequestBody_IsJustTheOpcode()
+ {
+ var body = CharacterEnterWorld.BuildEnterWorldRequestBody();
+
+ Assert.Equal(4, body.Length);
+ Assert.Equal(CharacterEnterWorld.EnterWorldRequestOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body));
+ }
+
+ [Fact]
+ public void BuildEnterWorldBody_Layout_OpcodeThenGuidThenAccountName()
+ {
+ var body = CharacterEnterWorld.BuildEnterWorldBody(
+ characterGuid: 0x50000001u,
+ accountName: "testaccount");
+
+ int pos = 0;
+ Assert.Equal(CharacterEnterWorld.EnterWorldOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4;
+ Assert.Equal(0x50000001u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4;
+
+ // String16L("testaccount") = u16(11), 11 ASCII bytes, pad to 4-byte
+ // boundary from start of u16: 2 + 11 = 13, padded to 16 → 3 pad bytes.
+ ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
+ Assert.Equal(11, len); pos += 2;
+ string name = System.Text.Encoding.ASCII.GetString(body.AsSpan(pos, 11));
+ Assert.Equal("testaccount", name); pos += 11;
+ // Verify padding bytes are zero and record size is aligned to 4.
+ Assert.Equal(0, body[pos++]);
+ Assert.Equal(0, body[pos++]);
+ Assert.Equal(0, body[pos++]);
+ Assert.Equal(4 + 4 + 16, body.Length); // opcode + guid + padded string
+ }
+}
+
+public class GameMessageFragmentTests
+{
+ [Fact]
+ public void BuildSingleFragment_FragmentHeaderMatchesProtocol()
+ {
+ byte[] msg = { 0x58, 0xF6, 0x00, 0x00, 0x01, 0x00, 0x00, 0x50 }; // fake opcode + 4 body bytes
+ var frag = GameMessageFragment.BuildSingleFragment(
+ fragmentSequence: 42,
+ queue: GameMessageGroup.UIQueue,
+ gameMessageBytes: msg);
+
+ Assert.Equal(42u, frag.Header.Sequence);
+ Assert.Equal(GameMessageFragment.OutboundFragmentId, frag.Header.Id);
+ Assert.Equal(1, frag.Header.Count);
+ Assert.Equal(0, frag.Header.Index);
+ Assert.Equal(24, frag.Header.TotalSize); // 16 header + 8 payload
+ Assert.Equal((ushort)GameMessageGroup.UIQueue, frag.Header.Queue);
+ Assert.Equal(msg, frag.Payload);
+ }
+
+ [Fact]
+ public void BuildSingleFragment_OversizeBody_Throws()
+ {
+ var big = new byte[MessageFragmentHeader.MaxFragmentDataSize + 1];
+ Assert.Throws(
+ () => GameMessageFragment.BuildSingleFragment(0, GameMessageGroup.UIQueue, big));
+ }
+
+ [Fact]
+ public void Serialize_Then_MessageFragment_TryParse_RoundTrips()
+ {
+ byte[] msg = { 0x01, 0x02, 0x03, 0x04, 0x05 };
+ var original = GameMessageFragment.BuildSingleFragment(
+ fragmentSequence: 7, queue: GameMessageGroup.UIQueue, gameMessageBytes: msg);
+
+ byte[] serialized = GameMessageFragment.Serialize(original);
+ var (reparsed, consumed) = MessageFragment.TryParse(serialized);
+
+ Assert.NotNull(reparsed);
+ Assert.Equal(serialized.Length, consumed);
+ Assert.Equal(original.Header.Sequence, reparsed!.Value.Header.Sequence);
+ Assert.Equal(original.Header.Count, reparsed.Value.Header.Count);
+ Assert.Equal(original.Payload, reparsed.Value.Payload);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs
new file mode 100644
index 0000000..84c58e6
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs
@@ -0,0 +1,87 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public class CharacterListTests
+{
+ [Fact]
+ public void Parse_SingleCharacter_ExtractsGuidAndName()
+ {
+ // Hand-assemble a CharacterList body matching ACE's writer format:
+ // u32 opcode, u32 0, u32 count=1,
+ // u32 guid, String16L name, u32 deleteDelta,
+ // u32 0, u32 slotCount, String16L accountName,
+ // u32 useTurbineChat, u32 hasToD
+ var w = new PacketWriter(128);
+ w.WriteUInt32(CharacterList.Opcode);
+ w.WriteUInt32(0);
+ w.WriteUInt32(1); // count
+ w.WriteUInt32(0x50000001u);
+ w.WriteString16L("+Acdream");
+ w.WriteUInt32(0); // deleteDelta
+ w.WriteUInt32(0);
+ w.WriteUInt32(11); // slotCount
+ w.WriteString16L("testaccount");
+ w.WriteUInt32(1); // useTurbineChat
+ w.WriteUInt32(1); // hasToD
+
+ var parsed = CharacterList.Parse(w.ToArray());
+
+ Assert.Single(parsed.Characters);
+ Assert.Equal(0x50000001u, parsed.Characters[0].Id);
+ Assert.Equal("+Acdream", parsed.Characters[0].Name);
+ Assert.Equal(0u, parsed.Characters[0].DeleteTimeDelta);
+ Assert.Equal(11u, parsed.SlotCount);
+ Assert.Equal("testaccount", parsed.AccountName);
+ Assert.True(parsed.UseTurbineChat);
+ Assert.True(parsed.HasThroneOfDestiny);
+ }
+
+ [Fact]
+ public void Parse_MultipleCharacters_PreservesOrder()
+ {
+ var w = new PacketWriter(128);
+ w.WriteUInt32(CharacterList.Opcode);
+ w.WriteUInt32(0);
+ w.WriteUInt32(3); // count
+ w.WriteUInt32(0x50000001u);
+ w.WriteString16L("Alice");
+ w.WriteUInt32(0);
+ w.WriteUInt32(0x50000002u);
+ w.WriteString16L("Bob");
+ w.WriteUInt32(0);
+ w.WriteUInt32(0x50000003u);
+ w.WriteString16L("Carol");
+ w.WriteUInt32(0);
+ w.WriteUInt32(0);
+ w.WriteUInt32(11);
+ w.WriteString16L("acct");
+ w.WriteUInt32(0);
+ w.WriteUInt32(1);
+
+ var parsed = CharacterList.Parse(w.ToArray());
+
+ Assert.Equal(3, parsed.Characters.Count);
+ Assert.Equal("Alice", parsed.Characters[0].Name);
+ Assert.Equal("Bob", parsed.Characters[1].Name);
+ Assert.Equal("Carol", parsed.Characters[2].Name);
+ }
+
+ [Fact]
+ public void Parse_WrongOpcode_Throws()
+ {
+ byte[] bytes = new byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu);
+ Assert.Throws(() => CharacterList.Parse(bytes));
+ }
+
+ [Fact]
+ public void Parse_Truncated_Throws()
+ {
+ byte[] bytes = new byte[8]; // just opcode + first zero, missing count
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterList.Opcode);
+ Assert.Throws(() => CharacterList.Parse(bytes));
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs b/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs
index 58b1c64..d80ce9c 100644
--- a/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs
@@ -4,11 +4,15 @@ namespace AcDream.Core.Net.Tests.Packets;
public class FragmentAssemblerTests
{
+ // NOTE: the first parameter name remains `id` for test-call-site clarity,
+ // but it now sets the fragment Sequence (the actual message-group key —
+ // the Id field is a constant on outbound fragments per AC protocol).
private static MessageFragment MakeFrag(uint id, ushort count, ushort index, byte[] payload, ushort queue = 7)
=> new(
new MessageFragmentHeader
{
- Id = id,
+ Sequence = id,
+ Id = 0x80000000u, // matches ACE outbound constant
Count = count,
Index = index,
TotalSize = (ushort)(MessageFragmentHeader.Size + payload.Length),