Five sub-changes:
1. Windows-1252 codec switch (global). Every Encoding.ASCII call site
in src/AcDream.Core.Net/Messages/ -> Encoding.GetEncoding(1252).
Touched HearSpeech, ChatRequests, GameEvents, AppraiseInfoParser,
CharacterList, CreateObject, PlayerDescriptionParser, SocialActions.
New Encodings.cs module-init registers CodePagesEncodingProvider
(System.Text.Encoding.CodePages ships with .NET 10 SDK but isn't
auto-registered). Matches retail + holtburger; accented names
no longer round-trip-broken.
2. New parsers (opcodes confirmed against holtburger opcodes.rs):
- EmoteText (0x01E0) { u32 senderGuid, string16 senderName, string16 text }
- SoulEmote (0x01E2) same wire layout as EmoteText
- ServerMessage (0xF7E0) { string16 message, u32 chatType }
- PlayerKilled (0x019E) { string16 deathMessage, u32 victimGuid, u32 killerGuid }
Shared StringReader.cs has the CP1252 String16L primitive.
3. WorldSession dispatch. ProcessDatagram adds branches for the four
new top-level opcodes + fires session-level events (EmoteHeard,
SoulEmoteHeard, ServerMessageReceived, PlayerKilledReceived).
0x0295 SetTurbineChatChannels stubbed with TODO for parallel I.6.
4. GameEventWiring routes WeenieError + WeenieErrorWithString
(parsers existed but were unrouted) -> chat.OnWeenieError.
5. ChatLog adapters: Emote / SoulEmote ChatKind values, OnEmote,
OnSoulEmote, OnPlayerKilled, OnWeenieError. OnLocalSpeech now
substitutes empty sender -> "You" per holtburger client/messages.rs.
ChatVM.FormatEntry handles new kinds (asterisk + sender + text).
22 new tests covering parser round-trips + reject-bad-opcode +
ChatLog adapter coverage + Win-1252 round-trip with non-ASCII chars.
Solution total: 881 green (210->225 in Core.Net.Tests, 606->613 in Core.Tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
3.9 KiB
C#
103 lines
3.9 KiB
C#
using System.Buffers.Binary;
|
|
using System.Text;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
///
|
|
/// <para>
|
|
/// Wire layout (ported from ACE's <c>GameMessageCharacterList.cs</c>
|
|
/// writer — see NOTICE.md for attribution):
|
|
/// </para>
|
|
///
|
|
/// <code>
|
|
/// 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)
|
|
/// </code>
|
|
/// </summary>
|
|
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<Character> Characters,
|
|
uint SlotCount,
|
|
string AccountName,
|
|
bool UseTurbineChat,
|
|
bool HasThroneOfDestiny);
|
|
|
|
/// <summary>
|
|
/// Parse a CharacterList body. <paramref name="body"/> must start with
|
|
/// the 4-byte opcode (0xF658) — i.e. pass the full reassembled
|
|
/// GameMessage output from <see cref="Packets.FragmentAssembler"/>.
|
|
/// </summary>
|
|
public static Parsed Parse(ReadOnlySpan<byte> 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<byte> 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<byte> 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");
|
|
// Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252).
|
|
string result = Encoding.GetEncoding(1252).GetString(source.Slice(pos, length));
|
|
pos += length;
|
|
int recordSize = 2 + length;
|
|
int padding = (4 - (recordSize & 3)) & 3;
|
|
pos += padding;
|
|
return result;
|
|
}
|
|
}
|