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
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue