Three follow-up fixes from the 2026-04-25 live verify session.
1. CRITICAL: BuildTell wire field order. Our outbound layout was
[target_name, message] but ACE's GameActionTell.Handle reads
[message, target_name] (verified against
references/ACE/.../GameActionTell.cs:17-18 verbatim). Result: every
/tell since Phase I.3 has been failing with WeenieError 0x052B
(CharacterNotAvailable) because ACE was looking up the message
text as the recipient name. Swapped the field order in
ChatRequests.BuildTell so message is written first; updated the
pinned BuildTell test to expect the corrected layout. The
WorldSessionChatTests round-trip continues to pass since SendTell
delegates to BuildTell.
2. Retail-style FormatEntry. The user asked for the canonical retail
strings:
/say (own): You say, "text"
/say (incoming): Name says, "text"
/tell (own echo): You tell Caith, "text"
/tell (incoming): Caith tells you, "text"
channel: [Trade] +Acdream says, "text"
/shout (own): You shout, "text"
/shout (incoming):Name shouts, "text"
Discriminators: SenderGuid == 0 distinguishes our own outbound
echoes (set by OnSelfSent) from real incoming whispers (carry the
sender's player guid). Sender == "" or "You" distinguishes our own
/say echoes (OnLocalSpeech substitutes "You" when the wire sender
is empty per holtburger client/messages.rs:476-487).
ChatEntry gains a new ChannelName slot so Channel-kind entries
render with the friendly room name ("Trade") instead of "ch 3".
Falls back to "ch {ChannelId}" when ChannelName isn't populated
(legacy ChatChannel inbound or older callers).
3. Suppress optimistic Channel echo. The user saw duplicates per
/trade /lfg in the live trace:
[ch 0] Trade: hello <-- our optimistic
[ch 3] +Acdream: [Trade] hello <-- ACE's TurbineChat broadcast
ACE's TurbineChatHandler at Network/Handlers/TurbineChatHandler.cs
broadcasts EventSendToRoom to ALL recipients in the room including
the sender, so the canonical echo always arrives via 0xF7DE. Drop
the optimistic OnSelfSent for Turbine kinds in GameWindow's
SendChatCmd handler; trust the server. Legacy ChatChannel paths
(Fellowship / Allegiance / Patron / Monarch / Vassals / CoVassals)
keep the optimistic echo because the legacy 0x0147 broadcast may
not always come back to the sender.
Inbound TurbineChat also stops embedding "[Trade] " into the
message text — passes the friendly name out-of-band via the new
channelName parameter on ChatLog.OnChannelBroadcast.
11 tests updated for the new format strings (8 in ChatVMTests, 1 in
ChatVMCombatTests, 1 BuildTell, plus the format additions cover
incoming/outgoing variants per kind). Solution total: 1007 green
(243 + 114 + 650), 0 warnings.
Tells should now actually deliver. Channel echoes show as
[Trade] +Acdream says, "hello" without the duplicate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
4.8 KiB
C#
117 lines
4.8 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Text;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Outbound chat GameActions — Talk (local /say), Tell (whisper),
|
|
/// ChatChannel (allegiance / general).
|
|
///
|
|
/// <para>
|
|
/// All three ride inside the <c>0xF7B1</c> GameAction envelope with
|
|
/// the same leading fields:
|
|
/// <code>
|
|
/// u32 0xF7B1
|
|
/// u32 gameActionSequence
|
|
/// u32 subOpcode
|
|
/// <payload>
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// String16L wire shape mirrors the other outbound AC messages:
|
|
/// <code>
|
|
/// u16 length // byte count (not char count)
|
|
/// byte[] ascii // ASCII bytes, no terminator
|
|
/// pad to 4-byte boundary
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Source of truth: r08 §3 rows 0x0015 / 0x005D / 0x0147.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class ChatRequests
|
|
{
|
|
public const uint GameActionEnvelope = 0xF7B1u;
|
|
public const uint TalkOpcode = 0x0015u;
|
|
public const uint TellOpcode = 0x005Du;
|
|
public const uint ChatChannelOpcode = 0x0147u;
|
|
|
|
/// <summary>Send a local /say message (heard by anyone within ~20m).</summary>
|
|
public static byte[] BuildTalk(uint gameActionSequence, string message)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(message);
|
|
byte[] msg = PackString16L(message);
|
|
byte[] body = new byte[12 + msg.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TalkOpcode);
|
|
Array.Copy(msg, 0, body, 12, msg.Length);
|
|
return body;
|
|
}
|
|
|
|
/// <summary>Send a /tell (whisper) by target character name.</summary>
|
|
/// <remarks>
|
|
/// Wire field order is <b>message FIRST, then target</b> — that's
|
|
/// what ACE's <c>GameActionTell.Handle</c> reads (see
|
|
/// <c>references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionTell.cs</c>
|
|
/// lines 17-18:
|
|
/// <code>
|
|
/// var message = clientMessage.Payload.ReadString16L();
|
|
/// var target = clientMessage.Payload.ReadString16L();
|
|
/// </code>
|
|
/// Filed after a 2026-04-25 live trace where every <c>/tell +Je hello</c>
|
|
/// failed with WeenieError 0x052B (CharacterNotAvailable). With the
|
|
/// previous (target-first) wire shape, ACE was reading our message
|
|
/// payload "hello" as the recipient name, looking it up, finding no
|
|
/// character "hello" online, and returning the not-available error.
|
|
/// Swapping the field order makes <c>/tell <name> <text></c>
|
|
/// actually deliver.
|
|
/// </remarks>
|
|
public static byte[] BuildTell(uint gameActionSequence, string targetName, string message)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(targetName);
|
|
ArgumentNullException.ThrowIfNull(message);
|
|
byte[] msg = PackString16L(message);
|
|
byte[] name = PackString16L(targetName);
|
|
byte[] body = new byte[12 + msg.Length + name.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TellOpcode);
|
|
Array.Copy(msg, 0, body, 12, msg.Length);
|
|
Array.Copy(name, 0, body, 12 + msg.Length, name.Length);
|
|
return body;
|
|
}
|
|
|
|
/// <summary>Send to a chat channel (allegiance, general, trade, etc).</summary>
|
|
public static byte[] BuildChatChannel(uint gameActionSequence, uint channelId, string message)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(message);
|
|
byte[] msg = PackString16L(message);
|
|
byte[] body = new byte[16 + msg.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), ChatChannelOpcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), channelId);
|
|
Array.Copy(msg, 0, body, 16, msg.Length);
|
|
return body;
|
|
}
|
|
|
|
private static byte[] PackString16L(string 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);
|
|
// trailing bytes are already zero from new[]
|
|
return result;
|
|
}
|
|
}
|