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,96 @@
using System;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Phase I.6: <see cref="SetTurbineChatChannels"/> 10-u32 GameEvent
/// payload parser.
///
/// <para>
/// Golden fixture taken from holtburger
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs</c>
/// lines 654-679 (test <c>set_turbine_chat_channels_fixture</c>) — itself
/// generated by ACE's <c>SyntheticProtocolTests.GenerateTurbineChatFixtures</c>.
/// </para>
/// </summary>
public sealed class SetTurbineChatChannelsTests
{
[Fact]
public void TryParse_HoltburgerGoldenFixture_RoundTrips()
{
// Full game-message body from turbine.rs:655-657:
// B0F70000 outer 0xF7B0 GameEvent opcode
// 00000000 guid 0
// 21000000 sequence 0x21
// 95020000 eventType 0x0295 (SetTurbineChatChannels)
// <40-byte payload>
byte[] full = HexDecode(
"B0F7000000000000210000009502000078563412" +
"020000000300000004000000050000000A000000" +
"09000000070000000800000009000000");
// The parser consumes only the 40-byte payload after the
// GameEventEnvelope header (16 bytes).
var env = GameEventEnvelope.TryParse(full);
Assert.NotNull(env);
Assert.Equal(GameEventType.SetTurbineChatChannels, env!.Value.EventType);
var parsed = SetTurbineChatChannels.TryParse(env.Value.Payload.Span);
Assert.NotNull(parsed);
// Allegiance: 0x12345678 (Unknown id used in fixture).
Assert.Equal(0x12345678u, parsed!.Value.AllegianceRoom);
// General..Olthoi
Assert.Equal(2u, parsed.Value.GeneralRoom);
Assert.Equal(3u, parsed.Value.TradeRoom);
Assert.Equal(4u, parsed.Value.LfgRoom);
Assert.Equal(5u, parsed.Value.RoleplayRoom);
Assert.Equal(10u, parsed.Value.OlthoiRoom);
// Society = SocietyRadiantBlood (id 9) per fixture
Assert.Equal(9u, parsed.Value.SocietyRoom);
// Sub-society rooms 7, 8, 9
Assert.Equal(7u, parsed.Value.SocietyCelestialHandRoom);
Assert.Equal(8u, parsed.Value.SocietyEldrytchWebRoom);
Assert.Equal(9u, parsed.Value.SocietyRadiantBloodRoom);
}
[Fact]
public void Serialize_RoundTripsThroughTryParse()
{
var original = new SetTurbineChatChannels.Parsed(
AllegianceRoom: 0x100,
GeneralRoom: 0x200,
TradeRoom: 0x300,
LfgRoom: 0x400,
RoleplayRoom: 0x500,
OlthoiRoom: 0x600,
SocietyRoom: 0x700,
SocietyCelestialHandRoom: 0x800,
SocietyEldrytchWebRoom: 0x900,
SocietyRadiantBloodRoom: 0xA00);
byte[] payload = SetTurbineChatChannels.Serialize(original);
Assert.Equal(SetTurbineChatChannels.PayloadSize, payload.Length);
var roundTripped = SetTurbineChatChannels.TryParse(payload);
Assert.NotNull(roundTripped);
Assert.Equal(original, roundTripped!.Value);
}
[Fact]
public void TryParse_TruncatedPayload_ReturnsNull()
{
// 39 bytes — one short of the 40-byte minimum.
Assert.Null(SetTurbineChatChannels.TryParse(new byte[39]));
}
private static byte[] HexDecode(string hex)
{
if ((hex.Length & 1) != 0) throw new ArgumentException("hex length must be even");
byte[] result = new byte[hex.Length / 2];
for (int i = 0; i < result.Length; i++)
result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
return result;
}
}

View file

@ -0,0 +1,278 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Phase I.6: <see cref="TurbineChat"/> codec round-trip tests for all
/// three payload variants and the UTF-16LE Turbine string codec.
///
/// <para>
/// Golden fixtures from holtburger
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/chat/turbine.rs</c>
/// lines 555-639 — generated by ACE's
/// <c>SyntheticProtocolTests.GenerateTurbineChatFixtures</c>.
/// </para>
/// </summary>
public sealed class TurbineChatTests
{
// ──────────────────────────────────────────────────────────────
// Round-trip fixtures (parse, then re-serialise, then parse again)
// ──────────────────────────────────────────────────────────────
[Fact]
public void TryParse_EventSendToRoom_GoldenFixture()
{
byte[] fixture = HexDecode(
"DEF700005E000000010000000100000001000000" +
"B5000B0001000000B5000B00000000003E000000" +
"020000000541006C006900630065000B68006500" +
"6C006C006F00200077006F0072006C0064000C00" +
"0000010000500000000002000000");
var parsed = TurbineChat.TryParse(fixture);
Assert.NotNull(parsed);
Assert.Equal(TurbineChat.BlobType.EventBinary, parsed!.Value.BlobType);
Assert.Equal(TurbineChat.DispatchType.SendToRoomByName, parsed.Value.DispatchType);
Assert.Equal(1u, parsed.Value.TargetType);
Assert.Equal(0x000B00B5u, parsed.Value.TargetId);
Assert.Equal(1u, parsed.Value.TransportType);
Assert.Equal(0x000B00B5u, parsed.Value.TransportId);
Assert.Equal(0u, parsed.Value.Cookie);
var ev = Assert.IsType<TurbineChat.Payload.EventSendToRoom>(parsed.Value.Body);
Assert.Equal(2u, ev.RoomId); // TurbineChatChannel.General
Assert.Equal("Alice", ev.SenderName);
Assert.Equal("hello world", ev.Message);
Assert.Equal(0x0Cu, ev.ExtraDataSize);
Assert.Equal(0x50000001u, ev.SenderId);
Assert.Equal(0, ev.HResult);
Assert.Equal(2u, ev.ChatType); // TurbineChatType.General
}
[Fact]
public void Build_EventSendToRoom_RoundTripsThroughTryParse()
{
byte[] built = TurbineChat.Build(
blobType: TurbineChat.BlobType.EventBinary,
dispatchType: TurbineChat.DispatchType.SendToRoomByName,
targetType: 1u,
targetId: 0x000B00B5u,
transportType: 1u,
transportId: 0x000B00B5u,
cookie: 0u,
payload: new TurbineChat.Payload.EventSendToRoom(
RoomId: 2u,
SenderName: "Alice",
Message: "hello world",
ExtraDataSize: 0x0Cu,
SenderId: 0x50000001u,
HResult: 0,
ChatType: 2u));
var parsed = TurbineChat.TryParse(built);
Assert.NotNull(parsed);
var ev = Assert.IsType<TurbineChat.Payload.EventSendToRoom>(parsed!.Value.Body);
Assert.Equal("Alice", ev.SenderName);
Assert.Equal("hello world", ev.Message);
}
[Fact]
public void TryParse_Response_GoldenFixture()
{
byte[] fixture = HexDecode(
"DEF7000038000000050000000100000001000000" +
"B5000B0001000000B5000B00000000001800000007000000" +
"020000000200000000000000");
var parsed = TurbineChat.TryParse(fixture);
Assert.NotNull(parsed);
Assert.Equal(TurbineChat.BlobType.ResponseBinary, parsed!.Value.BlobType);
Assert.Equal(TurbineChat.DispatchType.SendToRoomByName, parsed.Value.DispatchType);
var resp = Assert.IsType<TurbineChat.Payload.Response>(parsed.Value.Body);
Assert.Equal(7u, resp.ContextId);
Assert.Equal(2u, resp.ResponseId);
Assert.Equal(2u, resp.MethodId);
Assert.Equal(0, resp.HResult);
}
[Fact]
public void Build_Response_RoundTripsThroughTryParse()
{
byte[] built = TurbineChat.Build(
blobType: TurbineChat.BlobType.ResponseBinary,
dispatchType: TurbineChat.DispatchType.SendToRoomByName,
targetType: 1u, targetId: 0x000B00B5u,
transportType: 1u, transportId: 0x000B00B5u,
cookie: 0u,
payload: new TurbineChat.Payload.Response(
ContextId: 7u, ResponseId: 2u, MethodId: 2u, HResult: 0));
var parsed = TurbineChat.TryParse(built);
Assert.NotNull(parsed);
var resp = Assert.IsType<TurbineChat.Payload.Response>(parsed!.Value.Body);
Assert.Equal(7u, resp.ContextId);
}
[Fact]
public void TryParse_RequestSendToRoomById_GoldenFixture()
{
byte[] fixture = HexDecode(
"DEF700005D000000030000000200000001000000" +
"00000000000000000000000000000000" +
"3D000000" +
"07000000020000000200000002000000" +
"0A7400720061006400650020007300700061006D00" +
"0C000000010000500000000003000000");
var parsed = TurbineChat.TryParse(fixture);
Assert.NotNull(parsed);
Assert.Equal(TurbineChat.BlobType.RequestBinary, parsed!.Value.BlobType);
Assert.Equal(TurbineChat.DispatchType.SendToRoomById, parsed.Value.DispatchType);
var req = Assert.IsType<TurbineChat.Payload.RequestSendToRoomById>(parsed.Value.Body);
Assert.Equal(7u, req.ContextId);
Assert.Equal(2u, req.RoomId); // TurbineChatChannel.General
Assert.Equal("trade spam", req.Message);
Assert.Equal(0x0Cu, req.ExtraDataSize);
Assert.Equal(0x50000001u, req.SenderId);
Assert.Equal(0, req.HResult);
Assert.Equal(3u, req.ChatType); // TurbineChatType.Trade
}
[Fact]
public void Build_RequestSendToRoomById_RoundTripsThroughTryParse()
{
byte[] built = TurbineChat.Build(
blobType: TurbineChat.BlobType.RequestBinary,
dispatchType: TurbineChat.DispatchType.SendToRoomById,
targetType: 1u, targetId: 0u,
transportType: 0u, transportId: 0u,
cookie: 0u,
payload: new TurbineChat.Payload.RequestSendToRoomById(
ContextId: 7u,
RoomId: 2u,
Message: "trade spam",
ExtraDataSize: 0x0Cu,
SenderId: 0x50000001u,
HResult: 0,
ChatType: 3u));
var parsed = TurbineChat.TryParse(built);
Assert.NotNull(parsed);
var req = Assert.IsType<TurbineChat.Payload.RequestSendToRoomById>(parsed!.Value.Body);
Assert.Equal("trade spam", req.Message);
Assert.Equal(2u, req.RoomId);
}
[Fact]
public void TryParse_RequestRejectsNonAceRequestIds()
{
// Per turbine.rs:642-651: ACE's RequestSendToRoomById is the only
// (response_id=2, method_id=2) pair. Other ids are rejected.
byte[] fixture = HexDecode(
"DEF700005D000000030000000200000001000000" +
"00000000000000000000000000000000" +
"3D000000" +
"07000000020000000200000002000000" +
"0A7400720061006400650020007300700061006D00" +
"0C000000010000500000000003000000");
// Mutate response_id (offset 4 + 36 + 4 = 44) to 3 — must reject.
BitConverter.GetBytes(3u).CopyTo(fixture, 44);
Assert.Null(TurbineChat.TryParse(fixture));
}
// ──────────────────────────────────────────────────────────────
// UTF-16LE Turbine string codec
// ──────────────────────────────────────────────────────────────
[Fact]
public void TurbineString_ShortPrefix_RoundTrips()
{
var buf = new List<byte>();
TurbineChat.WriteTurbineString(buf, "Hello");
// 1-byte prefix, then 5 UTF-16LE code units = 5 + 1 = 11 bytes.
Assert.Equal(11, buf.Count);
Assert.Equal(5, buf[0]); // length prefix
Assert.Equal(0, 0x80 & buf[0]); // high bit clear
byte[] data = buf.ToArray();
int pos = 0;
string roundTrip = TurbineChat.ReadTurbineString(data, ref pos);
Assert.Equal("Hello", roundTrip);
Assert.Equal(data.Length, pos);
}
[Fact]
public void TurbineString_LongPrefix_RoundTrips()
{
// 200 chars > 0x80 → 2-byte prefix form.
string s = new string('a', 200);
var buf = new List<byte>();
TurbineChat.WriteTurbineString(buf, s);
// Prefix: 2 bytes; high bit of first byte set.
Assert.NotEqual(0, buf[0] & 0x80);
// Decoded length = ((b0 & 0x7F) << 8) | b1
int decodedLen = ((buf[0] & 0x7F) << 8) | buf[1];
Assert.Equal(200, decodedLen);
Assert.Equal(2 + 200 * 2, buf.Count);
byte[] data = buf.ToArray();
int pos = 0;
string roundTrip = TurbineChat.ReadTurbineString(data, ref pos);
Assert.Equal(s, roundTrip);
Assert.Equal(data.Length, pos);
}
[Fact]
public void TurbineString_NonAscii_RoundTripsAsUtf16LE()
{
// 'Café' has 4 UTF-16 code units, last with 0x00E9 — verifies
// we're using UTF-16LE rather than CP1252 like the rest of the
// chat layer.
const string s = "Café";
var buf = new List<byte>();
TurbineChat.WriteTurbineString(buf, s);
// Bytes: prefix(1) + UTF-16LE C-a-f-é = 1 + 8 = 9 bytes.
Assert.Equal(9, buf.Count);
Assert.Equal(4, buf[0]);
// 'é' = 0xE9 0x00 in UTF-16LE.
Assert.Equal(0xE9, buf[1 + 6]);
Assert.Equal(0x00, buf[1 + 7]);
byte[] data = buf.ToArray();
int pos = 0;
string roundTrip = TurbineChat.ReadTurbineString(data, ref pos);
Assert.Equal(s, roundTrip);
}
[Fact]
public void TurbineString_EmptyString_RoundTrips()
{
var buf = new List<byte>();
TurbineChat.WriteTurbineString(buf, "");
Assert.Single(buf);
Assert.Equal(0, buf[0]);
byte[] data = buf.ToArray();
int pos = 0;
Assert.Equal("", TurbineChat.ReadTurbineString(data, ref pos));
Assert.Equal(1, pos);
}
// ──────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────
private static byte[] HexDecode(string hex)
{
if ((hex.Length & 1) != 0) throw new ArgumentException("hex length must be even");
byte[] result = new byte[hex.Length / 2];
for (int i = 0; i < result.Length; i++)
result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
return result;
}
}

