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>
This commit is contained in:
parent
0aea24c78e
commit
94da385ff4
8 changed files with 629 additions and 10 deletions
|
|
@ -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<uint>();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ArgumentException>(
|
||||
() => 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);
|
||||
}
|
||||
}
|
||||
87
tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs
Normal file
87
tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs
Normal file
|
|
@ -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<FormatException>(() => 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<FormatException>(() => CharacterList.Parse(bytes));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue