acdream/docs/research/2026-08-09-chat-side-channels-vs-ace.md
Erik e07fba5731 fix(chat): CH3 review fixes — phantom UN-9, allegiance-broadcast echo, /a legacy fallback
Applies the Opus review of Campaign CH slice CH3 (614a1e05):

- B1: UN-9 was a phantom divergence — ACE's CharacterOptions1.cs:47
  OR-sum is 0x50C4A54A (its own comment confirms 1355064650), identical
  to acdream's literal. The wrong 0x50C48D4A existed only in the research
  doc. Row deleted, register §5 reverted to 4 rows, research doc corrected
  with dated notes.
- S1/S4: AllegianceBroadcast (0x02000000) is a server-echoing channel —
  ACE's GameActionChatChannel handler includes the sender in its real-name
  Allegiance.Members broadcast (retail's DoAllegianceBroadcast has no
  AddTextToScroll), so the client must skip its local optimistic echo, not
  keep it. ChatChannelInfo.Legacy.IsSelfEchoChannel() now returns true for
  it; RouteLegacyChannel's comment corrected; Turbine.IsSelfEchoChannel()'s
  backwards comment rewritten truthfully.
- S3: retail's /a stays on the legacy AllegianceBroadcast bitflag until
  StartupTurbineChatSystem successfully starts Turbine chat — "never
  started" (TurbineChatState.Enabled == false) now falls back to legacy in
  both LiveSessionCommandRouter.RouteChat and
  DirectGameRuntimeCommandAdapter.TrySendChannel, while "enabled but no
  allegiance room" still correctly refuses locally.
- S5: added a LiveSessionEventRouter test proving the Options.Replace ->
  OnCharacterOptionsChanged seeding order, and RuntimeSettingsTargets /
  GameWindowLiveSessionOwnershipTests tests proving the concrete
  ICommandBus.Publish wiring and the single LiveSessionCommandSurface
  construction site.
- S6: AP-181 rewritten to name both of retail's omitted pre-send checks
  (IsMessageSafe silent-drop, then IsMessageSpam) and stop misattributing
  either to RouteLegacyChannel, which has no such gates.
- N1-N7: CharacterOptionId moved below SocialActions so its doc comment
  re-attaches; TurbineChatMembershipGate reuses TurbineChatDisplayNames
  instead of a duplicate table; the gate-to-refusal-text mapping is now
  shared via TurbineChatMembershipGate.ResolveRefusalText instead of
  duplicated in both hosts; ChatSettings.Default now matches ACE's real
  CharacterOptions2.Default (Roleplay/Society start off); a doc-comment
  clarifies only the five Hear toggles are server-backed; the register's
  §3 header recounted 129 -> 128.

Suite: 11,964 passed / 4 skipped / 0 failed (baseline 11,957/4/0 + 7 new
tests). Campaign ledger CH3 review column updated to APPROVE-WITH-FIXES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:24:29 +02:00

39 KiB
Raw Blame History

Chat side channels vs current ACE — R4 research lane

Campaign: CH (chat & interface-text retail parity), docs/plans/2026-08-09-chat-parity-campaign.md lane R4. Date: 2026-08-09. Mode: research only, no code changed. Sources: current vendored ACE at references/ACE/, named retail decomp at docs/research/named-retail/, holtburger + Chorizite.ACProtocol cross-checks, acdream src/ as of 2914e43a.


0. Verdict summary

Family Verdict
The 26-day-old claim ("ACE doesn't run a TurbineChat server") FALSE. ACE has a complete TurbineChat implementation, on by default, and we have live logs proving it reaches us.
Turbine General / Trade / LFG Wire-correct end to end. Should already work. If they don't, the cause is a live-state issue, not a codec issue — see the probe in §7.1.
Turbine Roleplay Broken, root cause found. ACE never joins the player to Roleplay because HearRoleplayChat is not in ACE's CharacterOptions2.Default, ACE then filters the sender out of its own broadcast, and acdream neither gates the send nor echoes locally → total silence.
Turbine Society / Olthoi Correctly unavailable (no society, not an Olthoi player). Retail shows a specific refusal; we show nothing.
Turbine Allegiance (/a) Wrong transport when the player has no allegiance: acdream silently downgrades to the legacy AllegianceBroadcast bitflag. Retail never does this.
Legacy /f /v /p /m /cv Wire-correct outbound and inbound. Two presentation defects: every line double-prints, and the "not in a fellowship / not in an allegiance" refusals do reach us (they are wired) but the send still looks like it worked.
Client-side Hear* settings The Settings panel's six "Hear … Chat" toggles are dead — never applied to display, never sent to the server.
SetCharacterOptions (0x01A1) acdream's builder is malformed — latent, currently unreachable from the UI, but it would corrupt server-side options if ever wired up.
AddChannel / RemoveChannel (0x0145/0x0146) acdream's builders send the wrong payload type (string vs u32 bitfield). Unused today; admin-only on ACE.

One-line root cause for "side channels don't work": we treat the Hear<Channel>Chat character options as a local display preference. Retail and ACE treat them as channel membership. ACE filters the sender out of its own broadcast when the sender's option is off, and retail refuses the send outright with a named error. We do neither, so the message vanishes with no feedback.


1. The refuted claim