View file

@ -0,0 +1,61 @@
using AcDream.Core.Chat;
using Xunit;
namespace AcDream.Core.Tests.Chat;
/// <summary>
/// Phase I.6: <see cref="ChatChannelInfo"/> + <see cref="ChatChannelSource"/>
/// behaviour. Mirrors holtburger's <c>is_self_echo_channel</c> predicate
/// (chat.rs:492-507) — only the legacy fellowship + allegiance-tree
/// channels echo the sender's own messages back with empty sender.
/// </summary>
public sealed class ChatChannelInfoTests
{
[Fact]
public void Legacy_FellowshipBitflag_IsSelfEcho()
{
var c = new ChatChannelInfo.Legacy(0x00000800u, "Fellowship");
Assert.Equal(ChatChannelSource.Legacy, c.Source);
Assert.Equal("Fellowship", c.DisplayName);
Assert.True(c.IsSelfEchoChannel());
}
[Theory]
[InlineData(0x00001000u)] // Vassals
[InlineData(0x00002000u)] // Patron
[InlineData(0x00004000u)] // Monarch
[InlineData(0x01000000u)] // CoVassals
public void Legacy_AllegianceTreeBitflags_AreSelfEcho(uint id)
{
var c = new ChatChannelInfo.Legacy(id, "Allegiance");
Assert.True(c.IsSelfEchoChannel(), $"channel 0x{id:X8} should self-echo");
}
[Fact]
public void Legacy_AllegianceBroadcastBitflag_IsNOTSelfEcho()
{
// 0x02000000 AllegianceBroadcast is the read-only motd channel —
// server does not echo client messages back on it (per holtburger
// chat.rs:492-507 predicate, only Fellow+Vassals+Patron+Monarch+CoVassals).
var c = new ChatChannelInfo.Legacy(0x02000000u, "AllegianceBroadcast");
Assert.False(c.IsSelfEchoChannel());
}
[Fact]
public void Turbine_IsNeverSelfEcho()
{
// Turbine rooms don't echo — caller must emit local optimistic echo.
var t = new ChatChannelInfo.Turbine(
RoomId: 0x12345678u, ChatType: 2u, DispatchType: 2u, DisplayName: "General");
Assert.Equal(ChatChannelSource.Turbine, t.Source);
Assert.Equal("General", t.DisplayName);
Assert.False(t.IsSelfEchoChannel());
}
[Fact]
public void Legacy_UnknownBitflag_IsNotSelfEcho()
{
var c = new ChatChannelInfo.Legacy(0xCAFEF00Du, "WatCha");
Assert.False(c.IsSelfEchoChannel());
}
}

