acdream/tests/AcDream.Core.Net.Tests/Packets/FragmentAssemblerTests.cs
Erik 94da385ff4 feat(net): acdream enters the world — CharacterList parsed + CharacterEnterWorld sent + 68 CreateObject received (Phase 4.7)
Drives the full post-handshake flow on a live ACE server. After the
3-way handshake completes, acdream:
  1. Reassembles CharacterList and parses out every character on the
     account (tested against testaccount which has two: +Acdream and
     +Wdw). Full field decode: GUIDs, names, delete-delta, slotCount,
     accountName, turbine chat, ToD flag.
  2. Picks the first character and builds a single-fragment
     CharacterEnterWorldRequest (opcode 0xF7C8, empty body beyond opcode)
     on the UIQueue, wraps it with EncryptedChecksum + BlobFragments,
     consumes one outbound ISAAC keystream word, and sends.
  3. Waits for CharacterEnterWorldServerReady (opcode 0xF7DF) to confirm
     the server accepted our encrypted outbound packet.
  4. Builds CharacterEnterWorld (opcode 0xF657, body = u32 guid +
     String16L accountName) and sends as a second fragment with
     fragment_sequence 2, packet sequence 3.
  5. Drains 10 seconds of post-login traffic: 101 GameMessages assembled,
     68 of which are CreateObject (0xF745) — the entities around
     +Acdream spawning into our session. Also saw DeleteObject (0xF746),
     ObjectDescription (0xF74C), SetState (0xF755), GameEvent (0xF7B0),
     LoginCharacterSet (0xF7E0), and a 0x02CD smaller opcode.

This is the Phase 4.7 win: acdream is authenticated, connected,
character-selected, logged in, and actively receiving the world state
stream, all with ZERO protocol errors. Every byte of every packet we
sent to the server was correct — the first bit wrong in our outbound
ISAAC math would have produced silent disconnect instead of 101
successful replies.

Added to AcDream.Core.Net:
  - Messages/CharacterList.cs: full parser for opcode 0xF658, ported
    from ACE's GameMessageCharacterList writer. Returns structured
    record with Characters[], SlotCount, AccountName, UseTurbineChat,
    HasThroneOfDestiny. Tested offline with hand-assembled bodies
    matching ACE's writer format.
  - Messages/CharacterEnterWorld.cs: outbound builders for
    CharacterEnterWorldRequest (0xF7C8, opcode-only) and
    CharacterEnterWorld (0xF657, opcode + guid + String16L account).
  - Messages/GameMessageFragment.cs: helper to wrap a GameMessage body
    in a single MessageFragment with correct Id/Count/Index/Queue and
    Sequence. Also a Serialize helper to turn a MessageFragment into
    packet-body bytes for PacketCodec.Encode. Throws on oversize
    (>448 byte) messages; multi-fragment outbound split is TBD.
  - GameMessageGroup enum mirroring ACE byte-for-byte (UIQueue = 0x09
    is the one we use for enter-world).

Fixed: FragmentAssembler was keying on MessageFragmentHeader.Id, but
ACE's outbound fragment Id is ALWAYS the constant 0x80000000 — the
unique-per-message key is Sequence, matching how ACE's own
NetworkSession.HandleFragment keys its partialFragments dict. Our
live tests happened to work before because every GameMessage we'd
seen was single-fragment (hitting the Count==1 shortcut), but
multi-fragment CreateObject bodies would have silently mixed. Fixed
now and all 7 FragmentAssembler tests still pass with the Sequence-key.

Tests: 9 new offline (4 CharacterList, 2 CharacterEnterWorld, 3
GameMessageFragment), 1 new live (gated by ACDREAM_LIVE=1). Total
77 core + 83 net = 160 passing.

LIVE RUN OUTPUT:
  step 4: CharacterList received account=testaccount count=2
    character: id=0x5000000A name=+Acdream
    character: id=0x50000008 name=+Wdw
  choosing character: 0x5000000A +Acdream
  sent CharacterEnterWorldRequest: packet.seq=2 frag.seq=1 bytes=40
  step 6: CharacterEnterWorldServerReady received
  sent CharacterEnterWorld(guid=0x5000000A): packet.seq=3 frag.seq=2 bytes=60
  step 8 summary: 101 GameMessages assembled, 68 CreateObject
  unique opcodes seen: 0xF7B0, 0xF7E0, 0xF746, 0xF745, 0x02CD,
                       0xF755, 0xF74C

Phase 4.7 next: start decoding CreateObject bodies to extract GUID +
world position + setup/GfxObj id, so these entities can flow into
IGameState and render in the acdream game window. The foundry statue
is waiting in one of those 68 spawns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:14:31 +02:00

127 lines
5.1 KiB
C#