docs/ISSUES.md:15214 and docs/plans/2026-04-11-roadmap.md:429,948 both carry:

Note: ACE doesn't run a TurbineChat server — codec is ready for retail-server-emulating setups.

This is wrong, and has been wrong the whole time. Evidence, strongest first:

  1. ACE source. references/ACE/Source/ACE.Server/Network/Handlers/TurbineChatHandler.cs is a full 387-line inbound handler registered [GameMessage(GameMessageOpcode.TurbineChat, SessionState.WorldConnected)]. GameMessageTurbineChat.cs is the outbound writer. TurbineChatChannel.cs holds the room-id constants.

  2. It is on by default. PropertyManager.cs:608("use_turbine_chat", new Property<bool>(true, …)).

  3. We have already received it, repeatedly. Our own launch logs contain the parsed 0x0295 payload with live room ids, e.g. docs/research/2026-08-07-339-portal-space-hang.log:55 and eleven 2026-05-21/2026-05-23 capture logs:

    chat: SetTurbineChatChannels parsed enabled=True general=0x00000002
    trade=0x00000003 lfg=0x00000004 roleplay=0x00000005 society=0x00000000
    olthoi=0x0000000A allegiance=0x00000000
    

    Those are exactly ACE's TurbineChatChannel constants. TurbineChatState.Enabled has been true against local ACE since at least 2026-05-21.

Action: both the ISSUES entry and the two roadmap rows must be corrected in the CH3 commit.


2. acdream inventory (current truth)

2.1 Codec — src/AcDream.Core.Net/Messages/TurbineChat.cs

Builds and parses the 0xF7DE message. Header is opcode + 9 u32 (36 bytes):

Off Field Build value (outbound request)
0 u32 opcode 0xF7DE
4 u32 sizeFirst 40 + payloadLen
8 u32 blobType 3 RequestBinary
12 u32 dispatchType 2 SendToRoomById
16 u32 targetType 1
20 u32 targetId 0
24 u32 transportType 0
28 u32 transportId 0
32 u32 cookie 0
36 u32 sizeSecond 8 + payloadLen

Request payload (WritePayload, TurbineChat.cs:371-381): u32 contextId, u32 2, u32 2, u32 roomId, turbine-string message, u32 0x0C, u32 senderId, u32 0 hresult, u32 chatType.

Turbine string codec (ReadTurbineString/WriteTurbineString, TurbineChat.cs:406-463): 1-or-2-byte packed length in UTF-16 code units, then UTF-16LE bytes, no padding. This is the only acdream string type that is not CP1252 String16L.

Parse handles three shapes: (EventBinary, SendToRoomByName), (RequestBinary, SendToRoomById) with a hard reject unless the inner response/method ids are both 2, and (ResponseBinary, *). Unknown blob/dispatch pairs are captured verbatim rather than rejected.

2.2 Room table — src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs

Parses the 40-byte 0x0295 GameEvent payload as 10 u32 in order: allegiance, general, trade, lfg, roleplay, olthoi, society, societyCelHan, societyEldWeb, societyRadBlo. Registered in the WorldSession ctor (WorldSession.cs:837) and again in GameEventWiring.cs:184-213; the latter is the one that actually feeds TurbineChatState.OnChannelsReceived and prints the diagnostic line quoted in §1. WorldSession.TurbineChannelsReceived has no production subscriber — dead event surface, harmless.

2.3 State — src/AcDream.Core/Chat/TurbineChatState.cs

Holds Enabled + the ten room ids + a per-session context cookie starting at 1 and wrapping to 1. Reset() clears everything on session replace. RoomFor() maps a ChatChannelKindLite to a room id. Nothing in this class knows about the Hear* options.

2.4 Channel classification — src/AcDream.Core/Chat/ChatChannelInfo.cs

Legacy(channelId) vs Turbine(roomId, chatType, dispatchType), plus IsSelfEchoChannel(): true for legacy Fellow/Vassals/Patron/Monarch/CoVassals, false for Turbine. This type is never consulted by any production send path. It exists only for its own unit tests (ChatChannelInfoTests). The ChannelResolver referenced in the brief lives at src/AcDream.UI.Abstractions/ChannelResolver.cs, not under Core/Chat/.

2.5 Legacy resolver — src/AcDream.UI.Abstractions/ChannelResolver.cs

Kind Id ACE Channel Match
Fellowship 0x00000800 Fellow
Allegiance 0x02000000 AllegianceBroadcast ✔ value, ✘ semantics (§5.3)
Vassals 0x00001000 Vassals
Patron 0x00002000 Patron
Monarch 0x00004000 Monarch
CoVassals 0x01000000 CoVassals

2.6 Outbound send paths

Graphical, src/AcDream.App/Net/LiveSessionCommandRouter.cs:247-286:

SendChatCmd
 ├ Say  → SendTalk (no local echo — waits for the authoritative line)
 ├ Tell → SendTell + Chat.OnSelfSent(Tell)
 ├ TurbineChatRouting.Resolve(kind, TurbineChat) → non-null?
 │    → SendTurbineChat(room, chatType, SendToRoomById, playerGuid, text, cookie)
 │      (NO OnSelfSent)
 ├ ChannelResolver.Resolve(kind) → non-null?
 │    → SendChannel(legacyId, text) + Chat.OnSelfSent(Channel, displayName)
 └ else → log "dropped" and return

