feat(chat): Campaign CH slice CH3 — side-channel membership, wire, and echo parity

Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate
so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new
TurbineChatMembershipGate checks Turbine availability and the player's own
Hear*Chat option before sending, raising "Turbine chat is not available."
or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText
chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter)
and headless (DirectGameRuntimeCommandAdapter) send paths so they can't
diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat
server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md —
ACE's TurbineChat implementation is complete and on by default; the real
bug was treating Hear*Chat as a display filter instead of room membership.

Also: implements SetSingleCharacterOption (0x0005), the only wire message
that actually joins/leaves a Turbine room, and wires the five Settings Chat
toggles to it (publish on Save, changed bits only) plus seeds ChatSettings
from the server's own CharacterOptions2 on every PlayerDescription. Fixes
the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals
skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally
consulted). Routes /a to Turbine unconditionally (retail's @a never falls
back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast
verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead
of discarding it silently. Deletes the malformed, callerless SetCharacterOptions
(0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders.

Files every AC-specific algorithm change cites the named retail decomp
(SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0,
GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks.
Register rows AP-181 (no client-side spam throttle) and UN-9 (an
incidentally-discovered CharacterOptions1.Default literal mismatch, not
investigated further) filed per the divergence-register rule.

11,957 passed / 4 skipped / 0 failed (full Release suite, up from the
11,916/4/0 baseline).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 19:39:44 +02:00
parent fc9590e4fc
commit 614a1e055f
35 changed files with 1453 additions and 229 deletions

View file

@ -207,10 +207,35 @@ public static class PlayerDescriptionParser
{
None = 0,
AllowGive = 0x00000040,
// Campaign CH slice CH3 (2026-08-09): retail's Turbine-room
// membership gate for the Allegiance chat room
// (ClientCommunicationSystem::SendTurbineChat @0x0057db10 reads
// PlayerModule::HearAllegianceChat as its "hearOption" argument for
// DoTurbineChat_Allegiance). ACE: CharacterOptions1.cs.
HearAllegianceChat = 0x40000000,
DragItemOnPlayerOpensSecureTrade = 0x04000000,
Default = 0x50C4A54A,
}
/// <summary>
/// Second character-option bitfield (the trailer's
/// <c>CharacterOptions2</c> u32, present when
/// <see cref="CharacterOptionDataFlag.CharacterOptions2"/> is set).
/// Campaign CH slice CH3 (2026-08-09): only the five <c>Hear*Chat</c>
/// membership bits retail's <c>SendTurbineChat</c> reads are modeled
/// here — ACE <c>CharacterOptions2.cs</c> has the complete bitfield.
/// </summary>
[Flags]
public enum CharacterOptions2 : uint
{
None = 0,
HearGeneralChat = 0x00000100,
HearTradeChat = 0x00000200,
HearLFGChat = 0x00000400,
HearRoleplayChat = 0x00000800,
HearSocietyChat = 0x00080000,
}
/// <summary>One inventory entry — a guid plus a ContainerType
/// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
/// <c>events.rs:143-168</c> validates <c>ContainerType &lt;= 2</c>

View file

@ -23,6 +23,24 @@ namespace AcDream.Core.Net.Messages;
/// References: r08 §3 rows for each opcode.
/// </para>
/// </summary>
/// <summary>
/// ACE <c>CharacterOption</c> ids (a LINEAR enum, distinct from the
/// <c>CharacterOptions1</c>/<c>CharacterOptions2</c> BITFIELDS) — the first
/// <c>u32</c> of a <c>SetSingleCharacterOption (0x0005)</c> payload. Only
/// the six <c>ListenTo*Chat</c> ids Campaign CH slice CH3 (2026-08-09) needs
/// are modeled here; ACE <c>Source/ACE.Entity/Enum/CharacterOption.cs</c>
/// has the complete list.
/// </summary>
public enum CharacterOptionId : uint
{
ListenToAllegianceChat = 0x1B,
ListenToGeneralChat = 0x23,
ListenToTradeChat = 0x24,
ListenToLFGChat = 0x25,
ListenToRoleplayChat = 0x26,
ListenToSocietyChat = 0x2E,
}
public static class SocialActions
{
public const uint GameActionEnvelope = 0xF7B1u;
@ -40,11 +58,15 @@ public static class SocialActions
public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open
// Character options
public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap
// Chat channels
public const uint AddChannelOpcode = 0x0145u; // string16L channelName
public const uint RemoveChannelOpcode = 0x0146u; // string16L channelName
// CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder
// and the string-payload AddChannel/RemoveChannel (0x0145/0x0146)
// builders were deleted here — none had a production caller, and all
// three were malformed against ACE's real reader (research doc
// 2026-08-09-chat-side-channels-vs-ace.md §3.8/§3.7/§5.5). Only
// SetSingleCharacterOption (0x0005) — the message that actually
// changes Turbine room membership — has a caller (WorldSession.
// SendSetSingleCharacterOption), so only it is implemented.
public const uint SetSingleCharacterOptionOpcode = 0x0005u; // u32 optionId, u32 value (0/1)
/// <summary>Query a target's health — server replies with UpdateHealth (0x01C0).</summary>
public static byte[] BuildQueryHealth(uint seq, uint targetGuid)
@ -132,25 +154,29 @@ public static class SocialActions
return body;
}
/// <summary>Push the client's character-options bitmap to the server.</summary>
public static byte[] BuildSetCharacterOptions(uint seq, uint optionsBitmap)
/// <summary>
/// Toggle one character option and push it to the server.
/// <c>GameActionSetSingleCharacterOption @ GameActionType 0x0005</c> —
/// the ONLY wire message that changes Turbine room membership: for the
/// <c>ListenTo*Chat</c> option ids, ACE's handler both flips the option
/// AND calls <c>JoinTurbineChatChannel</c>/<c>LeaveTurbineChatChannel</c>
/// (re-pushing <c>SetTurbineChatChannels</c>). Payload
/// <c>u32 option, u32 value</c> confirmed against ACE
/// (<c>GameActionSetSingleCharacterOption.cs:11-12</c>) and holtburger
/// (<c>SetSingleCharacterOptionActionData::pack</c>,
/// <c>messages/player/actions.rs:149-157</c>) — both agree byte-for-byte.
/// </summary>
public static byte[] BuildSetSingleCharacterOption(uint seq, uint optionId, bool value)
{
byte[] body = new byte[16];
byte[] body = new byte[20];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetCharacterOptionsOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionsBitmap);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetSingleCharacterOptionOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), value ? 1u : 0u);
return body;
}
/// <summary>Subscribe to a named chat channel.</summary>
public static byte[] BuildAddChannel(uint seq, string channelName)
=> SingleString(seq, AddChannelOpcode, channelName);
/// <summary>Unsubscribe from a named chat channel.</summary>
public static byte[] BuildRemoveChannel(uint seq, string channelName)
=> SingleString(seq, RemoveChannelOpcode, channelName);
// ── Helpers ──────────────────────────────────────────────────────────────
private static byte[] SingleGuid(uint seq, uint sub, uint guid)
@ -163,17 +189,6 @@ public static class SocialActions
return body;
}
private static byte[] SingleString(uint seq, uint sub, string s)
{
byte[] str = PackString16L(s);
byte[] body = new byte[12 + str.Length];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
Array.Copy(str, 0, body, 12, str.Length);
return body;
}
private static byte[] PackString16L(string s)
{
ArgumentNullException.ThrowIfNull(s);

View file

@ -2195,13 +2195,14 @@ public sealed class WorldSession : IDisposable
}
/// <summary>
/// Send retail SetCharacterOptions (0x01A1) for the first character
/// option bitmap.
/// Send retail SetSingleCharacterOption (0x0005) — toggles one character
/// option. For the six <c>ListenTo*Chat</c> ids this is the message that
/// actually joins/leaves a Turbine room server-side (CH3, 2026-08-09).
/// </summary>
public void SendSetCharacterOptions(uint options)
public void SendSetSingleCharacterOption(uint optionId, bool value)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildSetCharacterOptions(seq, options));
SendGameAction(SocialActions.BuildSetSingleCharacterOption(seq, optionId, value));
}
public void SendAddFriend(string name)