View file

@ -0,0 +1,84 @@
using AcDream.Core.Chat;
using Xunit;
namespace AcDream.Core.Tests.Chat;
/// <summary>
/// Phase I.6: <see cref="TurbineChatState"/> behaviour.
/// </summary>
public sealed class TurbineChatStateTests
{
[Fact]
public void InitialState_DisabledAndZeroRooms_NextContextIdStartsAt1()
{
var s = new TurbineChatState();
Assert.False(s.Enabled);
Assert.Equal(0u, s.GeneralRoom);
Assert.Equal(0u, s.TradeRoom);
Assert.Equal(0u, s.AllegianceRoom);
Assert.Equal(1u, s.NextContextId());
// After consuming one cookie, the next is 2
Assert.Equal(2u, s.NextContextId());
}
[Fact]
public void OnChannelsReceived_PopulatesAllFieldsAndEnables()
{
var s = new TurbineChatState();
s.OnChannelsReceived(
allegianceRoom: 0xA0,
generalRoom: 0xA1,
tradeRoom: 0xA2,
lfgRoom: 0xA3,
roleplayRoom: 0xA4,
olthoiRoom: 0xA5,
societyRoom: 0xA6,
societyCelestialHandRoom: 0xA7,
societyEldrytchWebRoom: 0xA8,
societyRadiantBloodRoom: 0xA9);
Assert.True(s.Enabled);
Assert.Equal(0xA0u, s.AllegianceRoom);
Assert.Equal(0xA1u, s.GeneralRoom);
Assert.Equal(0xA2u, s.TradeRoom);
Assert.Equal(0xA3u, s.LfgRoom);
Assert.Equal(0xA4u, s.RoleplayRoom);
Assert.Equal(0xA5u, s.OlthoiRoom);
Assert.Equal(0xA6u, s.SocietyRoom);
Assert.Equal(0xA7u, s.SocietyCelestialHandRoom);
Assert.Equal(0xA8u, s.SocietyEldrytchWebRoom);
Assert.Equal(0xA9u, s.SocietyRadiantBloodRoom);
}
[Fact]
public void NextContextId_IsMonotonicAndStartsAt1()
{
var s = new TurbineChatState();
Assert.Equal(1u, s.NextContextId());
Assert.Equal(2u, s.NextContextId());
Assert.Equal(3u, s.NextContextId());
Assert.Equal(4u, s.NextContextId());
}
[Fact]
public void RoomFor_ReturnsConfiguredRoom()
{
var s = new TurbineChatState();
s.OnChannelsReceived(
allegianceRoom: 1, generalRoom: 2, tradeRoom: 3,
lfgRoom: 4, roleplayRoom: 5, olthoiRoom: 6,
societyRoom: 7, societyCelestialHandRoom: 8,
societyEldrytchWebRoom: 9, societyRadiantBloodRoom: 10);
Assert.Equal(1u, s.RoomFor(ChatChannelKindLite.Allegiance));
Assert.Equal(2u, s.RoomFor(ChatChannelKindLite.General));
Assert.Equal(3u, s.RoomFor(ChatChannelKindLite.Trade));
Assert.Equal(4u, s.RoomFor(ChatChannelKindLite.Lfg));
Assert.Equal(5u, s.RoomFor(ChatChannelKindLite.Roleplay));
Assert.Equal(6u, s.RoomFor(ChatChannelKindLite.Olthoi));
Assert.Equal(7u, s.RoomFor(ChatChannelKindLite.Society));
Assert.Equal(8u, s.RoomFor(ChatChannelKindLite.SocietyCelestialHand));
Assert.Equal(9u, s.RoomFor(ChatChannelKindLite.SocietyEldrytchWeb));
Assert.Equal(10u, s.RoomFor(ChatChannelKindLite.SocietyRadiantBlood));
}
}