TurbineChatRouting.Resolve (same file, ~line 388) gates only on state.Enabled and Room != 0.

Headless, src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:949-990 — same order, same gate, no echo either way.

Wire builders:

  • WorldSession.SendTurbineChatTo (WorldSession.cs:2602-2638) — hard-codes targetType=1, everything else 0, extraDataSize=0x0C, outer cookie=0, inner contextId = the caller's cookie. Sends through SendGameActionSendGameMessageGameMessageGroup.UIQueue.
  • WorldSession.SendChannel (WorldSession.cs:2090-2096) → ChatRequests.BuildChatChannel (ChatRequests.cs:89-100): [0xF7B1][seq][0x0147][u32 channelId][String16L message], CP1252, 4-byte aligned.

2.7 Inbound handling

  • 0xF7DEWorldSession.cs:1855-1866 parses and raises TurbineChatReceived; LiveSessionEventRouter.cs:280-283 subscribes; RouteTurbineChat (LiveSessionEventRouter.cs:479-489) forwards only EventSendToRoom into ChatLog.OnChannelBroadcast(roomId, sender, text, displayName). Response (the ack) and Unknown are silently discarded — no logging, no hResult inspection.
  • 0x0147 ChannelBroadcastGameEventWiring.cs:102-106GameEvents.ParseChannelBroadcast (u32 channelId, String16L sender, String16L message) → ChatLog.OnChannelBroadcast.
  • 0x028A / 0x028B WeenieError(WithString) — wired at GameEventWiring.cs:220-229 into ChatLog.OnWeenieError, and WeenieErrorMessages has the relevant strings: 0x051D "Turbine Chat is enabled.", 0x051B/0x051C channel enter/leave, 0x0414 not in allegiance, 0x050F not in a Fellowship.
  • RenderingChatVM.FormatEntry renders ChatKind.Channel as [Name] Sender says, "text", or [Name] You say, "text" when the sender is empty or "You". ChatWindowController.RetailChatColor gives Channel colorLightBlue. No filtering by channel anywhere.

2.8 Command parsing

ChatCommandRouter.Submit → retail client-command catalog → local /help → degenerate-prefix guard → unknown-verb @-passthrough → ChatInputParser.Parse. ChatInputParser.ChannelVerbs (ChatInputParser.cs:55-82) covers /g /general /gen /f /fellow /fellowship /a /allegiance /m /monarch /p /patron /v /vassals /cv /covassals /lfg /lookingforgroup /trade /tr /role /rp /roleplay /society /olthoi. No collisions with RetailClientCommandCatalog. The verb layer is fine.


3. The ACE contract

3.1 TurbineChat inbound — TurbineChatHandler.cs:20-60

Registered [GameMessage(GameMessageOpcode.TurbineChat, SessionState.WorldConnected)]. Reads, in order (all little-endian u32 unless noted):

# Field ACE comment / use
1 size "Bytes to follow" — read and discarded
2 chatBlobType must be 3 NETBLOB_REQUEST_BINARY, else "Unhandled" console line
3 chatBlobDispatchType 1 byName / 2 byId — only affects channel re-derivation
4 "Always 1"
58 "Always 0" ×4
9 size "Bytes to follow" — read and discarded
gag check session.Player.IsGaggedSendGagError(), return
10 contextId echoed in the ack
11 "Always 2"
12 "Always 2"
13 channelID room id
14 message packed-byte length (&0x80 ⇒ 2-byte), then len*2 bytes, Encoding.Unicode
15 "Always 0x0C"
16 senderID client-supplied, not validated
17 "Always 0"
18 chatType ChatType enum

acdream's outbound matches this field-for-field. Sizes match too: ACE backpatches firstSize = end - firstSizePos + 4 = 40 + payloadLen and secondSize = end - secondSizePos + 4 = 8 + payloadLen, identical to TurbineChat.Build lines 332-333.

3.2 TurbineChat outbound — GameMessageTurbineChat.cs:79-136

Sent on GameMessageGroup.LoginQueue.

NETBLOB_EVENT_BINARY (the broadcast): header is size, 1, 1, 1, 0x000B00B5, 1, 0x000B00B5, 0, size; payload is u32 channel, packed-byte + UTF-16 senderName, packed-byte + UTF-16 message, u32 0x0C, u32 senderID, u32 0, u32 chatType. Dispatch type is always ASYNCMETHOD_SENDTOROOMBYNAME (1) even for a byId request. acdream's (EventBinary, SendToRoomByName) parse matches.

NETBLOB_RESPONSE_BINARY (the ack): same header, payload u32 contextId, u32 2, u32 2, u32 0. acdream parses it, then throws it away (RouteTurbineChat early-returns).

ACE bug worth knowing: the ≥128-char branch of the senderName prefix writes message.Length, not senderName.Length (GameMessageTurbineChat.cs:98). Unreachable in practice (no AC character name is ≥128 chars).

3.3 Room-id table — TurbineChatChannel.cs

Allegiance=1, General=2, Trade=3, LFG=4, Roleplay=5, Society=6, SocietyCelestialHand=7, SocietyEldrytchWeb=8, SocietyRadiantBlood=9, Olthoi=10.

