fix(net): conform character entry and session shutdown

This commit is contained in:
Erik 2026-07-21 10:33:03 +02:00
parent bacc7e45a9
commit aea957f845
12 changed files with 1016 additions and 95 deletions

View file

@ -1,4 +1,5 @@
using System.Buffers.Binary;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
@ -41,6 +42,67 @@ public class CharacterEnterWorldTests
Assert.Equal(0, body[pos++]);
Assert.Equal(4 + 4 + 16, body.Length); // opcode + guid + padded string
}
[Fact]
public void SessionSelection_UsesCanonicalCharacterListAccountInF657()
{
CharacterList.Parsed characters = MakeCharacters(
[new CharacterList.Character(0x50000001u, "Ready", 0)],
accountName: "CanonicalCase");
WorldSession.EnterWorldSelection selection =
WorldSession.SelectCharacterForEnterWorld(characters, 0);
ReadOnlySpan<byte> body = selection.EnterWorldBody;
Assert.Equal(CharacterEnterWorld.EnterWorldOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body));
Assert.Equal(0x50000001u,
BinaryPrimitives.ReadUInt32LittleEndian(body[4..]));
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(body[8..]);
Assert.Equal("CanonicalCase",
System.Text.Encoding.ASCII.GetString(body.Slice(10, length)));
}
[Theory]
[InlineData(0u, 0u)]
[InlineData(0x50000001u, 1u)]
[InlineData(0x50000001u, 30u)]
public void SessionSelection_RejectsUnavailableActiveIdentity(
uint guid,
uint secondsGreyedOut)
{
CharacterList.Parsed characters = MakeCharacters(
[new CharacterList.Character(guid, "Unavailable", secondsGreyedOut)],
accountName: "Account");
Assert.Throws<InvalidOperationException>(() =>
WorldSession.SelectCharacterForEnterWorld(characters, 0));
}
[Theory]
[InlineData(-1)]
[InlineData(1)]
public void SessionSelection_RejectsIndexOutsideActiveCollection(int index)
{
CharacterList.Parsed characters = MakeCharacters(
[new CharacterList.Character(0x50000001u, "Ready", 0)],
accountName: "Account");
Assert.Throws<ArgumentOutOfRangeException>(() =>
WorldSession.SelectCharacterForEnterWorld(characters, index));
}
private static CharacterList.Parsed MakeCharacters(
IReadOnlyList<CharacterList.Character> active,
string accountName) =>
new(
Status: 0,
Characters: active,
DeletedCharacters: [],
SlotCount: 11,
AccountName: accountName,
UseTurbineChat: true,
HasThroneOfDestiny: true);
}
public class GameMessageFragmentTests

View file

@ -9,10 +9,10 @@ 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,
// Hand-assemble retail CharacterSet wire layout as emitted by ACE:
// u32 opcode, u32 status=0, u32 activeCount=1,
// u32 guid, String16L name, u32 secondsGreyedOut,
// u32 deletedCount=0, i32 numAllowed, String16L accountName,
// u32 useTurbineChat, u32 hasToD
var w = new PacketWriter(128);
w.WriteUInt32(CharacterList.Opcode);
@ -29,11 +29,13 @@ public class CharacterListTests
var parsed = CharacterList.Parse(w.ToArray());
Assert.Equal(0u, parsed.Status);
Assert.Single(parsed.Characters);
Assert.Empty(parsed.DeletedCharacters);
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(0u, parsed.Characters[0].SecondsGreyedOut);
Assert.Equal(11, parsed.SlotCount);
Assert.Equal("testaccount", parsed.AccountName);
Assert.True(parsed.UseTurbineChat);
Assert.True(parsed.HasThroneOfDestiny);
@ -69,6 +71,152 @@ public class CharacterListTests
Assert.Equal("Carol", parsed.Characters[2].Name);
}
[Fact]
public void Parse_NonzeroStatusAndDeletedCollection_PreservesRetailLayout()
{
var w = new PacketWriter(160);
w.WriteUInt32(CharacterList.Opcode);
w.WriteUInt32(7); // status
w.WriteUInt32(1); // active count
WriteIdentity(w, 0x50000001u, "Active", 0);
w.WriteUInt32(2); // deleted count, not padding
WriteIdentity(w, 0x50000002u, "Deleted One", 90);
WriteIdentity(w, 0x50000003u, "Deleted Two", 180);
WriteTail(w, 13, "CanonicalAccount", useTurbineChat: true, hasTod: false);
CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray());
Assert.Equal(7u, parsed.Status);
Assert.Single(parsed.Characters);
Assert.Equal("Active", parsed.Characters[0].Name);
Assert.Collection(
parsed.DeletedCharacters,
character =>
{
Assert.Equal(0x50000002u, character.Id);
Assert.Equal("Deleted One", character.Name);
Assert.Equal(90u, character.SecondsGreyedOut);
},
character =>
{
Assert.Equal(0x50000003u, character.Id);
Assert.Equal("Deleted Two", character.Name);
Assert.Equal(180u, character.SecondsGreyedOut);
});
Assert.Equal(13, parsed.SlotCount);
Assert.Equal("CanonicalAccount", parsed.AccountName);
Assert.True(parsed.UseTurbineChat);
Assert.False(parsed.HasThroneOfDestiny);
}
[Fact]
public void TrySelectFirstAvailable_SkipsZeroGuidAndGreyedActiveCharacters()
{
CharacterList.Parsed parsed = BuildParsed(
[
new CharacterList.Character(0, "Empty", 0),
new CharacterList.Character(0x50000001u, "Deleting", 30),
new CharacterList.Character(0x50000002u, "Ready", 0),
],
[new CharacterList.Character(0x50000003u, "Deleted", 0)]);
bool found = CharacterList.TrySelectFirstAvailable(parsed, out var selection);
Assert.True(found);
Assert.Equal(2, selection.ActiveIndex);
Assert.Equal(0x50000002u, selection.Character.Id);
}
[Fact]
public void TrySelectFirstAvailable_RejectsAllGreyedAndNeverReadsDeletedCollection()
{
CharacterList.Parsed parsed = BuildParsed(
[new CharacterList.Character(0x50000001u, "Deleting", 1)],
[new CharacterList.Character(0x50000002u, "Deleted Ready", 0)]);
Assert.False(CharacterList.TrySelectFirstAvailable(parsed, out _));
}
[Fact]
public void Parse_EmptyCollectionsAndNegativeAllowedSentinel_ArePreserved()
{
var w = new PacketWriter(64);
w.WriteUInt32(CharacterList.Opcode);
w.WriteUInt32(0);
w.WriteUInt32(0); // active count
w.WriteUInt32(0); // deleted count
w.WriteUInt32(uint.MaxValue); // retail signed -1 sentinel
w.WriteString16L("Account");
w.WriteUInt32(2); // every nonzero retail int is true
w.WriteUInt32(uint.MaxValue);
CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray());
Assert.Empty(parsed.Characters);
Assert.Empty(parsed.DeletedCharacters);
Assert.Equal(-1, parsed.SlotCount);
Assert.True(parsed.UseTurbineChat);
Assert.True(parsed.HasThroneOfDestiny);
Assert.False(CharacterList.TrySelectFirstAvailable(parsed, out _));
}
[Fact]
public void Parse_CountThatCannotFitRemainingPayload_IsRejected()
{
var active = new PacketWriter(16);
active.WriteUInt32(CharacterList.Opcode);
active.WriteUInt32(0);
active.WriteUInt32(256);
Assert.Throws<FormatException>(() => CharacterList.Parse(active.ToArray()));
var deleted = new PacketWriter(20);
deleted.WriteUInt32(CharacterList.Opcode);
deleted.WriteUInt32(0);
deleted.WriteUInt32(0);
deleted.WriteUInt32(256);
Assert.Throws<FormatException>(() => CharacterList.Parse(deleted.ToArray()));
}
[Fact]
public void Parse_255MinimumSizeIdentities_UsesPayloadBoundNotArbitraryCap()
{
var w = new PacketWriter(4096);
w.WriteUInt32(CharacterList.Opcode);
w.WriteUInt32(0);
w.WriteUInt32(255);
for (uint i = 1; i <= 255; i++)
WriteIdentity(w, i, string.Empty, 0);
w.WriteUInt32(0);
WriteTail(w, 255, "Account", useTurbineChat: true, hasTod: true);
CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray());
Assert.Equal(255, parsed.Characters.Count);
Assert.Equal(255, parsed.SlotCount);
}
[Fact]
public void Parse_EveryTruncatedPrefixOfRetailPayload_Throws()
{
var w = new PacketWriter(160);
w.WriteUInt32(CharacterList.Opcode);
w.WriteUInt32(3);
w.WriteUInt32(1);
WriteIdentity(w, 0x50000001u, "A", 0);
w.WriteUInt32(1);
WriteIdentity(w, 0x50000002u, "Deleted", 22);
WriteTail(w, 11, "Account", useTurbineChat: true, hasTod: true);
byte[] valid = w.ToArray();
for (int length = 0; length < valid.Length; length++)
{
byte[] prefix = valid.AsSpan(0, length).ToArray();
Assert.Throws<FormatException>(() => CharacterList.Parse(prefix));
}
_ = CharacterList.Parse(valid);
}
[Fact]
public void Parse_WrongOpcode_Throws()
{
@ -84,4 +232,40 @@ public class CharacterListTests
BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterList.Opcode);
Assert.Throws<FormatException>(() => CharacterList.Parse(bytes));
}
private static void WriteIdentity(
PacketWriter writer,
uint id,
string name,
uint secondsGreyedOut)
{
writer.WriteUInt32(id);
writer.WriteString16L(name);
writer.WriteUInt32(secondsGreyedOut);
}
private static void WriteTail(
PacketWriter writer,
int slotCount,
string accountName,
bool useTurbineChat,
bool hasTod)
{
writer.WriteUInt32(unchecked((uint)slotCount));
writer.WriteString16L(accountName);
writer.WriteUInt32(useTurbineChat ? 1u : 0u);
writer.WriteUInt32(hasTod ? 1u : 0u);
}
private static CharacterList.Parsed BuildParsed(
IReadOnlyList<CharacterList.Character> active,
IReadOnlyList<CharacterList.Character> deleted) =>
new(
Status: 0,
Characters: active,
DeletedCharacters: deleted,
SlotCount: 11,
AccountName: "Account",
UseTurbineChat: true,
HasThroneOfDestiny: true);
}

View file

@ -33,4 +33,12 @@ public sealed class CharacterLogOffTests
Assert.False(CharacterLogOff.IsConfirmation([0x53, 0xF6, 0x00]));
Assert.False(CharacterLogOff.IsConfirmation(BitConverter.GetBytes(0xF654u)));
}
[Fact]
public void IsConfirmation_RejectsRequestShapedBodyWithCharacterId()
{
byte[] request = CharacterLogOff.BuildRequestBody(0x5000000Au);
Assert.False(CharacterLogOff.IsConfirmation(request));
}
}