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>
175 lines
7.7 KiB
C#
175 lines
7.7 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Text;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Outbound social + query GameActions. These share a common pattern:
|
|
/// single-target / single-parameter GameActions inside the <c>0xF7B1</c>
|
|
/// envelope.
|
|
///
|
|
/// <para>
|
|
/// Wire format:
|
|
/// <code>
|
|
/// u32 0xF7B1 envelope
|
|
/// u32 gameActionSequence
|
|
/// u32 subOpcode
|
|
/// <payload> per-action
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// References: r08 §3 rows for each opcode.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class SocialActions
|
|
{
|
|
public const uint GameActionEnvelope = 0xF7B1u;
|
|
|
|
// Queries
|
|
public const uint QueryHealthOpcode = 0x01BFu; // u32 targetGuid
|
|
public const uint PingRequestOpcode = 0x01E9u; // u32 clientId
|
|
|
|
// Fellowship
|
|
public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, bool openness, bool shareXP
|
|
public const uint FellowshipQuitOpcode = 0x00A3u; // bool disband
|
|
public const uint FellowshipDismissOpcode = 0x00A4u; // u32 guid
|
|
public const uint FellowshipRecruitOpcode = 0x00A5u; // u32 guid
|
|
public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open
|
|
|
|
// Character options
|
|
public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap
|
|
|
|
// Chat channels
|
|
public const uint AddChannelOpcode = 0x0145u; // string16L channelName
|
|
public const uint RemoveChannelOpcode = 0x0146u; // string16L channelName
|
|
|
|
/// <summary>Query a target's health — server replies with UpdateHealth (0x01C0).</summary>
|
|
public static byte[] BuildQueryHealth(uint seq, uint targetGuid)
|
|
{
|
|
byte[] body = new byte[16];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), QueryHealthOpcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
|
|
return body;
|
|
}
|
|
|
|
/// <summary>Keepalive ping — server echoes with PingResponse (0x01EA).</summary>
|
|
public static byte[] BuildPingRequest(uint seq, uint clientId)
|
|
{
|
|
byte[] body = new byte[16];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PingRequestOpcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), clientId);
|
|
return body;
|
|
}
|
|
|
|
/// <summary>Create a fellowship with a chosen name + options.</summary>
|
|
public static byte[] BuildFellowshipCreate(
|
|
uint seq, string fellowshipName, bool openness, bool shareXp)
|
|
{
|
|
byte[] name = PackString16L(fellowshipName);
|
|
// 2 bools consume 2 bytes + alignment pad to 4.
|
|
int boolBlock = 2;
|
|
int pad = (4 - ((name.Length + boolBlock) & 3)) & 3;
|
|
byte[] body = new byte[12 + name.Length + boolBlock + pad];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipCreateOpcode);
|
|
Array.Copy(name, 0, body, 12, name.Length);
|
|
body[12 + name.Length] = openness ? (byte)1 : (byte)0;
|
|
body[12 + name.Length + 1] = shareXp ? (byte)1 : (byte)0;
|
|
return body;
|
|
}
|
|
|
|
/// <summary>Quit your current fellowship (optionally disband if leader).</summary>
|
|
public static byte[] BuildFellowshipQuit(uint seq, bool disband)
|
|
{
|
|
byte[] body = new byte[16]; // envelope + 1 byte bool aligned to 4
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipQuitOpcode);
|
|
body[12] = disband ? (byte)1 : (byte)0;
|
|
return body;
|
|
}
|
|
|
|
/// <summary>Dismiss a specific vassal from your fellowship.</summary>
|
|
public static byte[] BuildFellowshipDismiss(uint seq, uint targetGuid)
|
|
=> SingleGuid(seq, FellowshipDismissOpcode, targetGuid);
|
|
|
|
/// <summary>Recruit a target into your fellowship.</summary>
|
|
public static byte[] BuildFellowshipRecruit(uint seq, uint targetGuid)
|
|
=> SingleGuid(seq, FellowshipRecruitOpcode, targetGuid);
|
|
|
|
/// <summary>Toggle fellowship open / closed recruiting.</summary>
|
|
public static byte[] BuildFellowshipUpdate(uint seq, bool open)
|
|
{
|
|
byte[] body = new byte[16];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipUpdateOpcode);
|
|
body[12] = open ? (byte)1 : (byte)0;
|
|
return body;
|
|
}
|
|
|
|
/// <summary>Push the client's character-options bitmap to the server.</summary>
|
|
public static byte[] BuildSetCharacterOptions(uint seq, uint optionsBitmap)
|
|
{
|
|
byte[] body = new byte[16];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetCharacterOptionsOpcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionsBitmap);
|
|
return body;
|
|
}
|
|
|
|
/// <summary>Subscribe to a named chat channel.</summary>
|
|
public static byte[] BuildAddChannel(uint seq, string channelName)
|
|
=> SingleString(seq, AddChannelOpcode, channelName);
|
|
|
|
/// <summary>Unsubscribe from a named chat channel.</summary>
|
|
public static byte[] BuildRemoveChannel(uint seq, string channelName)
|
|
=> SingleString(seq, RemoveChannelOpcode, channelName);
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
private static byte[] SingleGuid(uint seq, uint sub, uint guid)
|
|
{
|
|
byte[] body = new byte[16];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), guid);
|
|
return body;
|
|
}
|
|
|
|
private static byte[] SingleString(uint seq, uint sub, string s)
|
|
{
|
|
byte[] str = PackString16L(s);
|
|
byte[] body = new byte[12 + str.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
|
|
Array.Copy(str, 0, body, 12, str.Length);
|
|
return body;
|
|
}
|
|
|
|
private static byte[] PackString16L(string s)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(s);
|
|
// Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252).
|
|
byte[] data = Encoding.GetEncoding(1252).GetBytes(s);
|
|
if (data.Length > ushort.MaxValue)
|
|
throw new ArgumentException("String too long for 16-bit length prefix.", nameof(s));
|
|
|
|
int recordSize = 2 + data.Length;
|
|
int padding = (4 - (recordSize & 3)) & 3;
|
|
byte[] result = new byte[recordSize + padding];
|
|
BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
|
|
Array.Copy(data, 0, result, 2, data.Length);
|
|
return result;
|
|
}
|
|
}
|