docs: Campaign CH R1/R2/R4 research + ledger correction (CH1 = 172c6f9a)

Commits the command-registry, interface-text (SpewBox), and
side-channels-vs-ACE research docs (R3 color-table landed with CH1).
Corrects the CH1 ledger SHA the implementer recorded pre-amend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 15:25:29 +02:00
parent 172c6f9aa3
commit e306c979ae
4 changed files with 2112 additions and 4 deletions

View file

@ -0,0 +1,778 @@
# 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 `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" |
| 58 | — | "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 = 0x50C48D4A`.
`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 &lt;X&gt; 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 (0x50C48D4A)` — 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 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=<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 |