GameEventSetTurbineChatChannels writes 10 u32: allegiance, 2, 3, 4, 5, 10, society, 7, 8, 9. Only allegiance and society are dynamic — allegiance is the allegiance's Biota.Id (a large uint, not the sentinel 1), society is 7/8/9 or 0.

Critical: this table is sent whole every time, regardless of which channels the player is actually joined to. roleplay=0x00000005 in our log means "the Roleplay room's id is 5", not "you are in the Roleplay room."

3.4 When ACE pushes 0x0295 — Player_Networking.cs:85-110

if (PropertyManager.GetBool("use_turbine_chat").Item) {
    EnqueueSend(new GameEventWeenieError(Session, WeenieError.TurbineChatIsEnabled));
    if (IsOlthoiPlayer) JoinTurbineChatChannel("Olthoi");
    else {
        if (GetCharacterOption(ListenToAllegianceChat) && Allegiance != null) JoinTurbineChatChannel("Allegiance");
        if (GetCharacterOption(ListenToGeneralChat))  JoinTurbineChatChannel("General");
        if (GetCharacterOption(ListenToTradeChat))    JoinTurbineChatChannel("Trade");
        if (GetCharacterOption(ListenToLFGChat))      JoinTurbineChatChannel("LFG");
        if (GetCharacterOption(ListenToRoleplayChat)) JoinTurbineChatChannel("Roleplay");
        if (GetCharacterOption(ListenToSocietyChat) && Society != FactionBits.None) JoinTurbineChatChannel("Society");
    }
}

JoinTurbineChatChannel sends WeenieErrorWithString.YouHaveEnteredThe_Channel (0x051B) then SendTurbineChatChannels(). Also fired from GameActionSetSingleCharacterOption when the client toggles a Hear* option, and from allegiance changes.

3.5 Character options — the gate that matters

CharacterOptions2 (ACE.Entity/Enum/CharacterOptions2.cs):

Bit Name In Default?
0x00000100 HearGeneralChat yes
0x00000200 HearTradeChat yes
0x00000400 HearLFGChat yes
0x00000800 HearRoleplayChat NO
0x00080000 HearSocietyChat NO