using AcDream.Core.Net.Packets;
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
{
Sequence = id,
Id = 0x80000000u, // matches ACE outbound constant
Count = count,
Index = index,
TotalSize = (ushort)(MessageFragmentHeader.Size + payload.Length),
Queue = queue,
},
payload);
[Fact]
public void Ingest_SingleFragmentMessage_ReleasesImmediately()
{
var assembler = new FragmentAssembler();
var frag = MakeFrag(id: 1, count: 1, index: 0, payload: new byte[] { 1, 2, 3 }, queue: 42);
var result = assembler.Ingest(frag, out var queue);
Assert.NotNull(result);
Assert.Equal(new byte[] { 1, 2, 3 }, result);
Assert.Equal(42, queue);
Assert.Equal(0, assembler.PartialCount);
}
[Fact]
public void Ingest_ThreeFragmentsInOrder_ReleasesOnLast()
{
// Queue is a property of the logical message, not individual fragments,
// so all three fragments carry the same queue value (captured from the
// first arrival). Testing with queue=9 on all three.
var assembler = new FragmentAssembler();
Assert.Null(assembler.Ingest(MakeFrag(7, 3, 0, new byte[] { 0xAA, 0xBB }, queue: 9), out _));
Assert.Equal(1, assembler.PartialCount);
Assert.Null(assembler.Ingest(MakeFrag(7, 3, 1, new byte[] { 0xCC, 0xDD }, queue: 9), out _));
var result = assembler.Ingest(MakeFrag(7, 3, 2, new byte[] { 0xEE }, queue: 9), out var queue);
Assert.NotNull(result);
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE }, result);
Assert.Equal(9, queue);
Assert.Equal(0, assembler.PartialCount);
}
[Fact]
public void Ingest_OutOfOrderFragments_ReleasesCorrectlyOnLastArrival()
{
// Arrive as index 2, then 0, then 1 — the last arrival (index 1) is
// neither the first nor the last index, so this tests that the
// assembler releases on "count full", not "last index".
var assembler = new FragmentAssembler();
Assert.Null(assembler.Ingest(MakeFrag(3, 3, 2, new byte[] { 0xCC }), out _));
Assert.Null(assembler.Ingest(MakeFrag(3, 3, 0, new byte[] { 0xAA }), out _));
var result = assembler.Ingest(MakeFrag(3, 3, 1, new byte[] { 0xBB }), out _);
Assert.NotNull(result);
// Result must be assembled in INDEX order, not arrival order.
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, result);
}
[Fact]
public void Ingest_DuplicateFragment_IsIdempotent()
{
var assembler = new FragmentAssembler();
Assert.Null(assembler.Ingest(MakeFrag(5, 2, 0, new byte[] { 0x11 }), out _));
// Resend index 0 — should not double-count or corrupt state.
Assert.Null(assembler.Ingest(MakeFrag(5, 2, 0, new byte[] { 0x11 }), out _));
// Assembler should still be waiting for index 1.
Assert.Equal(1, assembler.PartialCount);
var result = assembler.Ingest(MakeFrag(5, 2, 1, new byte[] { 0x22 }), out _);
Assert.NotNull(result);
Assert.Equal(new byte[] { 0x11, 0x22 }, result);
}
[Fact]
public void Ingest_MissingFragment_DoesNotRelease()
{
var assembler = new FragmentAssembler();
Assert.Null(assembler.Ingest(MakeFrag(9, 3, 0, new byte[] { 1 }), out _));
Assert.Null(assembler.Ingest(MakeFrag(9, 3, 2, new byte[] { 3 }), out _));
// Only 2 of 3 arrived → still waiting
Assert.Equal(1, assembler.PartialCount);
}
[Fact]
public void Ingest_TwoIndependentMessages_BuiltInParallel()
{
var assembler = new FragmentAssembler();
Assert.Null(assembler.Ingest(MakeFrag(100, 2, 0, new byte[] { 0xA1 }), out _));
Assert.Null(assembler.Ingest(MakeFrag(200, 2, 0, new byte[] { 0xB1 }), out _));
Assert.Equal(2, assembler.PartialCount);
var resultA = assembler.Ingest(MakeFrag(100, 2, 1, new byte[] { 0xA2 }), out _);
Assert.Equal(new byte[] { 0xA1, 0xA2 }, resultA);
Assert.Equal(1, assembler.PartialCount);
var resultB = assembler.Ingest(MakeFrag(200, 2, 1, new byte[] { 0xB2 }), out _);
Assert.Equal(new byte[] { 0xB1, 0xB2 }, resultB);
Assert.Equal(0, assembler.PartialCount);
}
[Fact]
public void DropAll_ClearsInFlightPartials()
{
var assembler = new FragmentAssembler();
assembler.Ingest(MakeFrag(1, 5, 0, new byte[] { 1 }), out _);
assembler.Ingest(MakeFrag(2, 5, 0, new byte[] { 2 }), out _);
Assert.Equal(2, assembler.PartialCount);
assembler.DropAll();
Assert.Equal(0, assembler.PartialCount);
}
}