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

@ -141,6 +141,24 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<PlayerKilled.Parsed>? PlayerKilledReceived;
/// <summary>
/// Phase I.6: fires when a <c>TurbineChat (0xF7DE)</c> top-level
/// GameMessage is received. Carries the unified
/// <see cref="TurbineChat.Parsed"/> envelope (header + payload
/// variant). Subscribers typically switch on the payload variant
/// and route <c>EventSendToRoom</c> into <c>ChatLog.OnChannelBroadcast</c>.
/// </summary>
public event Action<TurbineChat.Parsed>? TurbineChatReceived;
/// <summary>
/// Phase I.6: fires when a <c>SetTurbineChatChannels (0x0295)</c>
/// GameEvent (sub-opcode of 0xF7B0) is received — listing the
/// runtime room ids assigned to General / Trade / LFG / Roleplay /
/// Society / Olthoi (and the optional Allegiance Turbine room).
/// Subscribers typically feed <c>TurbineChatState.OnChannelsReceived</c>.
/// </summary>
public event Action<SetTurbineChatChannels.Parsed>? TurbineChannelsReceived;
/// <summary>
/// Issue #5: fires when a <c>PrivateUpdateVital (0x02E7)</c> arrives
/// — full per-vital snapshot (ranks / start / xp / current).
@ -315,6 +333,17 @@ public sealed class WorldSession : IDisposable
_loginEndpoint = serverLogin;
_connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
_net = new NetClient(serverLogin);
// Phase I.6: SetTurbineChatChannels (0x0295) is a GameEvent
// sub-opcode of 0xF7B0, not a top-level opcode. Route it through
// the dispatcher and surface a typed event so downstream wiring
// (GameEventWiring → TurbineChatState) doesn't need to know the
// GameEvent envelope encoding.
GameEvents.Register(GameEventType.SetTurbineChatChannels, e =>
{
var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span);
if (parsed is not null) TurbineChannelsReceived?.Invoke(parsed.Value);
});
}
/// <summary>
@ -680,13 +709,17 @@ public sealed class WorldSession : IDisposable
if (parsed is not null)
PlayerKilledReceived?.Invoke(parsed.Value);
}
else if (op == 0x0295u)
else if (op == TurbineChat.Opcode)
{
// Phase I.5 stub: 0x0295 SetTurbineChatChannels lands the
// global chat channel list (Trade, LFG, etc). I.6 will
// land the parser here. For now we silently absorb the
// packet so it doesn't show up in ACDREAM_DUMP_OPCODES.
// TODO(I.6): parse + route to ChatLog.OnSetTurbineChatChannels.
// Phase I.6: 0xF7DE TurbineChat — global community chat
// (General / Trade / LFG / Roleplay / Society / Olthoi).
// Three payload variants live inside the same opcode;
// dispatch to subscribers by raising a typed event.
// SetTurbineChatChannels (0x0295) is NOT here — it's a
// sub-opcode of the 0xF7B0 GameEvent envelope and rides
// the dispatcher path (registered in the ctor).
var parsed = TurbineChat.TryParse(body);
if (parsed is not null) TurbineChatReceived?.Invoke(parsed.Value);
}
else if (op == PrivateUpdateVital.FullOpcode)
{
@ -851,6 +884,60 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <summary>
/// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a
/// global community room (General / Trade / LFG / Roleplay /
/// Society / Olthoi). Unlike <see cref="SendChannel"/> this is a
/// top-level GameMessage (0xF7DE), not a 0xF7B1 GameAction — so it
/// rides <see cref="SendGameAction"/>'s capture seam (test-friendly)
/// but skips the GameAction sequence counter.
///
/// <para>
/// <paramref name="cookie"/> must come from the parent's
/// <c>TurbineChatState.NextContextId()</c> — WorldSession does not
/// own that state because it lives at the GameWindow / chat-runtime
/// level. <paramref name="senderGuid"/> is the local player's guid
/// (the server uses it to attribute messages on the chat-server side).
/// </para>
/// </summary>
public void SendTurbineChatTo(
uint roomId,
uint chatType,
uint dispatchType,
uint senderGuid,
string text,
uint cookie)
{
ArgumentNullException.ThrowIfNull(text);
// Holtburger always sets target_type=1, target_id=0, transport_type=0,
// transport_id=0 for outbound RequestSendToRoomById (commands.rs:288-291)
// — those fields are populated by ACE on inbound events but are
// semantically empty for client-issued requests.
_ = dispatchType; // outbound is always RequestSendToRoomById; see comment
var payload = new TurbineChat.Payload.RequestSendToRoomById(
ContextId: cookie,
RoomId: roomId,
Message: text,
ExtraDataSize: 0x0Cu, // ACE-side magic per holtburger commands.rs:297
SenderId: senderGuid,
HResult: 0,
ChatType: chatType);
byte[] body = TurbineChat.Build(
blobType: TurbineChat.BlobType.RequestBinary,
dispatchType: TurbineChat.DispatchType.SendToRoomById,
targetType: 1u,
targetId: 0u,
transportType: 0u,
transportId: 0u,
cookie: 0u, // outer header cookie is 0; inner context_id is the user-visible cookie
payload: payload);
SendGameAction(body);
}
private void SendGameMessage(byte[] gameMessageBody)
{
var fragment = GameMessageFragment.BuildSingleFragment(