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),