Default = 0x00948700. CharacterOptions1.Default includes HearAllegianceChat (0x40000000); CharacterOptions1.Default = 0x50C4A54A (corrected 2026-08-09 at the CH3 Opus review — the original filing here had 0x50C48D4A, a copy error with no basis in ACE's own source; ACE's CharacterOptions1.cs:47 OR-sum is 0x50C4A54A, confirmed by its own inline comment // 1355064650, and is identical to acdream's PlayerDescriptionParser.cs:217. This wrong literal is what filed the now-retracted UN-9 register row). PlayerFactory.CharacterCreateSetDefaultCharacterOptions sets exactly these two defaults on every new character.

acdream's RuntimeCharacterOptionsState.DefaultOptions2 = 0x00948700 (RuntimeCharacterState.cs:609) — byte-identical to ACE. We already model the right value; we just don't act on it.

3.6 Delivery gates on a room send

Every branch of TurbineChatHandler broadcasts with foreach (var recipient in PlayerManager.GetAllOnline())the sender is in that set. There is no if (recipient == session.Player) continue. So the sender's own copy comes back through the normal event path, subject to the same per-recipient filter:

if (channelID == General   && !recipient.GetCharacterOption(ListenToGeneralChat)  ||
    channelID == Trade     && !recipient.GetCharacterOption(ListenToTradeChat)    ||
    channelID == LFG       && !recipient.GetCharacterOption(ListenToLFGChat)      ||
    channelID == Roleplay  && !recipient.GetCharacterOption(ListenToRoleplayChat))
    continue;

Separately, the sender always gets a NETBLOB_RESPONSE_BINARY ack.

Config gates, with defaults from PropertyManager.cs:

Property Default Effect if tripped
use_turbine_chat true whole 0xF7DE path is a no-op
chat_disable_general/trade/lfg/roleplay false per-channel kill switch
chat_echo_only false loops back to sender only
chat_requires_account_15days false reject
chat_requires_account_time_seconds 0 reject
chat_requires_player_age 0 reject
chat_requires_player_level 0 reject
chat_echo_reject / chat_inform_reject whether a rejected sender sees anything
Player.IsGagged false drop + gag error

On a stock local ACE none of these block General/Trade/LFG. The only live filter is the per-recipient CharacterOption.

3.7 Legacy channels — GameActionChatChannel.cs

One multiplexed action, GameActionType.ChatChannel = 0x0147: u32 Channel then String16L message. acdream's BuildChatChannel matches.

Outbound GameEventChannelBroadcast (GameEventType 0x0147): u32 channel, String16L senderName, String16L messageText. acdream's ParseChannelBroadcast matches.

Channel Precondition Success Failure
Fellow 0x800 has Fellowship broadcast to members (real name) + self-echo with senderName="" WeenieError.YouDoNotBelongToAFellowship (0x050F)
Vassals 0x1000 allegiance + TotalVassals != 0 broadcast + self-echo "" YouAreNotInAllegiance (0x0414) / YouCantUseThatChannel (0x0423)
Patron 0x2000 allegiance + PatronId send to patron + self-echo "" same pair
Monarch 0x4000 allegiance + MonarchId send to monarch + self-echo "" same pair
CoVassals 0x1000000 allegiance + PatronId patron + siblings + self-echo "" same pair
AllegianceBroadcast 0x2000000 allegiance + permission ≥ Speaker broadcast to all members, sender included with real name (no "" echo) YouAreNotInAllegiance / YouDoNotHaveAuthorityInAllegiance (0x0535)
Help 0x400 none placeholder text, explicitly unfinished
unknown Console.WriteLine, silently dropped, nothing sent to client

ACE quirk: the Fellow branch's else fires the self-echo once per non-qualifying member, so a fellowship with N squelchers gives the sender N+1 self-echoes.

AddChannel (0x0145) / RemoveChannel (0x0146) read a u32 Channel bitfield, not a string, and return early unless the player is an Advocate/admin with the channel in ChannelsAllowed.

3.8 SetCharacterOptions (0x01A1)GameActionSetCharacterOptions.cs

u32 flags            (CharacterOptionDataFlag)
i32 characterOptions1     → SetCharacterOptions1()   [always read]
[Shortcut block if flags & 0x01]
u32 numTab1Spells + spells                          [always read]
[MultiSpellList 0x04] [ExtendedMultiSpellLists 0x10] [SpellLists8 0x400]
[DesiredComps 0x08] [SpellbookFilters 0x20]
[CharacterOptions2 0x40]  → i32 → SetCharacterOptions2()
[TimestampFormat 0x80] [GenericQualitiesData 0x100] [GameplayOptions 0x200]

Also refused entirely before FirstEnterWorldDone.

SetSingleCharacterOption is GameActionType = 0x0005, payload u32 option, u32 value. For the six ListenTo* options its handler sets the option and calls JoinTurbineChatChannel / LeaveTurbineChatChannel. This is the only wire message that changes Turbine room membership.


4. Retail truth

4.1 Rooms are pushed, never requested

ClientCommunicationSystem::Handle_Communication__Recv_ChatRoomTracker @ 0x0056e4f0:

gmCCommunicationSystem::SetChatRoomTracker(arg2);
if (tracker) {
    // BN renders the field read as GetMouseX(tracker) — heuristic artifact
    bool hasAllegRoom = tracker->m_allegianceRoomID != 0;
    gmCCommunicationSystem::SetTalkFocusEnabled(7, hasAllegRoom);
    if (WantsToBeInAllegChat() && hasAllegRoom) SetTalkFocus(7);
}

Pure passive storage plus a UI affordance. No join request is emitted anywhere in the decomp. The retail struct (acclient.h:40716) is 10 × u32 in exactly the order ACE writes:

struct ChatRoomTracker : PackObj {
  unsigned int m_allegianceRoomID, mGeneralChatRoomID, mTradeChatRoomID,
               mLFGChatRoomID, mRoleplayChatRoomID, mOlthoiChatRoomID,
               mSocietyChatRoomID, mSocietyCelHanChatRoomID,
               mSocietyEldWebChatRoomID, mSocietyRadBloChatRoomID;
};

4.2 What retail sends into a room — and the gate we're missing

Each per-channel entry point reads its own field off the tracker and passes the player's own Hear* option as the last argument (acclient_2013_pseudo_c.txt:394282-394447):

DoTurbineChat_General   SendTurbineChat(this, tracker.mGeneralChatRoomID,  General_ChatTypeEnum,  &text, PlayerModule::HearGeneralChat(...));
DoTurbineChat_Trade     SendTurbineChat(this, tracker.mTradeChatRoomID,    Trade_ChatTypeEnum,    &text, PlayerModule::HearTradeChat(...));
DoTurbineChat_LFG       SendTurbineChat(this, tracker.mLFGChatRoomID,      LFG_ChatTypeEnum,      &text, PlayerModule::HearLFGChat(...));
DoTurbineChat_Roleplay  SendTurbineChat(this, tracker.mRoleplayChatRoomID, Roleplay_ChatTypeEnum, &text, PlayerModule::HearRoleplayChat(...));
DoTurbineChat_Olthoi    SendTurbineChat(this, tracker.mOlthoiChatRoomID,   Olthoi_ChatTypeEnum,   &text, CPlayerSystem::IsOlthoi() != 0);
DoTurbineChat_Allegiance  SendTurbineChat(this, tracker.m_allegianceRoomID, Allegiance_ChatTypeEnum, &text, PlayerModule::HearAllegianceChat(...));

ClientCommunicationSystem::SendTurbineChat @ 0x0057db10this is the load-bearing function for the whole diagnosis:

if (!CCommunicationSystem::IsUsingTurbineChat() || roomId <= 0) {
    AddTextToScroll("Turbine chat is not available.\n");     // local, nothing sent
    return;
}
if (hearOption == 0) {                                       // 0x0057db37
    ChannelSystem::GetGlobalChannelName(chatType, &name);
    HandleFailureEvent(this, 0x551, &name);                  // YouAreNotListeningTo_Channel
    return 1;                                                // NOTHING IS SENT
}
if (!IsMessageSafe(text)) return;
if (IsMessageSpam())  { AddTextToScroll("You must wait %ds before communi…"); return 0; }
blob = new TurbineChatBlob{ m_targetID = GetPlayerID(), m_ChatType = chatType };
if (CCommunicationSystem::CSendToTurbineRoomByID(roomId, wideText, ...) failed)
    AddTextToScroll("Failed to send text to channel: …");

Three distinct retail refusals, all locally raised, none of which acdream has:

Condition Retail behaviour
Turbine off, or room id 0 prints Turbine chat is not available.
Player's own Hear* option off raises 0x0551 YouAreNotListeningTo_Channel"You are not listening to the <X> channel."
Spam throttle prints You must wait Ns before communicating again

0x0551 is confirmed as YouAreNotListeningTo_Channel in references/ACE/Source/ACE.Entity/Enum/WeenieErrorWithString.cs:479.

Retail emits no local echo on success — it relies on the server's broadcast coming back, exactly as ACE's GetAllOnline() loop provides. That is consistent and correct: the option gate is what guarantees the echo will arrive.

4.3 /a is Turbine, not legacy

StartupTurbineChatSystem @ 0x00594451ff. binds the command strings "a" and "guild" to DoTurbineChat_Allegiance. Once Turbine chat is up, /a is overridden away from the generic ChannelSystem::GetChannelID("allegiance") → 0x02000000 → Event_ChannelBroadcast path. Retail never silently downgrades /a to the legacy bitflag — with no allegiance room it hits the roomId <= 0 branch and prints "Turbine chat is not available."

4.4 Legacy family wire shape

CM_Communication::Event_ChannelBroadcast @ 0x006a4030 writes u32 0x0147, u32 channelBitmask, packed string, DWORD-aligned — inside the ordinary GameAction (OrderHdr) envelope. Call sites confirm the bitmasks: 0x800 Fellow, 0x2000 Patron, 0x4000 Monarch, 0x1000 Vassals, 0x2000000 AllegianceBroadcast, 0x400 Help. acdream's BuildChatChannel is byte-identical.

4.5 Inbound 0xF7DE in retail

Client::ProcessLogonEventQueue (line 18426, pumped every frame from Client::UseTime) strips the 4-byte tag + 4-byte length and hands the raw remainder to IChatClient vtable slot 3 — implemented in chatclient.dll, outside the PDB. So the inner blob layout is not directly observable in acclient.exe; our field table is ACE's reconstruction, independently corroborated by TurbineChatBlob being exactly 12 bytes (matching the hard-coded extraDataSize = 0x0C) and by ChatRoomTracker's field order.

Reference-quality caveat: holtburger's turbine.rs fixtures are labelled Generated from ACE: SyntheticProtocolTests.GenerateTurbineChatFixtures. It corroborates ACE; it is not an independent retail capture. Our codec cites holtburger throughout — that citation chain ultimately terminates at ACE, which is fine here because ACE is the server we talk to, but it should not be described as retail-verified.


5. Per-family defect diagnosis

5.1 Turbine General / Trade / LFG — wire-correct, should already work

  • Outbound bytes match TurbineChatHandler's reader field-for-field, including both self-referential size dwords (§3.1).
  • Inbound EventBinary/SendToRoomByName matches ACE's writer (§3.2), the payload-length arithmetic reconciles (sizeSecond - 8 == payloadLen), and RouteTurbineChat delivers it to ChatLog with the right display name.
  • ACE's defaults leave HearGeneralChat/TradeChat/LFGChat on, so the sender is not filtered out of GetAllOnline() and their own line comes back.
  • No config gate blocks these on stock ACE.

Defect (latent, becomes fatal the moment the option is off): we never check the option before sending, and we emit no local echo. If the user (or a future Settings sync) ever turns General off server-side, /g becomes silent with zero feedback instead of retail's "You are not listening to the General channel."

Defect (real, minor): we discard the ResponseBinary ack including its hResult, so a server-side rejection is invisible.

5.2 Turbine Roleplay — BROKEN. This is the headline defect.

Causal chain, every link verified:

  1. ACE gives new characters CharacterOptions2.Default = 0x00948700, which omits HearRoleplayChat (0x800) (CharacterOptions2.cs).
  2. At login ACE therefore skips JoinTurbineChatChannel("Roleplay") (Player_Networking.cs:104). The player is never in the room.
  3. 0x0295 still reports roleplay=0x00000005 because the table is a constant dump — this is exactly the log line that made the channel look available.
  4. acdream's TurbineChatRouting.Resolve sees RoleplayRoom == 5 != 0 and Enabled == true, so /rp hello is sent, correctly framed.
  5. ACE parses it fine, builds the broadcast, then in the recipient loop hits channelID == Roleplay && !recipient.GetCharacterOption(ListenToRoleplayChat) → continueand the sender is one of those recipients.
  6. The sender receives only the ResponseBinary ack, which RouteTurbineChat throws away.
  7. acdream emits no local echo for Turbine channels.

Net: the message is silently swallowed. Retail would have refused at step 4 with YouAreNotListeningTo_Channel.

Same chain applies to Society (HearSocietyChat also absent from Default, plus no society → SocietyRoom == 0, so we at least fall through to the "dropped" log) and Olthoi (IsOlthoiPlayer false → ACE returns at TurbineChatHandler.cs:146, no error to the client at all).

Aggravating factor: src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs defaults HearRoleplayChat: true and the Settings panel shows a checked box — the UI actively tells the user Roleplay is on while the server has it off. Those six toggles are read from and written to settings.json and consumed by nothing: no display filter, no wire message. Confirmed by grep — the only non-Settings references are the store and the panel itself.

5.3 Turbine Allegiance (/a) — wrong transport on the no-allegiance path

TurbineChatRouting.Resolve returns null when AllegianceRoom == 0, so /a falls through to ChannelResolver → legacy AllegianceBroadcast 0x02000000 → ACE replies YouAreNotInAllegiance. The user does get an error, so this is low-severity, but:

  • Retail binds /a to DoTurbineChat_Allegiance unconditionally (§4.3) and prints "Turbine chat is not available." instead.
  • With an allegiance, retail's /a and acdream's /a agree (both Turbine).
  • We have no verb for /ab (allegiance broadcast), which is the command that legitimately maps to 0x02000000. So we have the right id bound to the wrong verb, and the right verb missing.

Also unhandled: ACE's allegiance room id is Allegiance.Biota.Id, a large uint well above Olthoi (10). TurbineChatDisplayNames.Resolve keys off chatType not roomId, so it renders correctly — no defect, worth a note.

5.4 Legacy /f /v /p /m /cvwire-correct, presentation broken

Outbound [0x0147][channelId][String16L] matches both ACE's reader and retail's Event_ChannelBroadcast. Inbound parse matches GameEventChannelBroadcast. Channel ids all match. Errors are wired and have strings.

Defect — double print. ACE sends the sender a second GameEventChannelBroadcast with senderName = "", which ChatVM renders as [Fellowship] You say, "…". LiveSessionCommandRouter.cs:283-286 also calls Chat.OnSelfSent(ChatKind.Channel, …), which renders the same line. Result: every /f, /v, /p, /m, /cv line appears twice.

ChatChannelInfo.IsSelfEchoChannel() exists precisely to prevent this — its doc comment says "caller should suppress optimistic local echo to avoid double-printing" — and no production code calls it. The abstraction was built and never wired.

(Note AllegianceBroadcast 0x02000000 does not get the "" echo — the sender appears in the normal member iteration with their real name — so for that one channel the local OnSelfSent is arguably right. A correct fix must branch, not blanket-remove.)

Defect — silent failure looks like success. On YouDoNotBelongToAFellowship the user does get the error line (it's wired), but they also get the OnSelfSent echo first, so the transcript reads "You say X" followed by "You do not belong to a Fellowship." Fixing the double-print fixes this too.

Unimplemented on ACE: Channel.Help (0x400) is an explicit placeholder; unknown channel ids are dropped with no client-visible response.

5.5 Latent wire defects (not currently reachable, fix before they bite)

SetCharacterOptions (0x01A1) is malformed. SocialActions.BuildSetCharacterOptions (SocialActions.cs:136-144) emits a 16-byte body: [0xF7B1][seq][0x01A1][optionsBitmap]. ACE reads that single u32 as flags, then reads characterOptions1 past the end of the payload. Worse, CharacterOptionDataFlag.CharacterOptions2 = 0x40 collides with CharacterOptions1.AllowGive = 0x40, which is set in CharacterOptions1.Default (0x50C4A54A) (corrected 2026-08-09 at the CH3 Opus review; see the §3.5 correction note above — same copy error, same fix) — so ACE would also try to read an options2 value. Reachable only via IGameRuntimeCommands.SetOptions1, which has no production call site (grep: only tests). Latent, but this is the exact message a Settings-sync feature would reach for.

AddChannel (0x0145) / RemoveChannel (0x0146) send the wrong type. SocialActions documents them as string16L channelName; ACE reads (Channel)ReadUInt32(). Both are admin-only on ACE and neither has a caller.

No SetSingleCharacterOption (0x0005) sender exists. This is the only message that changes Turbine room membership, and we cannot send it.


6. Ordered fix list (CH3)

Ordered so each step is independently gate-able and the earliest steps are the ones that make the user-visible symptom go away.

  1. Correct the false claim. Delete the "ACE doesn't run a TurbineChat server" note from docs/ISSUES.md:15214 and docs/plans/2026-04-11-roadmap.md:429,948. Same commit as any code.

  2. Make Hear* a first-class gate on the outbound Turbine path (retail SendTurbineChat @ 0x0057db10). TurbineChatRouting.Resolve — and its headless twin DirectGameRuntimeCommandAdapter.TrySendChannel — must consult RuntimeCharacterOptionsState.Options2 (and Options1.HearAllegianceChat, and IsOlthoi for Olthoi) before sending, and raise the retail refusal locally instead of sending:

    • Turbine off or room id 0 → "Turbine chat is not available."
    • option off → WeenieErrorWithString 0x0551 YouAreNotListeningTo_Channel with the channel name → add [0x0551] = "You are not listening to the _ channel." to WeenieErrorMessages. This alone converts the Roleplay/Society/Olthoi silence into retail-correct feedback. Do this before step 3 so the behaviour is right even if the user chooses to leave the channel off.
  3. Implement SetSingleCharacterOption (0x0005)u32 option, u32 value — and wire the six Settings "Hear … Chat" toggles to it. ACE's handler will call JoinTurbineChatChannel/LeaveTurbineChatChannel and re-push 0x0295, and we already render the resulting YouHaveEnteredThe_Channel line. Option ids from ACE CharacterOption.cs: ListenToAllegianceChat, ListenToGeneralChat 0x23, ListenToTradeChat 0x24, ListenToLFGChat 0x25, ListenToRoleplayChat 0x26, ListenToSocietyChat. This is what actually turns Roleplay on.

  4. Seed ChatSettings from the server, not from a local default. Its HearRoleplayChat: true default is a lie relative to ACE's CharacterOptions2.Default. Drive the panel from RuntimeCharacterOptionsState.Options2 (already parsed out of PlayerDescription) so the checkbox reflects server truth, and make the toggle publish step 3's command. Remove the "local-only" claim from the ChatSettings doc comment.

  5. Kill the legacy double-print. Make LiveSessionCommandRouter consult ChatChannelInfo.IsSelfEchoChannel() (finally wiring the type that exists for this) and skip OnSelfSent for Fellow/Vassals/Patron/Monarch/CoVassals, keeping it for AllegianceBroadcast, Say and Tell. Add a conformance test per channel id.

  6. Stop discarding the TurbineChat ack. RouteTurbineChat should inspect Payload.Response.HResult and surface a non-zero result as a system line; at minimum log it. Today a server-side rejection is completely invisible.

  7. Route /a through Turbine unconditionally (retail StartupTurbineChatSystem), i.e. remove the silent legacy downgrade, and add /ab / /allegiancebroadcast bound to legacy 0x02000000. Register row for any residual divergence.

  8. Fix or delete the malformed builders. Either implement BuildSetCharacterOptions against ACE's real GameActionSetCharacterOptions layout (§3.8) or delete it and SetOptions1 until there is a caller; same for BuildAddChannel/BuildRemoveChannel (u32 bitfield, not string). Do not leave a malformed message one call site away from production.

  9. Register + memory. Rows in docs/architecture/retail-divergence-register.md for anything that stays divergent (e.g. no spam throttle, Channel.Help unimplemented server-side); update claude-memory/project_chat_pipeline.md, whose line 111 carries the same false "ACE doesn't run a TurbineChat server" claim.


7. Live probes (only where the source cannot settle it)

Everything above is settled from source except whether General/Trade/LFG are actually working today. The static analysis says they are; the user reports side channels don't work. Two cheap probes discriminate, and neither requires new code beyond one log line.

7.1 Is General round-tripping at all?

Already-existing instrumentation covers the send side — LiveSessionCommandRouter.cs:254-257 logs chat: outbound TurbineChat General room=0x… chatType=2 cookie=0x… sender=0x… len=….

Procedure: launch against local ACE, type /g test, then grep the launch log for outbound TurbineChat.

  • Line present, /g test appears in the chat window → General works; the reported breakage is Roleplay/Society/Olthoi//a only, and steps 24 are the whole fix.
  • Line present, nothing in the window → the sender is being filtered (HearGeneralChat off on this character despite the default, e.g. a previously-persisted value) or the inbound EventBinary is not being delivered. Distinguish with 7.2.
  • Line absentTurbineChatState.Enabled is false or the room id is 0 for this session; check for the chat: SetTurbineChatChannels parsed line at login.

7.2 Which inbound 0xF7DE variants arrive?

The one genuinely missing measurement. RouteTurbineChat (LiveSessionEventRouter.cs:479-489) early-returns on everything that is not EventSendToRoom, so we currently cannot tell "ACE sent nothing" from "ACE sent an ack and we dropped it". Add a temporary line at the top of RouteTurbineChat:

[turbine-in] blob=<BlobType> dispatch=<DispatchType> room/context=<…> hresult=<…>
  • ResponseBinary only, no EventBinary → confirms the §5.2 chain: ACE accepted the send and filtered the sender out of its own broadcast. The fix is the character option, not the codec.
  • Neither → the send never reached the handler; capture loopback UDP on 127.0.0.1:9000 with WireMCP and check the 0xF7DE bytes against §3.1.
  • EventBinary present but no chat line → the defect is downstream in ChatLog/ChatVM, not on the wire.

Strip the line once the answer is in hand.

7.3 Server-side confirmation (no client change)

ACE's LogTurbineChat (TurbineChatHandler.cs:345-385) writes [CHAT][General] +Acdream says, "…" to the ACE log when chat_log_general is set. Enabling that property and watching the ACE console proves whether the message reached the handler at all — the cleanest single discriminator, and it requires nothing from the client.


8. Files touched by any fix

Path Why
src/AcDream.App/Net/LiveSessionCommandRouter.cs Turbine option gate; legacy self-echo suppression
src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs same gate for the headless path
src/AcDream.Runtime/Session/LiveSessionEventRouter.cs ack/hResult handling in RouteTurbineChat
src/AcDream.Core/Chat/WeenieErrorMessages.cs 0x0551 YouAreNotListeningTo_Channel
src/AcDream.Core.Net/Messages/SocialActions.cs SetSingleCharacterOption; fix/remove 0x01A1, 0x0145, 0x0146
src/AcDream.Core.Net/WorldSession.cs SendSetSingleCharacterOption
src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs + SettingsPanel.cs seed from server options; publish the command
src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs /ab verb
src/AcDream.UI.Abstractions/ChannelResolver.cs /a vs /ab split
docs/ISSUES.md, docs/plans/2026-04-11-roadmap.md, claude-memory/project_chat_pipeline.md retract the false claim
docs/architecture/retail-divergence-register.md rows for residual divergences