feat(chat): Phase H.1 Talk/Tell/ChatChannel + HearSpeech + ChatLog
Completes the chat-wire layer end-to-end: outbound Talk (/say), Tell (/tell), ChatChannel, + inbound HearSpeech (0x02BB) / HearRangedSpeech (0x02BC) routed into a unified ChatLog that also consumes the already- parsed GameEvent ChannelBroadcast / Tell / TransientMessage / Popup. Wire layer (AcDream.Core.Net/Messages): - ChatRequests.BuildTalk (0x0015, inside 0xF7B1): gameActionSequence + string16L message. PackString16L helper with 4-byte pad. - ChatRequests.BuildTell (0x005D): targetName + message, each string16L with its own padding. - ChatRequests.BuildChatChannel (0x0147): channelId + message. - HearSpeech.TryParse handles BOTH 0x02BB local AND 0x02BC ranged — single parser with IsRanged flag in the returned record. Standalone GameMessage (NOT wrapped in 0xF7B0). WorldSession integration: - ProcessDatagram branch for HearSpeech.LocalOpcode / HearSpeech.RangedOpcode; fires new SpeechHeard event. - Places the new branch before the 0xF7B0 GameEvent branch so ordering stays stable. Core layer (AcDream.Core/Chat): - ChatEntry record: (Kind, Sender, Text, SenderGuid, ChannelId, Received). - ChatKind enum: LocalSpeech, RangedSpeech, Channel, Tell, System, Popup. - ChatLog: ring-buffer (default 500) of entries; adapters for every inbound source (OnLocalSpeech, OnChannelBroadcast, OnTellReceived, OnSystemMessage, OnPopup) plus OnSelfSent for echoing outbound. Fires EntryAppended so UI panel can scroll / highlight. Tests (15 new): - ChatRequests: Talk / Tell / ChatChannel byte-exact encoding (including string16L padding edge cases). - HearSpeech: local + ranged round-trip, wrong-opcode returns null. - ChatLog: local / ranged / channel / tell / system / self echo, ring-buffer drops oldest, Clear empties. Build green, 570 tests pass (up from 555). With the chat wire layer in place, Phase H.1's "chat window panel" (UI slice 05) is purely a UI task: instantiate ChatLog, bind to EntryAppended, feed rows into the retail-UI widget toolkit. No more protocol gaps. Ref: r08 §3 (opcodes 0x0015, 0x005D, 0x0147), §2 (0x02BB, 0x02BC). Ref: ACE GameMessageHearSpeech.cs + GameActionChannelBroadcast.cs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c95aedcd4a
commit
404cab55ba
6 changed files with 568 additions and 0 deletions
85
src/AcDream.Core.Net/Messages/HearSpeech.cs
Normal file
85
src/AcDream.Core.Net/Messages/HearSpeech.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>0x02BB HearSpeech</c> + <c>0x02BC HearRangedSpeech</c>
|
||||
/// GameMessages. Local-area / shout chat heard by the player. These
|
||||
/// do NOT ride the 0xF7B0 GameEvent envelope — they're standalone
|
||||
/// GameMessages dispatched the same way as CreateObject / UpdateMotion.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout:
|
||||
/// <code>
|
||||
/// u32 opcode // 0x02BB or 0x02BC
|
||||
/// string16L text
|
||||
/// string16L senderName
|
||||
/// u32 senderGuid
|
||||
/// u32 chatType
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// ChatType (from ACE):
|
||||
/// <list type="bullet">
|
||||
/// <item><description>0x01 = Broadcast</description></item>
|
||||
/// <item><description>0x02 = Combat</description></item>
|
||||
/// <item><description>0x0B = Speech</description></item>
|
||||
/// <item><description>0x0F = Emote</description></item>
|
||||
/// <item><description>0x10 = Tell</description></item>
|
||||
/// <item><description>0x11 = Syllables (spell casting)</description></item>
|
||||
/// <item><description>other values in ACE ChatMessageType.cs</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class HearSpeech
|
||||
{
|
||||
public const uint LocalOpcode = 0x02BBu;
|
||||
public const uint RangedOpcode = 0x02BCu;
|
||||
|
||||
public readonly record struct Parsed(
|
||||
string Text,
|
||||
string SenderName,
|
||||
uint SenderGuid,
|
||||
uint ChatType,
|
||||
bool IsRanged);
|
||||
|
||||
public static Parsed? TryParse(byte[] body)
|
||||
{
|
||||
if (body is null || body.Length < 16) return null;
|
||||
|
||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||
bool isRanged;
|
||||
if (opcode == LocalOpcode) isRanged = false;
|
||||
else if (opcode == RangedOpcode) isRanged = true;
|
||||
else return null;
|
||||
|
||||
int pos = 4;
|
||||
try
|
||||
{
|
||||
string text = ReadString16L(body, ref pos);
|
||||
string sender = ReadString16L(body, ref pos);
|
||||
if (body.Length - pos < 8) return null;
|
||||
uint senderGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); pos += 4;
|
||||
uint chatType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); pos += 4;
|
||||
return new Parsed(text, sender, senderGuid, chatType, isRanged);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
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");
|
||||
string result = Encoding.ASCII.GetString(source.Slice(pos, length));
|
||||
pos += length;
|
||||
int recordSize = 2 + length;
|
||||
int padding = (4 - (recordSize & 3)) & 3;
|
||||
pos += padding;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue