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:
Erik 2026-04-25 19:44:56 +02:00
parent f14296c75f
commit ca968fc766
11 changed files with 1604 additions and 8 deletions

View file

@ -0,0 +1,94 @@
namespace AcDream.Core.Chat;
/// <summary>
/// Source/transport classification for a chat channel — distinguishes
/// retail's two parallel chat channel pipelines.
/// </summary>
public enum ChatChannelSource
{
/// <summary>Legacy <c>ChatChannel</c> bitflag id rides 0x0147 ChatChannel.</summary>
Legacy,
/// <summary>Turbine room id rides 0xF7DE TurbineChat.</summary>
Turbine,
}
/// <summary>
/// Unified info about a chat channel — either a legacy ChatChannel id
/// (Fellowship, Allegiance, Vassals, Patron, Monarch, CoVassals) or a
/// Turbine room id (General, Trade, LFG, Roleplay, Society*, Olthoi).
///
/// <para>
/// Mirrors holtburger's <c>ChatChannelInfo</c>
/// (<c>references/holtburger/crates/holtburger-core/src/client/types.rs</c>
/// lines 63-102). The two retail channel pipelines run side by side —
/// legacy <c>ChatChannel</c> for the player-organisation channels and
/// <c>TurbineChat</c> for the global community rooms — and a single
/// abstraction over both keeps the chat panel and command bus from
/// having to special-case the transport at every call site.
/// </para>
///
/// <para>
/// <see cref="IsSelfEchoChannel"/> tells callers whether the server
/// echoes the client's own outgoing messages back on this channel
/// (so the client should suppress its optimistic local echo). Per
/// holtburger's predicate at <c>chat.rs::is_self_echo_channel</c>
/// (lines 492-507) this is true ONLY for the legacy fellowship/vassals/
/// patron/monarch/co-vassals channels — server resends those with
/// empty sender. Turbine and tells do not echo.
/// </para>
/// </summary>
public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Source)
{
/// <summary>Legacy <c>ChatChannel</c> bitflag id (0x00000800 etc.).</summary>
public sealed record Legacy(uint ChannelId, string DisplayName)
: ChatChannelInfo(DisplayName, ChatChannelSource.Legacy)
{
public override bool IsSelfEchoChannel()
{
// Per holtburger: the legacy fellowship + allegiance-tree
// channels are the ones the server echoes back to the sender
// with an empty sender field. Bitflag values from
// references/holtburger/.../messages/chat/types.rs::ChatChannel.
return ChannelId switch
{
0x00000800u => true, // Fellow
0x00001000u => true, // Vassals
0x00002000u => true, // Patron
0x00004000u => true, // Monarch
0x01000000u => true, // CoVassals
_ => false,
};
}
}
/// <summary>
/// TurbineChat room. <see cref="RoomId"/> is the runtime channel id
/// the server hands out via <c>SetTurbineChatChannels</c> (0x0295).
/// <see cref="ChatType"/> classifies the room semantically (General,
/// Trade, etc.); <see cref="DispatchType"/> chooses the wire dispatch
/// (SendToRoomById for outbound, SendToRoomByName for inbound events).
/// </summary>
public sealed record Turbine(
uint RoomId,
uint ChatType,
uint DispatchType,
string DisplayName)
: ChatChannelInfo(DisplayName, ChatChannelSource.Turbine)
{
public override bool IsSelfEchoChannel()
{
// Turbine rooms do NOT echo the sender's own messages back.
// The client must emit its own optimistic local echo to give
// the player feedback that the message was sent.
return false;
}
}
/// <summary>
/// True iff the server echoes our own outgoing messages on this
/// channel — caller should suppress optimistic local echo to avoid
/// double-printing.
/// </summary>
public abstract bool IsSelfEchoChannel();
}

View file

