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>
786 lines
39 KiB
Markdown
786 lines
39 KiB
Markdown
# 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" |
|
||
| 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=<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 |
|