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>
39 KiB
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:
-
ACE source.
references/ACE/Source/ACE.Server/Network/Handlers/TurbineChatHandler.csis a full 387-line inbound handler registered[GameMessage(GameMessageOpcode.TurbineChat, SessionState.WorldConnected)].GameMessageTurbineChat.csis the outbound writer.TurbineChatChannel.csholds the room-id constants. -
It is on by default.
PropertyManager.cs:608—("use_turbine_chat", new Property<bool>(true, …)). -
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:55and 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=0x00000000Those are exactly ACE's
TurbineChatChannelconstants.TurbineChatState.Enabledhas beentrueagainst 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-codestargetType=1, everything else 0,extraDataSize=0x0C, outercookie=0, innercontextId= the caller's cookie. Sends throughSendGameAction→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-1866parses and raisesTurbineChatReceived;LiveSessionEventRouter.cs:280-283subscribes;RouteTurbineChat(LiveSessionEventRouter.cs:479-489) forwards onlyEventSendToRoomintoChatLog.OnChannelBroadcast(roomId, sender, text, displayName).Response(the ack) andUnknownare 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-229intoChatLog.OnWeenieError, andWeenieErrorMessageshas the relevant strings:0x051D"Turbine Chat is enabled.",0x051B/0x051Cchannel enter/leave,0x0414not in allegiance,0x050Fnot in a Fellowship. - Rendering —
ChatVM.FormatEntryrendersChatKind.Channelas[Name] Sender says, "text", or[Name] You say, "text"when the sender is empty or "You".ChatWindowController.RetailChatColorgives ChannelcolorLightBlue. 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
if (PropertyManager.GetBool("use_turbine_chat").Item) {
EnqueueSend(new GameEventWeenieError(Session, WeenieError.TurbineChatIsEnabled));
if (IsOlthoiPlayer) JoinTurbineChatChannel("Olthoi");
else {
if (GetCharacterOption(ListenToAllegianceChat) && Allegiance != null) JoinTurbineChatChannel("Allegiance");
if (GetCharacterOption(ListenToGeneralChat)) JoinTurbineChatChannel("General");
if (GetCharacterOption(ListenToTradeChat)) JoinTurbineChatChannel("Trade");
if (GetCharacterOption(ListenToLFGChat)) JoinTurbineChatChannel("LFG");
if (GetCharacterOption(ListenToRoleplayChat)) JoinTurbineChatChannel("Roleplay");
if (GetCharacterOption(ListenToSocietyChat) && Society != FactionBits.None) JoinTurbineChatChannel("Society");
}
}
JoinTurbineChatChannel sends WeenieErrorWithString.YouHaveEnteredThe_Channel
(0x051B) then SendTurbineChatChannels(). Also fired from
GameActionSetSingleCharacterOption when the client toggles a Hear* option,
and from allegiance changes.
3.5 Character options — the gate that matters
CharacterOptions2 (ACE.Entity/Enum/CharacterOptions2.cs):
| Bit | Name | In Default? |
|---|---|---|
0x00000100 |
HearGeneralChat | yes |
0x00000200 |
HearTradeChat | yes |
0x00000400 |
HearLFGChat | yes |
0x00000800 |
HearRoleplayChat | NO |
0x00080000 |
HearSocietyChat | NO |
Default = 0x00948700. CharacterOptions1.Default includes
HearAllegianceChat (0x40000000); CharacterOptions1.Default = 0x50C4A54A
(corrected 2026-08-09 at the CH3 Opus review — the original filing here
had 0x50C48D4A, a copy error with no basis in ACE's own source; ACE's
CharacterOptions1.cs:47 OR-sum is 0x50C4A54A, confirmed by its own
inline comment // 1355064650, and is identical to acdream's
PlayerDescriptionParser.cs:217. This wrong literal is what filed the
now-retracted UN-9 register row).
PlayerFactory.CharacterCreateSetDefaultCharacterOptions sets exactly these
two defaults on every new character.
acdream's RuntimeCharacterOptionsState.DefaultOptions2 = 0x00948700
(RuntimeCharacterState.cs:609) — byte-identical to ACE. We already model
the right value; we just don't act on it.
3.6 Delivery gates on a room send
Every branch of TurbineChatHandler broadcasts with
foreach (var recipient in PlayerManager.GetAllOnline()) — the sender is in
that set. There is no if (recipient == session.Player) continue. So the
sender's own copy comes back through the normal event path, subject to the same
per-recipient filter:
if (channelID == General && !recipient.GetCharacterOption(ListenToGeneralChat) ||
channelID == Trade && !recipient.GetCharacterOption(ListenToTradeChat) ||
channelID == LFG && !recipient.GetCharacterOption(ListenToLFGChat) ||
channelID == Roleplay && !recipient.GetCharacterOption(ListenToRoleplayChat))
continue;
Separately, the sender always gets a NETBLOB_RESPONSE_BINARY ack.
Config gates, with defaults from PropertyManager.cs:
| Property | Default | Effect if tripped |
|---|---|---|
use_turbine_chat |
true | whole 0xF7DE path is a no-op |
chat_disable_general/trade/lfg/roleplay |
false | per-channel kill switch |
chat_echo_only |
false | loops back to sender only |
chat_requires_account_15days |
false | reject |
chat_requires_account_time_seconds |
0 | reject |
chat_requires_player_age |
0 | reject |
chat_requires_player_level |
0 | reject |
chat_echo_reject / chat_inform_reject |
— | whether a rejected sender sees anything |
Player.IsGagged |
false | drop + gag error |
On a stock local ACE none of these block General/Trade/LFG. The only live
filter is the per-recipient CharacterOption.
3.7 Legacy channels — GameActionChatChannel.cs
One multiplexed action, GameActionType.ChatChannel = 0x0147:
u32 Channel then String16L message. acdream's BuildChatChannel matches.
Outbound GameEventChannelBroadcast (GameEventType 0x0147):
u32 channel, String16L senderName, String16L messageText.
acdream's ParseChannelBroadcast matches.
| Channel | Precondition | Success | Failure |
|---|---|---|---|
Fellow 0x800 |
has Fellowship | broadcast to members (real name) + self-echo with senderName="" |
WeenieError.YouDoNotBelongToAFellowship (0x050F) |
Vassals 0x1000 |
allegiance + TotalVassals != 0 |
broadcast + self-echo "" |
YouAreNotInAllegiance (0x0414) / YouCantUseThatChannel (0x0423) |
Patron 0x2000 |
allegiance + PatronId |
send to patron + self-echo "" |
same pair |
Monarch 0x4000 |
allegiance + MonarchId |
send to monarch + self-echo "" |
same pair |
CoVassals 0x1000000 |
allegiance + PatronId |
patron + siblings + self-echo "" |
same pair |
AllegianceBroadcast 0x2000000 |
allegiance + permission ≥ Speaker | broadcast to all members, sender included with real name (no "" echo) |
YouAreNotInAllegiance / YouDoNotHaveAuthorityInAllegiance (0x0535) |
Help 0x400 |
none | placeholder text, explicitly unfinished | — |
| unknown | — | Console.WriteLine, silently dropped, nothing sent to client |
— |
ACE quirk: the Fellow branch's else fires the self-echo once per
non-qualifying member, so a fellowship with N squelchers gives the sender N+1
self-echoes.
AddChannel (0x0145) / RemoveChannel (0x0146) read a u32 Channel
bitfield, not a string, and return early unless the player is an
Advocate/admin with the channel in ChannelsAllowed.
3.8 SetCharacterOptions (0x01A1) — GameActionSetCharacterOptions.cs
u32 flags (CharacterOptionDataFlag)
i32 characterOptions1 → SetCharacterOptions1() [always read]
[Shortcut block if flags & 0x01]
u32 numTab1Spells + spells [always read]
[MultiSpellList 0x04] [ExtendedMultiSpellLists 0x10] [SpellLists8 0x400]
[DesiredComps 0x08] [SpellbookFilters 0x20]
[CharacterOptions2 0x40] → i32 → SetCharacterOptions2()
[TimestampFormat 0x80] [GenericQualitiesData 0x100] [GameplayOptions 0x200]
Also refused entirely before FirstEnterWorldDone.
SetSingleCharacterOption is GameActionType = 0x0005, payload
u32 option, u32 value. For the six ListenTo* options its handler sets the
option and calls JoinTurbineChatChannel / LeaveTurbineChatChannel. This
is the only wire message that changes Turbine room membership.
4. Retail truth
4.1 Rooms are pushed, never requested
ClientCommunicationSystem::Handle_Communication__Recv_ChatRoomTracker @ 0x0056e4f0:
gmCCommunicationSystem::SetChatRoomTracker(arg2);
if (tracker) {
// BN renders the field read as GetMouseX(tracker) — heuristic artifact
bool hasAllegRoom = tracker->m_allegianceRoomID != 0;
gmCCommunicationSystem::SetTalkFocusEnabled(7, hasAllegRoom);
if (WantsToBeInAllegChat() && hasAllegRoom) SetTalkFocus(7);
}
Pure passive storage plus a UI affordance. No join request is emitted anywhere
in the decomp. The retail struct (acclient.h:40716) is 10 × u32 in exactly the
order ACE writes:
struct ChatRoomTracker : PackObj {
unsigned int m_allegianceRoomID, mGeneralChatRoomID, mTradeChatRoomID,
mLFGChatRoomID, mRoleplayChatRoomID, mOlthoiChatRoomID,
mSocietyChatRoomID, mSocietyCelHanChatRoomID,
mSocietyEldWebChatRoomID, mSocietyRadBloChatRoomID;
};
4.2 What retail sends into a room — and the gate we're missing
Each per-channel entry point reads its own field off the tracker and passes the
player's own Hear* option as the last argument
(acclient_2013_pseudo_c.txt:394282-394447):
DoTurbineChat_General → SendTurbineChat(this, tracker.mGeneralChatRoomID, General_ChatTypeEnum, &text, PlayerModule::HearGeneralChat(...));
DoTurbineChat_Trade → SendTurbineChat(this, tracker.mTradeChatRoomID, Trade_ChatTypeEnum, &text, PlayerModule::HearTradeChat(...));
DoTurbineChat_LFG → SendTurbineChat(this, tracker.mLFGChatRoomID, LFG_ChatTypeEnum, &text, PlayerModule::HearLFGChat(...));
DoTurbineChat_Roleplay → SendTurbineChat(this, tracker.mRoleplayChatRoomID, Roleplay_ChatTypeEnum, &text, PlayerModule::HearRoleplayChat(...));
DoTurbineChat_Olthoi → SendTurbineChat(this, tracker.mOlthoiChatRoomID, Olthoi_ChatTypeEnum, &text, CPlayerSystem::IsOlthoi() != 0);
DoTurbineChat_Allegiance → SendTurbineChat(this, tracker.m_allegianceRoomID, Allegiance_ChatTypeEnum, &text, PlayerModule::HearAllegianceChat(...));
ClientCommunicationSystem::SendTurbineChat @ 0x0057db10 — this is the
load-bearing function for the whole diagnosis:
if (!CCommunicationSystem::IsUsingTurbineChat() || roomId <= 0) {
AddTextToScroll("Turbine chat is not available.\n"); // local, nothing sent
return;
}
if (hearOption == 0) { // 0x0057db37
ChannelSystem::GetGlobalChannelName(chatType, &name);
HandleFailureEvent(this, 0x551, &name); // YouAreNotListeningTo_Channel
return 1; // NOTHING IS SENT
}
if (!IsMessageSafe(text)) return;
if (IsMessageSpam()) { AddTextToScroll("You must wait %ds before communi…"); return 0; }
blob = new TurbineChatBlob{ m_targetID = GetPlayerID(), m_ChatType = chatType };
if (CCommunicationSystem::CSendToTurbineRoomByID(roomId, wideText, ...) failed)
AddTextToScroll("Failed to send text to channel: …");
Three distinct retail refusals, all locally raised, none of which acdream has:
| Condition | Retail behaviour |
|---|---|
| Turbine off, or room id 0 | prints Turbine chat is not available. |
| Player's own Hear* option off | raises 0x0551 YouAreNotListeningTo_Channel → "You are not listening to the <X> channel." |
| Spam throttle | prints You must wait Ns before communicating again |
0x0551 is confirmed as YouAreNotListeningTo_Channel in
references/ACE/Source/ACE.Entity/Enum/WeenieErrorWithString.cs:479.
Retail emits no local echo on success — it relies on the server's broadcast
coming back, exactly as ACE's GetAllOnline() loop provides. That is consistent
and correct: the option gate is what guarantees the echo will arrive.
4.3 /a is Turbine, not legacy
StartupTurbineChatSystem @ 0x00594451ff. binds the command strings "a" and
"guild" to DoTurbineChat_Allegiance. Once Turbine chat is up, /a is
overridden away from the generic ChannelSystem::GetChannelID("allegiance") → 0x02000000 → Event_ChannelBroadcast path. Retail never silently downgrades
/a to the legacy bitflag — with no allegiance room it hits the
roomId <= 0 branch and prints "Turbine chat is not available."
4.4 Legacy family wire shape
CM_Communication::Event_ChannelBroadcast @ 0x006a4030 writes
u32 0x0147, u32 channelBitmask, packed string, DWORD-aligned — inside the
ordinary GameAction (OrderHdr) envelope. Call sites confirm the bitmasks:
0x800 Fellow, 0x2000 Patron, 0x4000 Monarch, 0x1000 Vassals,
0x2000000 AllegianceBroadcast, 0x400 Help.
acdream's BuildChatChannel is byte-identical.
4.5 Inbound 0xF7DE in retail
Client::ProcessLogonEventQueue (line 18426, pumped every frame from
Client::UseTime) strips the 4-byte tag + 4-byte length and hands the raw
remainder to IChatClient vtable slot 3 — implemented in chatclient.dll,
outside the PDB. So the inner blob layout is not directly observable in
acclient.exe; our field table is ACE's reconstruction, independently
corroborated by TurbineChatBlob being exactly 12 bytes (matching the hard-coded
extraDataSize = 0x0C) and by ChatRoomTracker's field order.
Reference-quality caveat: holtburger's turbine.rs fixtures are labelled
Generated from ACE: SyntheticProtocolTests.GenerateTurbineChatFixtures. It
corroborates ACE; it is not an independent retail capture. Our codec cites
holtburger throughout — that citation chain ultimately terminates at ACE, which
is fine here because ACE is the server we talk to, but it should not be
described as retail-verified.
5. Per-family defect diagnosis
5.1 Turbine General / Trade / LFG — wire-correct, should already work
- Outbound bytes match
TurbineChatHandler's reader field-for-field, including both self-referential size dwords (§3.1). - Inbound
EventBinary/SendToRoomByNamematches ACE's writer (§3.2), the payload-length arithmetic reconciles (sizeSecond - 8 == payloadLen), andRouteTurbineChatdelivers it toChatLogwith the right display name. - ACE's defaults leave
HearGeneralChat/TradeChat/LFGChaton, so the sender is not filtered out ofGetAllOnline()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:
- ACE gives new characters
CharacterOptions2.Default = 0x00948700, which omitsHearRoleplayChat (0x800)(CharacterOptions2.cs). - At login ACE therefore skips
JoinTurbineChatChannel("Roleplay")(Player_Networking.cs:104). The player is never in the room. - 0x0295 still reports
roleplay=0x00000005because the table is a constant dump — this is exactly the log line that made the channel look available. - acdream's
TurbineChatRouting.ResolveseesRoleplayRoom == 5 != 0andEnabled == true, so/rp hellois sent, correctly framed. - 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. - The sender receives only the
ResponseBinaryack, whichRouteTurbineChatthrows away. - acdream emits no local echo for Turbine channels.
Net: the message is silently swallowed. Retail would have refused at step 4
with YouAreNotListeningTo_Channel.
Same chain applies to Society (HearSocietyChat also absent from Default,
plus no society → SocietyRoom == 0, so we at least fall through to the
"dropped" log) and Olthoi (IsOlthoiPlayer false → ACE returns at
TurbineChatHandler.cs:146, no error to the client at all).
Aggravating factor: src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
defaults HearRoleplayChat: true and the Settings panel shows a checked box —
the UI actively tells the user Roleplay is on while the server has it off. Those
six toggles are read from and written to settings.json and consumed by
nothing: no display filter, no wire message. Confirmed by grep — the only
non-Settings references are the store and the panel itself.
5.3 Turbine Allegiance (/a) — wrong transport on the no-allegiance path
TurbineChatRouting.Resolve returns null when AllegianceRoom == 0, so
/a falls through to ChannelResolver → legacy AllegianceBroadcast 0x02000000
→ ACE replies YouAreNotInAllegiance. The user does get an error, so this is
low-severity, but:
- Retail binds
/atoDoTurbineChat_Allegianceunconditionally (§4.3) and prints "Turbine chat is not available." instead. - With an allegiance, retail's
/aand acdream's/aagree (both Turbine). - We have no verb for
/ab(allegiance broadcast), which is the command that legitimately maps to0x02000000. 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.
-
Correct the false claim. Delete the "ACE doesn't run a TurbineChat server" note from
docs/ISSUES.md:15214anddocs/plans/2026-04-11-roadmap.md:429,948. Same commit as any code. -
Make
Hear*a first-class gate on the outbound Turbine path (retailSendTurbineChat @ 0x0057db10).TurbineChatRouting.Resolve— and its headless twinDirectGameRuntimeCommandAdapter.TrySendChannel— must consultRuntimeCharacterOptionsState.Options2(andOptions1.HearAllegianceChat, andIsOlthoifor 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_Channelwith the channel name → add[0x0551] = "You are not listening to the _ channel."toWeenieErrorMessages. 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.
- Turbine off or room id 0 →
-
Implement
SetSingleCharacterOption (0x0005)—u32 option, u32 value— and wire the six Settings "Hear … Chat" toggles to it. ACE's handler will callJoinTurbineChatChannel/LeaveTurbineChatChanneland re-push 0x0295, and we already render the resultingYouHaveEnteredThe_Channelline. Option ids from ACECharacterOption.cs:ListenToAllegianceChat,ListenToGeneralChat 0x23,ListenToTradeChat 0x24,ListenToLFGChat 0x25,ListenToRoleplayChat 0x26,ListenToSocietyChat. This is what actually turns Roleplay on. -
Seed
ChatSettingsfrom the server, not from a local default. ItsHearRoleplayChat: truedefault is a lie relative to ACE'sCharacterOptions2.Default. Drive the panel fromRuntimeCharacterOptionsState.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 theChatSettingsdoc comment. -
Kill the legacy double-print. Make
LiveSessionCommandRouterconsultChatChannelInfo.IsSelfEchoChannel()(finally wiring the type that exists for this) and skipOnSelfSentfor Fellow/Vassals/Patron/Monarch/CoVassals, keeping it forAllegianceBroadcast, Say and Tell. Add a conformance test per channel id. -
Stop discarding the TurbineChat ack.
RouteTurbineChatshould inspectPayload.Response.HResultand surface a non-zero result as a system line; at minimum log it. Today a server-side rejection is completely invisible. -
Route
/athrough Turbine unconditionally (retailStartupTurbineChatSystem), i.e. remove the silent legacy downgrade, and add/ab//allegiancebroadcastbound to legacy0x02000000. Register row for any residual divergence. -
Fix or delete the malformed builders. Either implement
BuildSetCharacterOptionsagainst ACE's realGameActionSetCharacterOptionslayout (§3.8) or delete it andSetOptions1until there is a caller; same forBuildAddChannel/BuildRemoveChannel(u32 bitfield, not string). Do not leave a malformed message one call site away from production. -
Register + memory. Rows in
docs/architecture/retail-divergence-register.mdfor anything that stays divergent (e.g. no spam throttle,Channel.Helpunimplemented server-side); updateclaude-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 testappears in the chat window → General works; the reported breakage is Roleplay/Society/Olthoi//aonly, and steps 2–4 are the whole fix. - Line present, nothing in the window → the sender is being filtered
(
HearGeneralChatoff on this character despite the default, e.g. a previously-persisted value) or the inboundEventBinaryis not being delivered. Distinguish with 7.2. - Line absent →
TurbineChatState.Enabledis false or the room id is 0 for this session; check for thechat: SetTurbineChatChannels parsedline 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=<…>
ResponseBinaryonly, noEventBinary→ 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:9000with WireMCP and check the 0xF7DE bytes against §3.1. EventBinarypresent but no chat line → the defect is downstream inChatLog/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 |