@ -0,0 +1,136 @@
namespace AcDream.Core.Chat;
/// <summary>
/// Runtime state for retail's TurbineChat (0xF7DE) global chat rooms —
/// the General / Trade / LFG / Roleplay / Society / Olthoi pipeline.
///
/// <para>
/// Lifecycle:
/// <list type="bullet">
/// <item>Pre-login: <see cref="Enabled"/> false, all room ids 0, context counter 1.</item>
/// <item>Server fires <c>SetTurbineChatChannels (0x0295)</c> shortly after EnterWorld
/// → <see cref="OnChannelsReceived"/> populates the room ids and flips
/// <see cref="Enabled"/> on.</item>
/// <item>Outbound chat: caller asks for a fresh context id via
/// <see cref="NextContextId"/>, looks up the room id by
/// <see cref="ChatType"/>, and feeds the pair to
/// <see cref="WorldSession.SendTurbineChatTo"/>.</item>
/// </list>
/// </para>
///
/// <para>
/// Mirrors holtburger's <c>TurbineChatState</c>
/// (<c>references/holtburger/crates/holtburger-core/src/client/types.rs</c>
/// lines 657-672). Cookie counter starts at 1 (not 0) per holtburger:
/// <c>next_context_id.wrapping_add(1).max(1)</c> keeps 0 reserved for
/// "no context" / response cookies.
/// </para>
/// </summary>
public sealed class TurbineChatState
{
/// <summary>True after the first <c>SetTurbineChatChannels</c> arrives.</summary>
public bool Enabled { get; private set; }
/// <summary>Allegiance Turbine room (0 if player has no allegiance).</summary>
public uint AllegianceRoom { get; private set; }
public uint GeneralRoom { get; private set; }
public uint TradeRoom { get; private set; }
public uint LfgRoom { get; private set; }
public uint RoleplayRoom { get; private set; }
public uint OlthoiRoom { get; private set; }
/// <summary>Top-level Society room (0 if player has no society).</summary>
public uint SocietyRoom { get; private set; }
public uint SocietyCelestialHandRoom { get; private set; }
public uint SocietyEldrytchWebRoom { get; private set; }
public uint SocietyRadiantBloodRoom { get; private set; }
private uint _nextContextId = 1;
/// <summary>
/// Get-and-increment the per-session context cookie. Wraps to 1 (not
/// 0) so 0 stays reserved for "no context" / Response cookies.
/// Mirrors holtburger's <c>wrapping_add(1).max(1)</c>.
/// </summary>
public uint NextContextId()
{
uint cookie = _nextContextId;
unchecked
{
_nextContextId += 1;
if (_nextContextId == 0) _nextContextId = 1;
}
return cookie;
}
/// <summary>
/// Absorb a parsed <c>SetTurbineChatChannels</c> payload — flips
/// <see cref="Enabled"/> on and fills in the per-channel room ids.
/// Takes raw u32s (rather than the parser's struct directly) so
/// AcDream.Core stays free of an AcDream.Core.Net dependency.
/// </summary>
public void OnChannelsReceived(
uint allegianceRoom,
uint generalRoom,
uint tradeRoom,
uint lfgRoom,
uint roleplayRoom,
uint olthoiRoom,
uint societyRoom,
uint societyCelestialHandRoom,
uint societyEldrytchWebRoom,
uint societyRadiantBloodRoom)
{
Enabled = true;
AllegianceRoom = allegianceRoom;
GeneralRoom = generalRoom;
TradeRoom = tradeRoom;
LfgRoom = lfgRoom;
RoleplayRoom = roleplayRoom;
OlthoiRoom = olthoiRoom;
SocietyRoom = societyRoom;
SocietyCelestialHandRoom = societyCelestialHandRoom;
SocietyEldrytchWebRoom = societyEldrytchWebRoom;
SocietyRadiantBloodRoom = societyRadiantBloodRoom;
}
/// <summary>
/// Look up the runtime room id for a Turbine
/// <see cref="ChatChannelKindLite"/>. Returns 0 if the channel is not
/// available (server hasn't populated it / player not in society / etc).
/// </summary>
public uint RoomFor(ChatChannelKindLite kind) => kind switch
{
ChatChannelKindLite.Allegiance => AllegianceRoom,
ChatChannelKindLite.General => GeneralRoom,
ChatChannelKindLite.Trade => TradeRoom,
ChatChannelKindLite.Lfg => LfgRoom,
ChatChannelKindLite.Roleplay => RoleplayRoom,
ChatChannelKindLite.Olthoi => OlthoiRoom,
ChatChannelKindLite.Society => SocietyRoom,
ChatChannelKindLite.SocietyCelestialHand => SocietyCelestialHandRoom,
ChatChannelKindLite.SocietyEldrytchWeb => SocietyEldrytchWebRoom,
ChatChannelKindLite.SocietyRadiantBlood => SocietyRadiantBloodRoom,
_ => 0u,
};
}
/// <summary>
/// Coarse Turbine channel selector usable from
/// <see cref="TurbineChatState.RoomFor"/> without dragging in the
/// AcDream.UI.Abstractions <c>ChatChannelKind</c> (which lives in a
/// different layer). Maps 1:1 onto the chat-type-id values from
/// holtburger turbine.rs (TurbineChatType, lines 31-45).
/// </summary>
public enum ChatChannelKindLite
{
Allegiance,
General,
Trade,
Lfg,
Roleplay,
Society,
SocietyCelestialHand,
SocietyEldrytchWeb,
SocietyRadiantBlood,
Olthoi,
}