fix(net): conform character entry and session shutdown
This commit is contained in:
parent
bacc7e45a9
commit
aea957f845
12 changed files with 1016 additions and 95 deletions
|
|
@ -7,22 +7,25 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// Inbound <c>CharacterList</c> GameMessage (opcode <c>0xF658</c>).
|
||||
/// 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).
|
||||
/// data. Contains the retail status, separate active and deleted character
|
||||
/// collections, plus account-level settings.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (ported from ACE's <c>GameMessageCharacterList.cs</c>
|
||||
/// writer — see NOTICE.md for attribution):
|
||||
/// Wire layout ported from retail <c>CharacterSet::UnPack</c> at
|
||||
/// <c>0x004FE340</c> and <c>CharacterIdentity::UnPack</c> at
|
||||
/// <c>0x004FE880</c>. ACE currently emits zero for both status and deleted
|
||||
/// count, which is why older readers mislabeled them as padding:
|
||||
/// </para>
|
||||
///
|
||||
/// <code>
|
||||
/// u32 0 (leading padding, always 0)
|
||||
/// u32 characterCount
|
||||
/// for each character:
|
||||
/// u32 status
|
||||
/// u32 activeCount
|
||||
/// for each active character:
|
||||
/// u32 characterId (GUID)
|
||||
/// String16L name (prefixed with '+' for admin chars)
|
||||
/// u32 deleteTimeDelta (0 if not scheduled for deletion)
|
||||
/// u32 0 (trailing padding)
|
||||
/// u32 secondsGreyedOut
|
||||
/// u32 deletedCount
|
||||
/// for each deleted character: same CharacterIdentity record
|
||||
/// u32 slotCount (max characters per account)
|
||||
/// String16L accountName
|
||||
/// u32 useTurbineChat (bool)
|
||||
|
|
@ -33,11 +36,15 @@ public static class CharacterList
|
|||
{
|
||||
public const uint Opcode = 0xF658u;
|
||||
|
||||
public readonly record struct Character(uint Id, string Name, uint DeleteTimeDelta);
|
||||
public readonly record struct Character(uint Id, string Name, uint SecondsGreyedOut);
|
||||
|
||||
public readonly record struct Selection(int ActiveIndex, Character Character);
|
||||
|
||||
public sealed record Parsed(
|
||||
uint Status,
|
||||
IReadOnlyList<Character> Characters,
|
||||
uint SlotCount,
|
||||
IReadOnlyList<Character> DeletedCharacters,
|
||||
int SlotCount,
|
||||
string AccountName,
|
||||
bool UseTurbineChat,
|
||||
bool HasThroneOfDestiny);
|
||||
|
|
@ -55,27 +62,78 @@ public static class CharacterList
|
|||
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);
|
||||
uint status = ReadU32(body, ref pos);
|
||||
Character[] characters = ReadCharacters(body, ref pos, "active");
|
||||
Character[] deletedCharacters = ReadCharacters(body, ref pos, "deleted");
|
||||
int slotCount = unchecked((int)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);
|
||||
return new Parsed(
|
||||
status,
|
||||
characters,
|
||||
deletedCharacters,
|
||||
slotCount,
|
||||
accountName,
|
||||
useTurbineChat,
|
||||
hasThroneOfDestiny);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// acdream's unattended character-management adaptation: choose the first
|
||||
/// active, non-greyed identity in retail wire order. Deleted identities are
|
||||
/// intentionally outside the candidate collection.
|
||||
/// </summary>
|
||||
public static bool TrySelectFirstAvailable(Parsed parsed, out Selection selection)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parsed);
|
||||
|
||||
for (int i = 0; i < parsed.Characters.Count; i++)
|
||||
{
|
||||
Character character = parsed.Characters[i];
|
||||
if (!IsAvailableActiveIdentity(character))
|
||||
continue;
|
||||
|
||||
selection = new Selection(i, character);
|
||||
return true;
|
||||
}
|
||||
|
||||
selection = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether an identity from the active collection can be selected.
|
||||
/// The collection context is deliberate: deleted identities share the same
|
||||
/// wire record but are never selectable.
|
||||
/// </summary>
|
||||
public static bool IsAvailableActiveIdentity(Character character) =>
|
||||
character.Id != 0 && character.SecondsGreyedOut == 0;
|
||||
|
||||
private static Character[] ReadCharacters(
|
||||
ReadOnlySpan<byte> body,
|
||||
ref int pos,
|
||||
string collectionName)
|
||||
{
|
||||
uint count = ReadU32(body, ref pos);
|
||||
// CharacterIdentity's minimum aligned wire size is twelve bytes:
|
||||
// guid + empty String16L record + secondsGreyedOut. Derive the bound
|
||||
// from the packet rather than inventing a retail-external count cap.
|
||||
if (count > (uint)((body.Length - pos) / 12))
|
||||
throw new FormatException(
|
||||
$"{collectionName} character count {count} exceeds remaining payload");
|
||||
|
||||
var characters = new Character[checked((int)count)];
|
||||
for (int i = 0; i < characters.Length; i++)
|
||||
{
|
||||
uint id = ReadU32(body, ref pos);
|
||||
string name = ReadString16L(body, ref pos);
|
||||
uint secondsGreyedOut = ReadU32(body, ref pos);
|
||||
characters[i] = new Character(id, name, secondsGreyedOut);
|
||||
}
|
||||
|
||||
return characters;
|
||||
}
|
||||
|
||||
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
|
||||
|
|
@ -97,6 +155,8 @@ public static class CharacterList
|
|||
pos += length;
|
||||
int recordSize = 2 + length;
|
||||
int padding = (4 - (recordSize & 3)) & 3;
|
||||
if (source.Length - pos < padding)
|
||||
throw new FormatException("truncated String16L padding");
|
||||
pos += padding;
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,6 @@ public static class CharacterLogOff
|
|||
/// confirmation. ACE emits the canonical four-byte opcode-only form.
|
||||
/// </summary>
|
||||
public static bool IsConfirmation(ReadOnlySpan<byte> body) =>
|
||||
body.Length >= sizeof(uint) &&
|
||||
body.Length == sizeof(uint) &&
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue