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>
278 lines
11 KiB
C#
278 lines
11 KiB
C#
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;
|
|
}
|
|
}
|