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

786 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 = 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 &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 (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 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 |