# 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 `HearChat` 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(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 `SendGameAction` → `SendGameMessage` → `GameMessageGroup.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 - **0xF7DE** — `WorldSession.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 ChannelBroadcast** — `GameEventWiring.cs:102-106` → `GameEvents.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. - **Rendering** — `ChatVM.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" | | 5–8 | — | "Always 0" ×4 | | 9 | size | "Bytes to follow" — read and discarded | | — | *gag check* | `session.Player.IsGagged` → `SendGagError()`, 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` ```csharp 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: ```csharp 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`: ```c 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: ```c 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`): ```c 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 @ 0x0057db10` — **this is the load-bearing function for the whole diagnosis**: ```c 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) → continue` — **and 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 `return`s 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 /cv` — **wire-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 2–4 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 absent** → `TurbineChatState.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= dispatch= 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 |