feat(net+chat): #19 TurbineChat (0xF7DE) codec + ChatChannelInfo + SetTurbineChatChannels parser
Full port of holtburger's TurbineChat sidecar wire path: - TurbineChat.cs: 0xF7DE codec with three payload variants (EventSendToRoom S->C, RequestSendToRoomById C->S, Response). 10-field outer header (size_first/blob_type/dispatch_type/ target_type/target_id/transport_type/transport_id/cookie/ size_second + payload). - UTF-16LE turbine string codec with 1-or-2 byte variable-length prefix (high bit on first byte signals 2-byte form). Mirrors holtburger's read_turbine_string / write_turbine_string at references/holtburger/.../messages/chat/turbine.rs:502-544. - SetTurbineChatChannels.cs: 0x0295 GameEvent sub-opcode parser (10 x u32 channel ids). Wired through GameEventDispatcher in WorldSession ctor; routes to GameEventWiring + TurbineChatState. - ChatChannelInfo.cs (Core): unified record union with Legacy (channel id + name) and Turbine (room id + chat type + dispatch type + name) variants, plus IsSelfEchoChannel predicate (Tells = false, channels = true so optimistic echo is suppressed where the server will echo). - TurbineChatState.cs (Core): Enabled flag + 10 cached room ids + NextContextId() cookie counter starting at 1. - WorldSession adds TurbineChatReceived + TurbineChannelsReceived events; SendTurbineChatTo outbound builds RequestSendToRoomById + sends through SendGameAction. ProcessDatagram dispatches 0xF7DE at the top level. - GameWindow constructs TurbineChatState, subscribes inbound EventSendToRoom -> ChatLog.OnChannelBroadcast; extends I.3's SendChatCmd handler to route Turbine kinds (General/Trade/Lfg/ Roleplay/Society/Olthoi) through TurbineChat first, fall back to legacy ChatChannel send when state.Enabled == false. Round-trip golden fixtures from holtburger source verified for all three payload variants + UTF-16LE strings (short + long prefix + non-ASCII Cafe + empty) + SetTurbineChatChannels. 26 new tests: - TurbineChatTests, SetTurbineChatChannelsTests in Core.Net.Tests - ChatChannelInfoTests, TurbineChatStateTests in Core.Tests Solution total: 960 green (243 Core.Net + 625 Core + 92 UI). ACE doesn't run a TurbineChat server, so codec is "ready when needed" for retail-server-emulating setups. Legacy ChatChannel fallback continues to work for current ACE-against-acdream play. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f14296c75f
commit
ca968fc766
11 changed files with 1604 additions and 8 deletions
116
src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs
Normal file
116
src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// <c>SetTurbineChatChannels (0x0295)</c> — GameEvent (0xF7B0 sub-opcode)
|
||||
/// the server fires once at login (and after a chat-server reconnect)
|
||||
/// listing the runtime room ids assigned to the global community
|
||||
/// channels (General, Trade, LFG, Roleplay, Society + sub-societies,
|
||||
/// Olthoi, plus the optional Allegiance Turbine room).
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (10 sequential u32s, payload only — the 0xF7B0 envelope
|
||||
/// is parsed by <see cref="GameEventEnvelope"/>; this parser consumes
|
||||
/// the payload slice):
|
||||
/// <code>
|
||||
/// u32 allegianceRoom // 0 = None (server-side flag)
|
||||
/// u32 generalRoom
|
||||
/// u32 tradeRoom
|
||||
/// u32 lfgRoom
|
||||
/// u32 roleplayRoom
|
||||
/// u32 olthoiRoom
|
||||
/// u32 societyRoom // 0 = None (player not in a society)
|
||||
/// u32 societyCelestialHandRoom
|
||||
/// u32 societyEldrytchWebRoom
|
||||
/// u32 societyRadiantBloodRoom
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Source: holtburger
|
||||
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs</c>
|
||||
/// lines 132-209 (struct + ProtocolUnpack/ProtocolPack).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SetTurbineChatChannels
|
||||
{
|
||||
/// <summary>GameEvent sub-opcode (NOT a top-level GameMessage opcode).</summary>
|
||||
public const uint EventType = 0x0295u;
|
||||
|
||||
/// <summary>Payload size in bytes (10 u32 fields).</summary>
|
||||
public const int PayloadSize = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed channel ids. Optional fields (<see cref="AllegianceRoom"/>,
|
||||
/// <see cref="SocietyRoom"/>) carry <c>0u</c> when the server signals
|
||||
/// "not applicable" — callers should treat 0 as None.
|
||||
/// </summary>
|
||||
public readonly record struct Parsed(
|
||||
uint AllegianceRoom,
|
||||
uint GeneralRoom,
|
||||
uint TradeRoom,
|
||||
uint LfgRoom,
|
||||
uint RoleplayRoom,
|
||||
uint OlthoiRoom,
|
||||
uint SocietyRoom,
|
||||
uint SocietyCelestialHandRoom,
|
||||
uint SocietyEldrytchWebRoom,
|
||||
uint SocietyRadiantBloodRoom);
|
||||
|
||||
/// <summary>
|
||||
/// Parse the payload of a 0xF7B0 envelope whose event type is
|
||||
/// <see cref="EventType"/>. The slice must contain exactly the 10
|
||||
/// channel u32s (40 bytes); shorter buffers return null.
|
||||
/// </summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < PayloadSize) return null;
|
||||
|
||||
uint allegiance = BinaryPrimitives.ReadUInt32LittleEndian(payload[..4]);
|
||||
uint general = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4, 4));
|
||||
uint trade = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8, 4));
|
||||
uint lfg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12, 4));
|
||||
uint roleplay = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(16, 4));
|
||||
uint olthoi = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(20, 4));
|
||||
uint society = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(24, 4));
|
||||
uint societyCelHan = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(28, 4));
|
||||
uint societyEldWeb = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(32, 4));
|
||||
uint societyRadBlo = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(36, 4));
|
||||
|
||||
return new Parsed(
|
||||
AllegianceRoom: allegiance,
|
||||
GeneralRoom: general,
|
||||
TradeRoom: trade,
|
||||
LfgRoom: lfg,
|
||||
RoleplayRoom: roleplay,
|
||||
OlthoiRoom: olthoi,
|
||||
SocietyRoom: society,
|
||||
SocietyCelestialHandRoom: societyCelHan,
|
||||
SocietyEldrytchWebRoom: societyEldWeb,
|
||||
SocietyRadiantBloodRoom: societyRadBlo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize <paramref name="parsed"/> into a 40-byte payload buffer
|
||||
/// (the 0xF7B0 envelope is the caller's responsibility). Used by
|
||||
/// fixture round-trip tests.
|
||||
/// </summary>
|
||||
public static byte[] Serialize(Parsed parsed)
|
||||
{
|
||||
var buf = new byte[PayloadSize];
|
||||
var span = buf.AsSpan();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span, parsed.AllegianceRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4, 4), parsed.GeneralRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8, 4), parsed.TradeRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12, 4), parsed.LfgRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(16, 4), parsed.RoleplayRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(20, 4), parsed.OlthoiRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(24, 4), parsed.SocietyRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(28, 4), parsed.SocietyCelestialHandRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(32, 4), parsed.SocietyEldrytchWebRoom);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(36, 4), parsed.SocietyRadiantBloodRoom);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue