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

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

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

View file

@ -1,7 +1,7 @@
# Campaign CH — chat & interface-text retail parity # Campaign CH — chat & interface-text retail parity
**Status:** ACTIVE 2026-08-09 — research lanes running; slices finalize when **Status:** ACTIVE 2026-08-09 — R1R4 research complete (four docs under
they land. `docs/research/2026-08-09-chat-retail-*.md`); CH1 landed, in Opus review.
**Why now:** first track of the alpha-release program (chat is the most **Why now:** first track of the alpha-release program (chat is the most
visible daily surface for the friend-alpha). User-directed 2026-08-09. visible daily surface for the friend-alpha). User-directed 2026-08-09.
@ -98,8 +98,8 @@ implementer per slice against a pinned contract (per
| Slice | Commit | Suite | Review | User gate | | Slice | Commit | Suite | Review | User gate |
|---|---|---|---|---| |---|---|---|---|---|
| R1R4 research | | — | — | — | | R1R4 research | `see docs/research/2026-08-09-chat-retail-*` | — | — | — |
| CH1 colors | `06f448fc` | 11,833 passed / 4 skipped / 0 failed | — | pending | | CH1 colors | `172c6f9a` | 11,833 passed / 4 skipped / 0 failed | in review | pending |
| CH2 interface text | — | — | — | — | | CH2 interface text | — | — | — | — |
| CH3 side channels | — | — | — | — | | CH3 side channels | — | — | — | — |
| CH4 commands | — | — | — | — | | CH4 commands | — | — | — | — |

View file

@ -0,0 +1,399 @@
# Retail client slash-command registry — complete enumeration + acdream audit
Date: 2026-08-09
Status: RESEARCH ONLY. No production code was changed.
Oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build) plus byte-level decode of the PDB-paired binary
`C:\Users\erikn\Downloads\acclient.exe` (v11.4186, CodeView GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`). Every verb string in this document was
read out of `.rdata` at the exact `push imm32` operand feeding
`PStringBase<char>::PStringBase<char>` inside the registration loop — none were
inferred from Binary Ninja's symbolic rendering, which mislabels several of the
pooled single-character strings as wide-string slices
(`&*U"fvpca"[4]`) and two as vtable fields.
Extends `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`,
which already proved out the families acdream ported (lifestone/marketplace/
arena/age/birth/framerate/lockui/die/loc/corpse/clear/UI-layout/friends/afk/
consent/squelch/filter/emote/fillcomps). Those pseudocode blocks are not
repeated here.
---
## 1. Registry mechanism
There is **one** command table and **two** functions that populate it:
`ClientCommunicationSystem::m_hashCommands`, an
`IntrusiveHashTable<CaseInsensitiveStringBase<PStringBase<char>>, CmdHashData*>`
sized to 100 buckets in the ctor at `0x0058555D`.
`ClientCommunicationSystem::InitializeCommands @ 0x00581970` (spanning to
`0x00585520`) registers **116** entries; `CmdHashData::CmdHashData @ 0x0056ED30`
stores `+0x00` verb, `+0x08` `func` (`uint8_t (*)(this, int argc, char** argv)`),
`+0x0C` unused-in-practice, `+0x10` `help`
(`uint8_t (*)(this, HelpType, char const*, PStringBase<char>*)`), `+0x14` unused.
`ClientCommunicationSystem::StartupTurbineChatSystem @ 0x0057EFB0` runs only when
the server enables Turbine Chat; it **removes** the `a` entry and adds 15
entries (`a`, `guild`, `gu`, `general`, `cg`, `trade`, `ct`, `lfg`, `clfg`,
`roleplay`, `crp`, `society`, `soc`, `olthoi`, `o`), i.e. 14 net-new verbs.
Nothing else ever calls `IntrusiveHashTable<…CmdHashData*>::add` — verified by
grepping every call site (18 in `0x0057xxxx`, all inside
`StartupTurbineChatSystem` plus the two hash-table helpers themselves; 116 in
`0x0058xxxx`, all inside `InitializeCommands`).
Dispatch is `ClientCommunicationSystem::OnChatCommand @ 0x00581320`
`ClientCommunicationSystem::DoCommand @ 0x0057E2E0`. `OnChatCommand` switches on
`firstChar - 0x2F`: `'/'` (case 0) is **rewritten in place to `'@'`** and falls
into `DoCommand`; `'@'` (case 0x11) enters `DoCommand` directly; `':'` and `';'`
(cases 0x0B/0x0C) have their first character replaced with a space and the whole
line prefixed with the literal `"@emote"` before `DoCommand` — that is retail's
emote shorthand. Anything else routes to `PublicChat` / talk-focus.
`DoCommand` splits the line on `" \t"` (`PSUtils::FindAllWords`, delimiter set at
`0x007E0F5C`), takes `words[0].substring(1)` as the verb, **right-trims `','`**
from it (trim char set `0x0079452C`, `trim(left=0, right=1)`) so `@f, hello`
works, then looks the verb up. If the entry is absent **or its `func` is NULL**,
it calls `ClientCommunicationSystem::DoChannelCommand @ 0x005774A0`, which tries
`ChannelSystem::GetChannelID @ 0x005CF1F0` on the verb and, on a hit, sends
`CM_Communication::Event_ChannelBroadcast(id, joinedArgs)`. Only if *that* also
fails does the client fall back to `CM_Communication::Event_Talk` with the
**original `@`-prefixed line** — which is exactly the passthrough ACE relies on
(`references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionTalk.cs:21`,
`if (message.StartsWith("@"))``CommandManager`). A registered handler that
returns 0 raises `HandleFailureEvent(0x26, "")`.
---
## 2. Complete retail command table
One row per handler. `A` in the Turbine column = the row exists only after
`StartupTurbineChatSystem`. acdream paths are relative to the repo root.
### 2.1 Help / group nodes
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|---|---|---|---|---|---|
| `help` | `?` | `DoHelp @ 0x0057F9E0` | `[command\|group]` | No args: prints the group index + "Note: You may substitute a forward slash (/) for the at symbol (@)." With an arg: looks the verb up and calls its `help` fn. | **PARTIAL**`ChatCommandRouter.TryHandleLocalPresentationCommand` (`src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs:96`) prints one flat blob for `/help`, `/?`, `/h`. No `@help <command>`, no group topics. `/h` is an acdream invention (not a retail verb). |
| `commands` | — | *(NULL func)*, help `HelpAllGroup @ 0x0057E7F0` | — | Help-topic node only. Typing it alone falls through to channel lookup then server. | MISSING |
| `allegiances` | — | *(NULL func)*, `HelpAllegiancesGroup @ 0x0057D4C0` | — | Help topic. | MISSING |
| `channels` | — | *(NULL func)*, `HelpChannelsGroup @ 0x005773E0` | — | Help topic. | MISSING |
| `chatting` | — | *(NULL func)*, `HelpChattingGroup @ 0x0057B1A0` | — | Help topic. | MISSING |
| `death` | — | *(NULL func)*, `HelpDeathGroup @ 0x0057B8B0` | — | Help topic. | MISSING |
| `status` | — | *(NULL func)*, `HelpStatusGroup @ 0x0057C410` | — | Help topic. | MISSING |
| `text` | — | *(NULL func)*, `HelpTextGroup @ 0x0057C6C0` | — | Help topic. | MISSING |
### 2.2 Local chat routing
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|---|---|---|---|---|---|
| `say` | `s` | `DoSay @ 0x00581240` | `<text>` | Joins + trims args, `PublicChat`. Empty → "You must specify the text you wish to say." | IMPLEMENTED-EQUIVALENT — `ChatInputParser` `SayAliases = {"/say","/s"}` (`ChatInputParser.cs:41`). Not a catalog command; routed as `SendChatCmd(Say)`. |
| `tell` | `t`, `send`, `whisper`, `w` | `DoTell @ 0x00577E40` | `<name>, <text>` | Splits on the **first comma**; name = left, text = right. Sets `SetLastTelleeName`, sends `CM_Communication::Event_TalkDirectByName`. Retail help is explicit: "you must put a comma after the character's name." | **PARTIAL**`ChatInputParser.TellAliases = {"/tell","/t"}` only; `send`/`whisper`/`w` MISSING. acdream splits on **whitespace first** and merely trims a trailing comma off the target (`ChatInputParser.cs:208-220`), so a multi-word name (`/t Lord Gnarly, hi`) resolves the wrong target where retail resolves it correctly. |
| `reply` | `r`, `rp` | `DoReply @ 0x00577910` | `<text>` | Sends `Event_TalkDirect` to `gmCCommunicationSystem::GetLastTeller()` (a **guid**, not a name). No teller → "Someone must @tell you first!" | **PARTIAL/DIVERGENT**`ReplyAliases = {"/reply","/r"}`; `rp` is MISSING **and is bound to Roleplay in acdream** (`ChatInputParser.cs:78`). acdream replies by *name*, retail by guid. |
| `mr` | — | **NULL func**, help `HelpReply @ 0x00577A50` | `<text>` | Documented as "reply to the last person who @m'd you (monarchs only)" but the 2013 build registers it with a **null function pointer** (verified at `0x00583041`: `xor edi,edi; xor ebp,ebp` before the ctor call — arg3 is 0). It therefore falls through to channel lookup (miss) and is sent to the server as text. A retail defect, not an acdream one. | MISSING (and **do not** implement client-side — retail does not) |
| `pr` | — | **NULL func**, help `HelpReply` | `<text>` | Same as `mr` (verified at `0x005830C1`). | MISSING (same note) |
| `retell` | `rt` | `DoReTell @ 0x00577BD0` | `<text>` | Re-sends to the last person **you** tell'd. | **PARTIAL**`RetellAliases = {"/retell"}`; `rt` MISSING. |
| `chat` | — | `DoChatToggle @ 0x0056FAD0` | `on\|off` | `on``Event_ModifyGlobalSquelch(remove, 2)`; `off``Event_ModifyGlobalSquelch(add, 2)` — i.e. it is a global **Speech** filter, not a separate flag. | MISSING (cheap: acdream already has `ModifyGlobalSquelch`) |
| `notell` | — | `DoNoTell @ 0x0056FBD0` | `on\|off` | Same mechanism with message type **3 (Tell)**. | MISSING (same) |
| `join` | — | `DoJoinChat @ 0x0056F510` | `<Allegiance\|General\|Trade\|LFG\|Roleplay\|Society\|Soc>` | Sets the matching `PlayerModule::SetHear*Chat(true)` Turbine-room flag. | MISSING |
| `leave` | — | `DoLeaveChat @ 0x0056F7F0` | same tags | Clears the matching flag. | MISSING |
| `index` | — | `DoChannelIndex @ 0x0056E640` | — | `CM_Communication::Event_ChannelIndex()`. ACE: `GameActionType.IndexChannels`. | MISSING |
| `clist` | — | `DoChannelList @ 0x0057A9B0` | `<channel>` | `GetChannelID` then `Event_ChannelList(id)`; bad tag → WeenieError `0x422`; no arg → "Please specify the channel name." ACE: `ListChannels`. | MISSING |
| `on` | — | `DoChannelOn @ 0x0057AA80` | `<channel>` | `Event_AddToChannel(id)`. ACE: `AddChannel`. | MISSING |
| `off` | — | `DoChannelOff @ 0x0057AB50` | `<channel>` | `Event_RemoveFromChannel(id)`. ACE: `RemoveChannel`. | MISSING |
| `title` | — | `DoTitle @ 0x0057A640` | `<text>` | Sets the popup chat window's title. Pure local UI. | MISSING |
| `log` | — | `DoSetOutput @ 0x0057E4F0` | `[filename]` | Toggles chat-to-file logging; appends `.txt`; "Copying chat to %s. Run command again with no arguments to turn off logging." | MISSING |
| `clear` | — | `DoClear @ 0x0056E600` | `[all]` | IMPLEMENTED | **IMPLEMENTED**`RetailClientCommandCatalog.cs:115`, `ClientCommandController.cs:165`. |
| `filter` / `unfilter` | — | `DoFilter @ 0x0057C860` / `DoUnFilter @ 0x0057C880` | `-<type>` | IMPLEMENTED | **IMPLEMENTED**`ClientCommandController.ExecuteGlobalFilter`. |
| `messagetypes` | `message_types`, `msgtypes`, `msg_types` | `DoMessageTypes @ 0x0057A010` | — | Lists squelch/filter categories. | **PARTIAL** — only `messagetypes` registered (`RetailClientCommandCatalog.cs:251`); the three underscore/short aliases are MISSING. |
| `loadfile` | — | `DoLoadFile @ 0x00581870` / `LoadFile @ 0x00581710` | `<file>` | `fopen` the file, then feed every line back through `OnChatCommand` after `MakeLoadFileVariableSubstitutions`. A script player. No arg → "You must provide a file name."; open failure → "Cannot open file %hs". | MISSING (see §3 risk note) |
### 2.3 Chat channels (`DoStupidChannelHack @ 0x0057B130``DoChannelCommand`)
All of these are one handler: no args → "You must specify the text you wish to
say."; otherwise `ChannelSystem::GetChannelID(verb)` then
`CM_Communication::Event_ChannelBroadcast(id, text)`.
| Channel | Registered verbs | Channel id | acdream status |
|---|---|---|---|
| Allegiance | `a`, `ab` (also `allegiance`/`all` **as a command**, see §2.4) | `0x02000000` | `/a` IMPLEMENTED-EQUIVALENT (`ChatInputParser.cs:63`). `ab` MISSING. **`/allegiance` is bound to the Allegiance channel in acdream but is a COMMAND in retail** — divergence. |
| Co-vassals | `co-vassals`, `covassals`, `covassal`, `c` | `0x01000000` | `covassals` OK; `c`, `covassal`, `co-vassals` MISSING. acdream's `/cv` is an invention. |
| Monarch | `monarch`, `m` | `0x4000` | IMPLEMENTED-EQUIVALENT |
| Patron | `patron`, `p` | `0x2000` | IMPLEMENTED-EQUIVALENT |
| Vassals | `vassals`, `vassal`, `v` | `0x1000` | `vassals`, `v` OK; `vassal` MISSING. |
| Fellowship | `fellowship`, `fellows`, `fellow`, `f`, `group`, `g`, `party` | `0x800` | **DIVERGENT** — retail's `g` and `group` and `party` are **Fellowship**. acdream maps `/g` to **General** (`ChatInputParser.cs:57`). `fellows`, `group`, `party` MISSING. |
`ChannelSystem::GetChannelID` also resolves 22 tags that are **not** in the
command table. Because `DoCommand` falls through to `DoChannelCommand` for any
unregistered verb, these still work as channel broadcasts:
| Tags | Channel id |
|---|---|
| `av`, `av1`, `advocate`, `advocate1` | `0x08` |
| `av2`, `advocate2` | `0x10` |
| `av3`, `advocate3` | `0x20` |
| `abuse` | `0x01` |
| `ad`, `admin` | `0x02` |
| `au`, `audit` | `0x04` |
| `sent`, `sentinel` | `0x200` |
| `celestialhand`, `celhan` | `0x08000000` |
| `eldrytchweb`, `eldweb` | `0x10000000` |
| `radiantblood`, `radblo` | `0x20000000` |
| `ol` (and `olthoi` pre-Turbine) | `0x40000000` |
| `help` | `0x400`**explicitly rejected** by `DoChannelCommand` at `0x005774FD` (`id == 0 || id == 0x400` → return 0) |
All 22 are SERVER-PASSTHROUGH in acdream today (they reach ACE as `@abuse …`
text rather than a channel broadcast).
### 2.4 Turbine Chat (registered only by `StartupTurbineChatSystem @ 0x0057EFB0`)
Each sends `ClientCommunicationSystem::SendTurbineChat @ 0x0057DB10` with the
matching `ChatTypeEnum` and the player's `PlayerModule::Hear*Chat` gate.
| Verb | Aliases | Handler @addr | acdream status |
|---|---|---|---|
| `a` | `guild`, `gu` | `DoTurbineChat_Allegiance @ 0x0057EBA0` | `/a` routed as a plain Allegiance channel message; `guild`/`gu` MISSING |
| `general` | `cg` | `DoTurbineChat_General @ 0x0057EC50` | `general` OK; `cg` MISSING. acdream's `/gen` is an invention. |
| `trade` | `ct` | `DoTurbineChat_Trade @ 0x0057ECE0` | `trade` OK; `ct` MISSING. `/tr` is an invention. |
| `lfg` | `clfg` | `DoTurbineChat_LFG @ 0x0057ED70` | `lfg` OK; `clfg` MISSING. `/lookingforgroup` is an invention. |
| `roleplay` | `crp` | `DoTurbineChat_Roleplay @ 0x0057EE00` | `roleplay` OK; `crp` MISSING. `/role` and `/rp` are inventions (`/rp` collides with retail's reply alias). |
| `society` | `soc` | `DoTurbineChat_Society @ 0x0057EF20` | `society` OK; `soc` MISSING |
| `olthoi` | `o` | `DoTurbineChat_Olthoi @ 0x0057EE90` | `olthoi` OK; `o` MISSING |
### 2.5 Allegiance management
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|---|---|---|---|---|---|
| `allegiance` | `all` | `DoAllegiance @ 0x0057D5A0` | `<sub> [args]` | Subcommand dispatcher. Verbs read out of the handler: `boot` (`DoAllegianceBoot @ 0x0057AEF0`), `info` (`DoAllegianceInfo @ 0x00576560`), `chat`/`ch` (`DoAllegianceChat @ 0x00575CB0`), `broadcast`/`br` (`DoAllegianceBroadcast @ 0x005761F0`), `ban` (`DoAllegianceBan @ 0x005762A0`), `officer` (`DoAllegianceOfficer @ 0x00576650`), `title` (`DoAllegianceOfficerTitle @ 0x00576A10`), `hometown`/`ho` (`DoAllegianceHometown @ 0x0056EF10`), `motd` (`DoMotd @ 0x00577150`), `name` (`DoAllegianceName @ 0x00576C80`), `lock` (`DoAllegianceLock @ 0x00576E70`), `house` (`DoAllegianceHouse @ 0x0056EF70`). | MISSING (and `/allegiance` currently mis-bound as a chat channel) |
| `ab` | — | `DoAllegianceBroadcast @ 0x005761F0` | `<text>` | Monarch broadcast to the whole allegiance. | MISSING |
| `alh` | `ah` | `DoAllegianceHometown @ 0x0056EF10` | — | ACE: `GameActionType.RecallAllegianceHometown`. | MISSING |
| `motd` | — | `DoMotd @ 0x00577150` | `[set <text>\|clear]` | Displays, sets (monarch-only), or clears the allegiance MOTD. | MISSING |
| `speaker` | — | `DoSpeaker @ 0x0057DAB0` | — | Prints exactly "This command is no longer in use, please see @allegiance officer.\n". Pure local. | MISSING (trivial) |
### 2.5b Housing
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|---|---|---|---|---|---|
| `house` | `hou` | `DoHouse @ 0x00580860` | `<sub> [args]` | Subcommand verbs read out of the handler: `open`, `close`, `recall`/`re`, `mansion_recall`/`alleg_recall`/`ma`, `storage` (`DoHouseStorage @ 0x00579560`), `remove`, `boot` (`DoHouseBoot @ 0x00579970`), `boot_all`, `remove_all`, `guest` (`DoHouseGuests @ 0x005791A0`), `abandon`, `available`, `hooks`, `on`, `off`. | **PARTIAL**`RetailClientCommandCatalog.TryMatch` (`RetailClientCommandCatalog.cs:279-301`) recognizes only `recall`, `mansion_recall`, `alleg_recall`. `hou`, `re`, `ma` and all 12 other subcommands are MISSING, **and the catalog swallows them locally** with an invalid-args message instead of letting them reach ACE. |
| `hor` | `hr` | `DoHouseRecall @ 0x00570450` | — | IMPLEMENTED | **IMPLEMENTED** |
| `hom` | `hoa` | `DoMansionRecall @ 0x005704B0` | — | IMPLEMENTED | **IMPLEMENTED** |
| `hslist` | — | `DoHouseAvailableList @ 0x00570510` | `<apartment\|cottage\|villa\|mansion>` | Lists available houses. ACE: `GameActionType.ListAvailableHouses`. | MISSING |
### 2.6 Death / recall / PK
| Verb | Aliases | Handler @addr | acdream status |
|---|---|---|---|
| `lifestone` | `lif`, `ls` | `DoLifestone @ 0x0056FC70` | **IMPLEMENTED** |
| `marketplace` | `mar`, `mp` | `DoMarketplace @ 0x0056FCE0` | **IMPLEMENTED** |
| `pkarena` | `pka` | `DoPKArena @ 0x005788D0` | **IMPLEMENTED** |
| `pklarena` | `pla` | `DoPKLArena @ 0x005789D0` | **IMPLEMENTED** |
| `pklite` | `pkl` | `DoPKLite @ 0x0057A490` | **PARTIAL**`pkl` alias MISSING (`RetailClientCommandCatalog.cs:218` registers only `pklite`) |
| `die` | — | `DoDie @ 0x00580050` | **IMPLEMENTED** |
| `corpse` | `cor` | `DoCorpse @ 0x00578220` | **IMPLEMENTED** |
| `consent` | — | `DoConsent @ 0x0057DDA0` | **IMPLEMENTED** |
| `permit` | — | `DoPermit @ 0x005785A0` | `add <name>` / `remove <name>` — grants/revokes corpse-loot permission. ACE: `AddPlayerPermission` / `RemovePlayerPermission`. **MISSING** |
### 2.7 Status / display
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|---|---|---|---|---|---|
| `age` | — | `DoAge @ 0x0057C5A0` | — | **IMPLEMENTED** |
| `birth` | — | `DoBirth @ 0x0056E5F0` | — | **IMPLEMENTED** |
| `day` | — | `DoDay @ 0x005706F0` | — | Toggles `LScape::m_fAlwaysDaylight` via `LScape::SetDay`, persists with `PlayerModule::SetPersistentAtDay`, echoes "Let there be light!" when enabling. Pure local. | MISSING |
| `endurance` | — | `DoEndurance @ 0x0057C5F0` | — | Prints a fixed paragraph beginning "The endurance attribute has a nu…". Pure local text. | MISSING (trivial) |
| `framerate` | — | `DoFrameRate @ 0x005707D0` | — | **IMPLEMENTED** |
| `loc` | — | `DoLoc @ 0x0057A250` | — | **IMPLEMENTED** |
| `version` | — | `DoVersion @ 0x0057E1B0` | — | Prints "Client version %s\n"; additionally prints "Using Turbine Chat.\n" when `IsUsingTurbineChat()`, and extra lines when `PlayerIsPSR()`. | **PARTIAL**`ClientCommandController.cs:136` prints the version line only. |
| `render` | — | `DoRenderOption @ 0x0057E120` | `[options]` | Forwards to `SmartBox::HandleRenderOption`, prints its two out-strings. Pure local dev/render toggle. | MISSING |
### 2.8 Interface layout, emotes, social, components
| Verb | Aliases | Handler @addr | acdream status |
|---|---|---|---|
| `saveui` / `loadui` / `saveautoui` / `loadautoui` / `lockui` | — | `0x0056FFF0` / `0x00570150` / `0x005702B0` / `0x00570330` / `0x005703B0` | **IMPLEMENTED** |
| `emote` | `e`, `em`, `me` | `DoEmote @ 0x00578AD0` | **IMPLEMENTED** |
| `emotes` | — | `DoEmoteList @ 0x0057BB30` | **IMPLEMENTED** |
| `afk` | — | `DoAFK @ 0x0057B3F0` | **IMPLEMENTED** |
| `friends` / `friends_add` / `friends_remove` | — | `0x0057BC00` / `0x00578FB0` / `0x00579080` | **IMPLEMENTED** |
| `squelch` / `unsquelch` | — | `0x0057BF50` / `0x0057C070` | **IMPLEMENTED** |
| `fillcomps` | — | `DoFillComponents @ 0x0056FD50` | **IMPLEMENTED** |
### 2.9 Prefixes (not verbs)
| Prefix | Site | Behavior | acdream status |
|---|---|---|---|
| `/` | `OnChatCommand @ 0x00581431` | Rewritten to `@`, then dispatched. | **IMPLEMENTED**`RetailClientCommandCatalog.TryMatch` accepts both (`RetailClientCommandCatalog.cs:267`). |
| `@` | `OnChatCommand @ 0x00581444` | Dispatched directly. | **IMPLEMENTED** |
| `:` , `;` | `OnChatCommand @ 0x0058144D` | First char replaced with a space, line prefixed with `"@emote"`, then dispatched — `:waves``@emote waves`. | **MISSING** |
| trailing `,` on the verb | `DoCommand @ 0x0057E3CD` | Right-trimmed, so `@f, hi``@f hi`. | **MISSING** — acdream's verb token keeps the comma, so `/f, hi` does not match any verb and is shipped to ACE as `@f,`. |
---
## 3. Prioritized missing-command list
Effort key: **L** = pure-local (no wire message); **W-have** = wire path already
exists in acdream; **W-new** = needs a new game action.
### Tier 1 — correctness bugs in what already ships (do these first)
1. **`/g` is bound to General; retail binds it to Fellowship** (`0x800`).
`ChatInputParser.cs:57`. One-line fix, but it silently sends fellowship
chatter to a global channel today. **L**
2. **`/rp` is bound to Roleplay; retail binds it to `reply`.** `ChatInputParser.cs:78`.
Same class of bug (a private reply becomes a global broadcast). **L**
3. **`/allegiance <text>` is bound to the Allegiance channel; retail's
`allegiance`/`all` is the allegiance *management command*.** Channel verbs are
`a`/`ab`/`guild`/`gu`. **L**
4. **`/house <anything-but-recall>` is swallowed locally.** `RetailClientCommandCatalog.cs:288-296`
returns a match with `HasValidArguments:false` for every unrecognized
subcommand, so `@house open`, `@house guest add X`, `@house abandon` never
reach ACE. Until `DoHouse` is ported, unrecognized `house` subcommands must
fall through to `SendServerCommandCmd`. **L**
5. **Verb-trailing-comma trim.** `@f, hi` / `@t Bob, hi` — retail right-trims `,`
off the verb in `DoCommand`. **L**
6. **`/tell` splits on the first comma, not the first space.** `ChatInputParser.cs:208`.
Multi-word names break today. **L**
### Tier 2 — pure-local commands, no wire work
7. `:` / `;` emote prefixes (`OnChatCommand` case 0x0B/0x0C). **L**
8. `@day` — daylight toggle + `SetPersistentAtDay`. **L**
9. `@endurance` — fixed help paragraph. **L**
10. `@speaker` — fixed deprecation line. **L**
11. `@title <text>` — chat window title. **L**
12. `@log [file]` — chat-to-file logging. **L**
13. `@version` — add the "Using Turbine Chat." line. **L**
14. `@render``SmartBox::HandleRenderOption` equivalent (acdream has no SmartBox;
map to the existing quality/debug toggles or leave as a documented divergence). **L**
15. `@help <command>` / `@help <group>` + the 7 group nodes (`commands`,
`allegiances`, `channels`, `chatting`, `death`, `status`, `text`). The exact
retail help strings for all 57 `Help*` functions are recoverable from the
binary — see §4 for the extraction recipe. **L**
16. **Missing aliases on already-implemented commands** (one dictionary edit
each): `pkl`, `hou`, `message_types`, `msgtypes`, `msg_types`, `rt`, `send`,
`whisper`, `w`, `vassal`, `covassal`, `co-vassals`, `c`, `fellows`, `group`,
`party`, `guild`, `gu`, `cg`, `ct`, `clfg`, `crp`, `soc`, `o`, `ab`.
Also delete the non-retail inventions `gen`, `cv`, `lookingforgroup`, `tr`,
`role`, `h`. **L**
### Tier 3 — wire messages acdream already has
17. `@chat on|off` and `@notell on|off` — both are `ModifyGlobalSquelch` with
message types 2 and 3. acdream already wires `ModifyGlobalSquelch` for
`/filter`. **W-have**
18. `@join` / `@leave <room>` — set `PlayerModule::SetHear*Chat`; these ride the
existing character-options path. **W-have**
### Tier 4 — new game actions (ACE-side handlers all exist)
19. `@permit add|remove <name>` — ACE `AddPlayerPermission` / `RemovePlayerPermission`. **W-new**
20. `@hslist <type>` — ACE `ListAvailableHouses`. **W-new**
21. `@index` / `@clist` / `@on` / `@off` — ACE `IndexChannels`, `ListChannels`,
`AddChannel`, `RemoveChannel`. **W-new** (four small parameterless/one-dword actions)
22. `@allegiance <sub>` — the 12-subcommand dispatcher. ACE handlers exist for
every one (`AllegianceInfoRequest`, `AllegianceChatBoot`, `AddAllegianceBan`,
`SetAllegianceOfficer`, `SetAllegianceOfficerTitle`, `RecallAllegianceHometown`,
`SetAllegianceName`, `DoAllegianceLockAction`, `DoAllegianceHouseAction`, …).
Largest single item; deserves its own slice. **W-new**
23. `@house <sub>` — 15 subcommands; ACE handlers exist (`SetOpenHouseStatus`,
`ChangeStoragePermission`, `BootSpecificHouseGuest`, `HouseBootAll`,
`AbandonHouse`, `HouseQuery`, `ModifyAllegianceGuestPermission`, …). **W-new**
24. `@motd [set|clear]` — allegiance MOTD. **W-new**
25. `@ab` — allegiance broadcast. **W-new**
26. `@alh` / `@ah``RecallAllegianceHometown`. **W-new**
27. Turbine-chat verbs as *Turbine* sends (`SendTurbineChat`) rather than plain
`ChannelBroadcast`. acdream already ships the 0xF7DE TurbineChat path, so
this is a routing decision, not new wire work. **W-have**
### Explicitly do NOT implement
- `@mr`, `@pr` — registered with a **null function pointer** in the 2013 build.
Retail ships them as help text only; the command itself falls through to the
server. Implementing them client-side would be a divergence.
- The 22 fallback-only channel tags (`abuse`, `admin`, `audit`, `advocate*`,
`sentinel`, `celhan`, `eldweb`, `radblo`, `ol`, …) are GM/faction channels.
They are reachable in retail only through `DoChannelCommand`'s fallback; the
cheapest faithful port is to add the `GetChannelID` table and let the existing
unknown-verb path consult it before falling back to `SendServerCommandCmd`.
- `@loadfile` — a client-side script player that re-enters `OnChatCommand` for
every line of an arbitrary file. Faithful, but it is a scripting surface;
acdream already has a designed plugin API for that, so porting `@loadfile`
should be an explicit product decision, not a parity checkbox.
---
## 4. Counts
| Measure | Count |
|---|---|
| Verbs registered by `InitializeCommands @ 0x00581970` | **116** |
| Verbs added by `StartupTurbineChatSystem @ 0x0057EFB0` | 15 (14 net-new; `a` is replaced) |
| **Total registered verbs** | **130** |
| …of which have a **null** `func` (help-only nodes) | 9 (`commands`, `allegiances`, `channels`, `chatting`, `death`, `status`, `text`, `mr`, `pr`) |
| Distinct handler functions behind those verbs | 68 (61 base + 7 Turbine) |
| Additional verbs reachable via the `GetChannelID` fallback (unregistered) | 22 |
| **Total client-parsed verbs** | **152** |
| Prefixes with special parsing | 4 (`/`, `@`, `:`, `;`) |
acdream, against the 130 registered verbs. Every verb is in exactly one row, so
the column sums to 130.
| Status | Verbs | Where |
|---|---|---|
| IMPLEMENTED — typed `ExecuteClientCommandCmd` | **45** (33 `ClientCommandId` values) | `RetailClientCommandCatalog.cs` + `ClientCommandController.cs` |
| PARTIAL via typed catalog | **1**`house` (3 of 15 subcommands, and it swallows the rest) | `RetailClientCommandCatalog.cs:279` |
| IMPLEMENTED-EQUIVALENT — chat alias → `SendChatCmd` | **21** | `ChatInputParser.cs` |
| PARTIAL via chat alias (argument shape wrong) | **3**`tell` (space-split, not comma-split), `reply` (by name, not guid), `retell` | `ChatInputParser.cs` |
| PARTIAL via local presentation | **2**`help`, `?` (flat blob; no per-command or group help) | `ChatCommandRouter.cs:96` |
| DIVERGENT — bound, wrong target | **3**`g`→General (retail Fellowship), `rp`→Roleplay (retail reply), `allegiance`→channel (retail command) | `ChatInputParser.cs` |
| **MISSING** | **55** | — |
| **Total registered** | **130** | |
Plus **22** unregistered `GetChannelID` fallback tags, all MISSING → **77**
client-parsed verbs unimplemented out of 152.
Reading note: the §2 tables mark `messagetypes`, `pklite` and `version` PARTIAL
because an *alias* or an *output line* is missing. The counts above are
per-verb, so those three verbs sit in IMPLEMENTED while their missing aliases
(`message_types`, `msgtypes`, `msg_types`, `pkl`) are counted as four separate
MISSING verbs.
Everything not in the table above reaches ACE correctly as
`SendServerCommandCmd` → Talk (`ChatCommandRouter.cs:62`), which matches retail's
own final fallback (`DoCommand``Event_Talk`) except that retail consults the
channel-tag table first.
acdream verbs with **no retail counterpart** (candidates for removal):
`/h`, `/gen`, `/cv`, `/lookingforgroup`, `/tr`, `/role`.
---
## 5. Reproduction recipes
Verb table (authoritative, resolves BN's mislabeled pooled strings):
```
# scan InitializeCommands for `push imm32` operands that point at short
# ASCII strings in .rdata — one per registered verb, in registration order
python pushes.py 581970 585520 # 116 verbs
python pushes.py 57efb0 57f9e0 # 15 Turbine verbs
```
Handler pairing: `sed -n '396604,399712p' acclient_2013_pseudo_c.txt` and read
the `CmdHashData::CmdHashData(ptr, &name, <func>, nullptr)` call (later blocks)
or the `*(esi + 8) = <func>` / `*(esi + 0x10) = <help>` stores (first ~22
blocks — BN renders the same code two different ways).
Exact retail help text for all 57 `Help*` functions: scan each
`ClientCommunicationSystem::Help*` symbol's byte extent for `push imm32` into
`.rdata` and read the C string. This recovered, verbatim, every usage line and
group listing quoted in §2 (e.g. the `@tell` comma requirement, the `@afk msg`
192-character limit, the `@hslist` house types).
`ChannelSystem::GetChannelID @ 0x005CF1F0`: alternate `push imm32` (tag) and
`mov eax, imm32` (channel id) down the function; the id immediately precedes the
next tag group.
Cross-checks performed: ACE `GameActionTalk.cs:21` (the `@`-passthrough
contract) and the `GameActionType` enumeration (confirms server-side handlers
exist for every Tier-4 item). holtburger's `commands.rs` was consulted and
**rejected as an oracle for aliases** — it binds `/g` to Allegiance and `/p` to
Fellowship, which matches neither retail nor acdream.

View file

@ -0,0 +1,931 @@
# Retail's transient on-screen "interface text" — the SpewBox
**Date:** 2026-08-09
**Status:** RESEARCH ONLY. No production code changed.
**Oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build, PDB-named), `docs/research/named-retail/symbols.json`,
`docs/research/named-retail/acclient.h`, plus byte-level string recovery from the
PDB-paired binary `C:\Users\erikn\Downloads\acclient.exe` (v11.4186, CodeView GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`). Server-side cross-check against
`references/ACE/` and `references/holtburger/`.
---
## TL;DR
The system is called the **SpewBox**`gmSpewBoxUI` @ `0x004D5A30`.
The routing rule is one line: **`ClientSystem::AddTextToScroll(text, type, ...)`
broadcasts one notice to every text sink, and the sinks self-select — the SpewBox
takes `type == 0x1A` and nothing else, while every `ChatInterface` window is
constructed with a 64-bit type filter that has bit 26 (`1 << 0x1A`) cleared, so
`0x1A` is exactly the type the chat window refuses and the SpewBox accepts.**
**23 client-raised local refusal sites** were found (11 distinct message strings),
none of which involve a server round-trip. All of them use type `0x1A`.
---
## 1. System identification
### 1.1 The display element
| Symbol | Address | Role |
|---|---|---|
| `gmSpewBoxUI::gmSpewBoxUI` | `0x004D5A30` | ctor; derives from `UIElement_Field` + `NoticeHandler` |
| `gmSpewBoxUI::Create` | `0x004D5C30` | factory (`operator new(0x610)`) |
| `gmSpewBoxUI::Register` | `0x004D5DD0` | `UIElement::RegisterElementClass(0x10000016, gmSpewBoxUI::Create)` |
| `gmSpewBoxUI::GetUIElementType` | `0x004D5AA0` | returns `0x10000016` |
| `gmSpewBoxUI::PostInit` | `0x004D5AB0` | binds the child ListBox, reads max-items, registers for notice `0x186B6` and global message `3` |
| `gmSpewBoxUI::RecvNotice_DisplayFinalStringInfo` | `0x004D60A0` | **the type filter**`if (arg2 == 0x1A) m_spewBoxPending.AddToEnd(str)` |
| `gmSpewBoxUI::Update` | `0x004D5DF0` | drains the pending queue into the ListBox |
| `gmSpewBoxUI::ListenToGlobalMessage` | `0x004D6090` | `if (msg == 3) Update()` |
| `gmSpewBoxUI::ListenToElementMessage` | `0x004D57C0` | `if (idElement == 0x1000004A && idMessage == 0x10000003) DeleteItem()` — the expiry hook |
| `gmSpewBoxUI::~gmSpewBoxUI` | `0x004D5BD0` | unregisters |
It is registered alongside the rest of the HUD in the element-class registration
block at `0x0047A4A6` (`gmClient` init), between `gmSmartBoxUI::Register()` and the
`gmFloaty*UI` family.
### 1.2 The router
| Symbol | Address | Role |
|---|---|---|
| `ClientSystem::AddTextToScroll(PStringBase<wchar>, uint type, uint8 allowPluginFilter, uint windowId)` | `0x00563C50` | **the single chokepoint** for all player-visible text |
| `ClientSystem::AddTextToScroll(PStringBase<char>, ...)` | `0x004C2420` | narrow-string overload → widens → above |
| `ClientSystem::AddTextToScroll(char const*, ...)` | `0x00487FC0` | literal overload → widens → above |
| `ECM_UI::SendNotice_DisplayFinalStringInfo` | `0x00692550` | broadcast to notice id `0x186B6` |
| `ECM_UI::SendNotice_DisplayStringInfo` | `0x006925B0` | broadcast to notice id `0x186A5` |
| `ECM_UI::SendNotice_DisplayWeenieError` | `0x00692600` | broadcast to notice id `0x186B7` |
| `ClientCommunicationSystem::RecvNotice_DisplayStringInfo` | `0x0056E890` | notice `0x186A5``AddTextToScroll` |
| `ClientCommunicationSystem::RecvNotice_DisplayWeenieError` | `0x0057E700` | notice `0x186B7``HandleFailureEvent` |
| `ClientCommunicationSystem::HandleFailureEvent(uint errorId, PStringBase<wchar> param)` | `0x00571990` | **the error-id → text + destination switch** (339 cases) |
| `ChatInterface::RecvNotice_DisplayFinalStringInfo` | `0x004F4640` | the chat-window sink |
| `ChatInterface::TypeIsActive` | `0x004F2F10` | `(m_llTextTypeFilter >> type) & 1` |
| `ChatInterface::ChatInterface` (ctor) | `0x004F4550` | sets the default filter — see §2.2 |
| `ChatInterface::BuildChatColorLookupTable` | `0x004F31C0` | per-type chat colors |
### 1.3 The wire entry points
| Symbol | Address | Opcode | Behaviour |
|---|---|---|---|
| `ClientCommunicationSystem::Handle_Communication__TextboxString` | `0x0057D3A0` | **`0xF7E0`** (`ServerMessage`) | squelch check, then `AddTextToScroll(text, wireChatType, 1, 0)`**the wire type decides the destination** |
| `ClientCommunicationSystem::Handle_Communication__TransientString` | `0x0057D460` | **`0x02EB`** (GameEvent) | `AddTextToScroll(text, 0x1A, 1, 0)`**hardcoded to the SpewBox** |
| `ClientCommunicationSystem::Handle_Communication__PopUpString` | `0x0057FE80` | **`0x0004`** (GameEvent) | builds a `PropertyCollection` and calls `DialogFactory::MakeDialogInCurrentUI`**a modal dialog, neither chat nor spew** |
| `ClientCommunicationSystem::RecvNotice_DisplayWeenieError` | `0x0057E700` | **`0x028A` / `0x028B`** | → `HandleFailureEvent` → per-id destination |
Dispatch table sites: `0x0055CA1F` (`0xF7E0`), `0x0055C581` (`0x02EB`),
`0x0055B0BD` (event `0x0004`), all inside the `UIQueueManager` message switch that
begins at `0x0055B000`.
---
## 2. The routing model
### 2.1 One broadcast, self-selecting sinks
`ClientSystem::AddTextToScroll` @ `0x00563C50` does, in order:
1. **Plugin veto.** If `allowPluginFilter != 0` and the plugin API is ready, call
`IACPlugin::OnChatWindowText(bstr, type, &eat)`. If the plugin sets `eat`, the
message is dropped entirely — it reaches neither chat nor spew.
(`0x00563C7D``0x00563CB6`.)
2. **Trim** trailing whitespace.
3. **Censor.** If `PlayerModule::FilterLanguage()`, explode on spaces and replace
any word failing `TabooTableAdaptor::CheckCensorsW` with `****`.
4. **Branch on type** (`0x00563DE6`):
- `type == 0x1A`**skip the timestamp, skip the chat log file**, jump straight
to the broadcast.
- otherwise → prepend `%#H:%M:%S ` if `PlayerModule::DisplayTimeStamps()`, and
`fprintf` the line to `ClientSystem::s_pLogFile` if a chat log is open.
5. **Broadcast** `ECM_UI::SendNotice_DisplayFinalStringInfo(type, mainStr,
prefixStr, windowId)`.
That last call goes to *every* registered handler of notice `0x186B6`. There are
exactly two kinds of subscriber:
- **`gmSpewBoxUI`** (`0x004D60A0`): `if (type == 0x1A) enqueue`. Nothing else, and
it ignores `windowId` entirely.
- **`ChatInterface`** (`0x004F4640`), one per chat window:
```
if (windowId == this->m_eWindowID) -> append
else if (windowId == 0 && TypeIsActive(type)) -> append
else -> ignore
```
So the routing rule is a **type filter on the receiver side**, not a switch on the
sender side. There is no "destination" field anywhere in the data.
The fact that type `0x1A` is *skipped* for timestamping and chat-log-file writing
(step 4) is the client author's own statement that `0x1A` is not chat.
### 2.2 Why `0x1A` never appears in the chat window
`ChatInterface::ChatInterface` @ `0x004F4550`:
```
0x004F45B8 this->m_llTextTypeFilter = 0xFFFFFFFF; // low dword
0x004F45BE ((uint32*)&m_llTextTypeFilter)[1] = 0xFFFFFFFF; // high dword
0x004F45F3 this->m_llTextTypeFilter &= 0xFBFFFFFF; // clear bit 26
```
`0xFBFFFFFF` = `~0x04000000` = `~(1 << 26)` = `~(1 << 0x1A)`.
**Every chat window is born with every text type enabled except `0x1A`.** That is
the whole mechanism. `TypeIsActive` @ `0x004F2F10` is just
`(m_llTextTypeFilter >> type) & 1`.
The filter is subsequently overwritten from a saved UI bitfield property
(`InqBitfield64` at `0x004F3109` and `0x004F3984`), so in principle a chat window
could be configured to show `0x1A` — but the shipped chat-options UI does not offer
it, which is why ACE's `ChatMessageType.cs:255-259` concluded "Client doesn't
display it" and commented `x1A` out. **That conclusion is wrong and worth
recording:** the client does display `0x1A`, just not in the chat scroll.
### 2.3 The complete destination model
| Destination | Owner | Trigger |
|---|---|---|
| **Chat scroll** (one or more windows) | `ChatInterface` @ `0x004F4640` | `AddTextToScroll` with `type != 0x1A`, or with `windowId == this window` |
| **SpewBox** (transient screen text) | `gmSpewBoxUI` @ `0x004D60A0` | `AddTextToScroll` with `type == 0x1A` |
| **Modal dialog** | `DialogFactory::MakeDialogInCurrentUI` | `Handle_Communication__PopUpString` (event `0x0004`) only |
| **Chat log file** | `ClientSystem::s_pLogFile` | `AddTextToScroll` with `type != 0x1A` |
| **Plugin sink / veto** | `IACPlugin::OnChatWindowText` | every `AddTextToScroll` with `allowPluginFilter != 0` |
| **(dropped)** | — | plugin sets the `eat` out-param |
Note that a `type == 0x1A` message with a **non-zero `windowId`** lands in *both*
the SpewBox and that specific chat window. This is exactly what slash-command
output does: `ClientCommunicationSystem` emits its command responses as
`AddTextToScroll(text, 0x1A, 1, this->m_idCurrentCommandSource)` (~40 sites from
`0x0056EF3B` through `0x005707FB`), so a `/`-command's reply appears on screen
*and* is echoed into the window you typed it in.
### 2.4 Where the strings come from
**Not from `client_local_English.dat`.** Every player-visible error string in this
path is a **wide-char literal compiled into `acclient.exe`**:
- `HandleFailureEvent` @ `0x00571990` builds each one inline
(`PStringBase<unsigned short>::PStringBase<unsigned short>(&var, u"…")`) or via
`PStringBase<unsigned short>::sprintf(&s, u"The %s cannot be used …")` with the
`0x028B` string parameter substituted.
- The 11 movement/jump refusals are process-lifetime globals initialised by static
ctors at `0x00708F00``0x00709180` (see §4).
The DAT `StringTable` machinery (`StringInfo::SetTableEnum`,
`StringInfo::SetStringIDandTableEnum`) exists and is used for **UI chrome**
option labels, tooltips, command aliases — but the failure-event text is hardcoded.
`StringInfo::SetLiteralValue` is what the failure path uses.
This matters for the port: **we do not need a DAT string table to reach parity on
this feature.** A C# table keyed by `WeenieError` id is exactly what retail does.
---
## 3. Presentation parameters
### 3.1 What acclient owns (portable, measured)
From `gmSpewBoxUI::PostInit` @ `0x004D5AB0` and `gmSpewBoxUI::Update` @ `0x004D5DF0`:
| Behaviour | Evidence | Value |
|---|---|---|
| Backing widget | `0x004D5AD7` | a `UIElement_ListBox` found by `GetChildRecursive(0x10000049)` |
| Mouse | `0x004D5AC4`, `0x004D5AFE` | the SpewBox and its ListBox are both `SetMouseVisible(0)`**click-through** |
| Background | `0x004D5ABB` | `SetShouldEraseBackground(1)` |
| Max concurrent lines | `0x004D5B34` | ListBox property `0x10000028`; **defaults to 1** if the property is absent or unreadable |
| Per-line widget | `0x004D5E42` | `CreateChildElementByEnum(parent=null, layoutEnum=0x10000012, elementId=0x1000004A)` — a DAT-authored `UIElement_Text` template |
| Text preprocessing | `0x004D5E9C` | `trim(leading=0, trailing=1, whitespace)` — trailing whitespace stripped |
| Sizing | `0x004D5EB1``0x004D5EE6` | resized to the ListBox's width, then `RecalculateGlyphList`, then resized again to the computed scrollable height (word wrap) |
| **Dedupe** | `0x004D5EF6``0x004D5F91` | if the current item 0 has **byte-identical text**, that older item is deleted first. A repeated message refreshes in place instead of stacking. |
| Insertion | `0x004D5F9F` | `InsertItem(item, 0)`**newest at the top** |
| Overflow | `0x004D5FB6` | if `count > m_maxConcurrentItems`, `DeleteItem(count - 1)`**oldest drops off** |
| Scroll | `0x004D601D` | `ScrollToShow(0)` after a batch |
| Drain cadence | `0x004D5BA6`, `0x0045CFFB` | global message `3`, broadcast once per UI tick from `UIElementManager::UseTime` @ `0x0045CFD0` |
| Expiry hook | `0x004D57D7` | the SpewBox deletes an item when it receives element message `0x10000003` from element id `0x1000004A` |
The queue is a `SmartArray<StringInfo,1> m_spewBoxPending`; `RecvNotice_*` only
enqueues, `Update` only drains. Enqueue and display are decoupled by one frame.
### 3.2 PRESENTATION-UNKNOWN (keystone / DAT-owned)
These could **not** be established from acclient and must not be guessed:
1. **Line lifetime / fade curve.** acclient never raises element message
`0x10000003`. I searched every `BroadcastElementMessage` / `ForwardElementMessage`
call site in the whole 66 MB listing: the only element message id above
`0x10000000` that acclient itself raises is `0x10000004`
(`0x004F0EC5`, a stat-type element). `UIElement` / `UIRegion` / `ElementDesc` /
`LayoutDesc` expose no `Duration` / `Lifetime` / `Fade` / `Expire` member at all.
**The timeout and any fade are owned by keystone.dll or by the authored
`ElementDesc` behaviour of layout `0x10000012` element `0x1000004A`.**
*Resolution path:* dump that LayoutDesc from `client_local_English.dat`, or set a
cdb breakpoint on `gmSpewBoxUI::ListenToElementMessage` (`0x004D57C0`) in a live
retail client and time the deltas between a message appearing and its removal.
2. **Screen position and extent.** Authored in whatever LayoutDesc declares an
element of class `0x10000016`. The natural host is the main game view
(`gmSmartBoxUI`, LayoutDesc `0x2100000F`) but **this was not confirmed** — no
dumped layout in `docs/research/retail-ui/` mentions it.
*Resolution path:* enumerate LayoutDescs and look for element type `0x10000016`.
3. **Font, size, justification, colour of the SpewBox line.** All from the same DAT
template. In particular, the SpewBox does **not** use the chat colour table:
`BuildChatColorLookupTable` writes to `ChatInterface::m_chatLog`, a different
element tree entirely.
4. **Max concurrent items in the shipped layout.** The code reads ListBox property
`0x10000028`; the authored value is DAT data. The *code default* is 1.
### 3.3 The chat-window colour table (adjacent, for completeness)
`ChatInterface::BuildChatColorLookupTable` @ `0x004F31C0` assigns
`RGBAColor` constants to text types. Colour values below are from
`claude-memory/reference_retail_chat_colors.md` (dumped live via cdb, 2026-06-16).
| Type(s) | Colour symbol | Addr | RGB |
|---|---|---|---|
| *default (all)* | `colorGreen` | `0x81C578` | 0.500, 1.000, 0.498 |
| `0x02` | `colorWhite` | `0x81C4B8` | 1, 1, 1 |
| `0x03 0x0A 0x13 0x1F` | (yellow, unnamed) | `0x81C4C8` | 1, 1, 0.247 |
| `0x04 0x0B` | (unnamed, not yet read) | `0x81C4D8` | — |
| `0x05` | `colorBrightPurple` | `0x81C4E8` | 1, 0.498, 1 |
| `0x06 0x0F 0x15` | `colorDarkRed` | `0x81C4F8` | 1, 0.247, 0.247 |
| `0x07 0x11` | `colorLightBlue` | `0x81C518` | 0.247, 0.749, 1 |
| `0x08 0x09` | `colorPink` | `0x81C528` | 1, 0.588, 0.588 |
| `0x0C` | `colorGrey` | `0x81C558` | 0.824, 0.824, 0.784 |
| `0x0D` | `colorCyan` | `0x81C538` | 0.247, 0.863, 0.863 |
| `0x0E 0x1B 0x1C 0x1D 0x1E 0x20` | `colorBlueGrey` | `0x81C548` | 0.706, 0.863, 0.941 |
| `0x12 0x21` | (orange, unnamed) | `0x81C568` | 0.933, 0.573, 0.118 |
| `0x16` | `colorLightRed` | `0x81C508` | 0.960, 0.459, 0.447 |
| `0x1A` | `colorBrightRed` | `0x81C4A8` | 1, 0, 0 |
Two things fall out of this table:
- The `0x1A` row exists purely for the case where a user manually enables the
filter bit. **It is not the SpewBox's colour.** Do not port it as such.
- Types `0x20` and `0x21` are real and coloured. **ACE's `ChatMessageType` stops at
`0x1F`** — the client's text-type space is wider than the server-side enum.
---
## 4. Client-raised local errors (no server round-trip)
Retail refuses several actions locally and prints the refusal itself. All of them
land on type `0x1A`.
### 4.1 The message globals
Static ctors at `0x00708F00``0x00709180`. Strings recovered verbatim from the
binary (the pseudo-C truncates at 33 chars).
| Global | Full text | Used? |
|---|---|---|
| `cant_jump_position` | `You can't jump from this position` | yes (3 sites) |
| `cant_jump_in_air` | `You can't jump while in the air` | yes (3 sites) |
| `cant_jump_load` | `You're too loaded down to jump` | yes (3 sites) |
| `cant_jump_stamina` | `You're too tired to jump!` | **dead in this build** |
| `cant_jump_recent` | `You've jumped too recently!` | **dead in this build** |
| `too_tired` | `You are too tired to move!` | yes (1 site) |
| `cant_sit_combat` | `You can't sit down while in combat` * | yes (1 site) |
| `cant_lie_down_combat` | `You can't lie down while in combat` * | yes (1 site) |
| `cant_crouch_combat` | `You can't crouch while in combat` * | yes (1 site) |
| `cant_emote_combat` | `You can't use chat emotes in combat` * | yes (1 site) |
| `cant_emote_position` | `You can't use chat emotes from this position` * | yes (1 site) |
\* these five were length-truncated in the listing; the prefixes are exact, the
tails are the obvious completion and should be re-read from the binary before being
committed as literals.
### 4.2 Raise sites
**Jump family** — the source of the codes is `CMotionInterp` and they are
`WeenieError` ids, the same numbering the server uses.
| Function | Addr | Codes it produces |
|---|---|---|
| `CMotionInterp::charge_jump` | `0x005281C0` | `0x49` if `CWeenieObject::CanJump(jump_extent)` fails; `0x48` if `forward_command` is a disallowed posture; `0` otherwise |
| `CMotionInterp::jump_is_allowed` | `0x005282B0` | `0x24` if not on the ground; `0x47` if fully constrained or out of stamina; else defers to `jump_charge_is_allowed` / `motion_allows_jump` |
| Consumer | Addr | Sites |
|---|---|---|
| `ClientCombatSystem::CommenceJump` | `0x0056AF90` | `0x0056AFE3``cant_jump_position` (0x48); `0x0056AFD7``cant_jump_load` (0x49); `0x0056AFCB``cant_jump_in_air` (fallback) |
| `ClientCombatSystem::DoJump` | `0x0056B110` | `0x0056B29A``cant_jump_in_air` (0x24); `0x0056B27E``cant_jump_position` (0x48); `0x0056B262``cant_jump_load` (0x49) |
| `ClientCommunicationSystem::HandleFailureEvent` | `0x00571990` | `0x00571DA1` (0x24), `0x00571D73` (0x48), `0x00571D8A` (0x49) — **the same three globals, reused for the server-sent ids** |
That last row is the important one: retail reuses one string table for
locally-detected and server-reported failures. The client-local path is a *latency
optimisation over the server's own answer*, not a separate feature.
**Movement / posture / emote family** — `CommandInterpreter::MovePlayer` @
`0x006B3F40`, switching on `CPhysicsObj::DoMotion`'s return:
| Code | Addr | Message | Emit |
|---|---|---|---|
| `0x3E` | `0x006B43E4` | `too_tired` | `ECM_UI::SendNotice_DisplayStringInfo(0x1A, ...)` |
| `0x3F` | `0x006B4366` | `cant_crouch_combat` | same |
| `0x40` | `0x006B43A4` | `cant_sit_combat` | same |
| `0x41` | `0x006B43C4` | `cant_lie_down_combat` | same |
| `0x42` | `0x006B43F6` | `cant_emote_combat` | same |
| `0x44` | `0x006B4419` | `cant_emote_position` | same |
These take the `SendNotice_DisplayStringInfo` path (notice `0x186A5`) rather than
calling `AddTextToScroll` directly, but
`ClientCommunicationSystem::RecvNotice_DisplayStringInfo` @ `0x0056E890` immediately
forwards to `AddTextToScroll(str, 0x1A, 1, 0)`, so the outcome is identical.
**Vendor family** — `0x004C4575`, `AddTextToScroll("You need an open vendor.", 0x1A, 1, 0)`.
**Total: 23 client-raised sites, 6 enclosing functions, 11 distinct strings.**
### 4.3 What is *not* client-raised
Retail does **not** locally generate "You are too encumbered to carry that!" —
`0x2A` arrives from the server as `WeenieError` and is turned into text by
`HandleFailureEvent`. Likewise spell fizzle (`0x0402`) is server-sent. ACE confirms
this shape: `Player_Inventory.cs` sends the encumbrance message as
`GameEventCommunicationTransientString` (`0x02EB`) rather than as a `WeenieError`
at all, and `Player_Magic.cs:918` sends `SendWeenieError(YourSpellFizzled)`.
---
## 5. What the server sends (ACE cross-check)
| Opcode | Class | Payload | Client destination |
|---|---|---|---|
| `0xF7E0` `ServerMessage` | `GameMessageSystemChat` | `string16L text`, `u32 chatType` | `AddTextToScroll(text, chatType, 1, 0)` — chat *or* spew depending on the type |
| GameEvent `0x02EB` `CommunicationTransientString` | `GameEventCommunicationTransientString` | `string16L text` only, **no type field** | hardcoded `0x1A`**SpewBox** |
| GameEvent `0x028A` `WeenieError` | `GameEventWeenieError` | `u32 errorId` | `HandleFailureEvent` → per-id (see appendix) |
| GameEvent `0x028B` `WeenieErrorWithString` | `GameEventWeenieErrorWithString` | `u32 errorId`, `string16L param` | same, with `%s` substitution |
| GameEvent `0x0004` `PopUpString` | — | `string16L text` | `DialogFactory::MakeDialogInCurrentUI` → modal dialog |
ACE never resolves a `WeenieError` id to text — it always writes the bare u32
(`references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventWeenieError.cs`).
**The client owns every error string.** `0x48 = YouCantJumpFromThisPosition`,
`0x49 = CantJumpLoadedDown` (ACE marks the latter "client side only", consistent
with our `charge_jump` finding).
`references/holtburger/` is **not** a useful oracle here: it flattens `0x02EB` into
a plain system chat line
(`crates/holtburger-core/src/client/messages.rs:268-275`) and has no transient
destination at all. It *is* a useful oracle for id→text: its hand-written
`format_weenie_error` table (`crates/holtburger-core/src/errors.rs`) covers ~60
ids, and its `is_actually_weenie_error()` allowlist (`errors.rs:302-315`) correctly
notes that several "errors" are success notices.
---
## 6. acdream gap list
Verified against the worktree at `.claude/worktrees/eloquent-hugle-42119e`.
### 6.1 Routing
| Retail | acdream today | Gap |
|---|---|---|
| One `AddTextToScroll(text, type, ...)` chokepoint feeding N self-selecting sinks | `GameEventWiring.cs:223/228` calls `chat.OnWeenieError(...)`; `LiveSessionEventRouter.cs:268` calls `Chat.OnSystemMessage(text, chatType)` | **No chokepoint, no sink model.** Every producer writes directly into `ChatLog`. |
| Destination decided by text type on the receiver | `ChatLog` is the only destination | **The whole transient destination is missing.** |
| Text type `0x1A` = SpewBox | wire `chatType` *is* parsed and stored in `ChatEntry.ChannelId` but **never read for display**; colour comes solely from the 9-value `ChatKind` enum (`ChatWindowController.cs:542-555`) | The discriminator we need is on the wire, captured, and then thrown away. |
| 34-value text-type space (0x000x21) | no enum mirroring it — `ChatKind` (9 buckets), `TurbineChat.ChatType` (rooms), `ChatChannelKind` (outbound) are all different axes | **Missing enum.** Raw `0x1Au` literals already appear at `InteractionRetainedUiComposition.cs:348/418/756/771` and `SessionPlayerComposition.cs:1128` with no name. |
| `0x02EB CommunicationTransientString` → always spew | **not wired at all** | Missing message. |
| `0x0004 PopUpString` → modal dialog | `GameEventWiring.cs:126` `chat.OnPopup(...)``ChatLog` | Wrong destination (retail opens a dialog). Out of scope for this port but worth a register row. |
| Plugin veto hook `OnChatWindowText(text, type, &eat)` | none | Missing; note it for the plugin API. |
| Timestamp + chat-log-file suppressed for `0x1A` | n/a | Falls out of the port if the sink split is done right. |
### 6.2 Presentation
`PortalWaitNoticeController` (`src/AcDream.App/UI/PortalWaitNoticeController.cs`) is
the closest existing thing: a single centred full-screen `UiText`, `ClickThrough`,
`ZOrder = int.MaxValue`, ported from `gmSmartBoxUI::UseTime`. It is a **single
overwrite-only slot with no queue, no timeout, no fade** — structurally the right
shape but missing every SpewBox behaviour (bounded queue, newest-on-top, dedupe
against the newest, per-line expiry).
`TextRenderer` + `BitmapFont` (`src/AcDream.App/Rendering/`) are a 2D screen-space
quad batcher and an ASCII atlas — primitives with no message concept.
`DebugVM.ToastKind`/`AddToast` is a 25-deep ring rendered **inside the ImGui dev
panel only** (`DebugPanel.cs:88`), explicitly documented as "no on-screen flash".
There is **no** spew-box panel, controller, or element id anywhere in `src/` — a
tree-wide grep for `Spew` returns zero hits.
### 6.3 Strings
Two unrelated hardcoded maps exist and neither covers the SpewBox set:
- `src/AcDream.Core/Chat/WeenieErrorMessages.cs` — ~30 no-param + ~28 with-string
templates, fallback `"WeenieError 0x{code:X4}"`.
- `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` — 4 codes, used only by the
`UseDone` handler.
Neither has `0x0048` or `0x0049`. A server-sent `0x48` renders today as the literal
string `WeenieError 0x0048`. Retail has 339 ids in its switch.
### 6.4 Client-raised errors — the sharpest gap
`src/AcDream.Core/Physics/MotionInterpreter.cs` **already computes the right codes**:
`JumpChargeIsAllowed` (`:1762-1773`), `ChargeJump` (`:1827-1851`, an explicit port
of `CMotionInterp::charge_jump @ 0x005281C0`), `JumpIsAllowedSharedGate`
(`:2052-2070`). They are unit-tested
(`tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs`).
They are then **discarded**:
- `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:2473`
`_motion.ChargeJump();` with the return value not assigned to anything.
- `PlayerMovementController.cs:2484-2514``var jumpResult = _motion.jump(...)`,
`if (jumpResult == WeenieError.None) { ...launch... }`, **no `else`**. On refusal
the controller resets `_jumpCharging` / `_jumpExtent` and returns silently.
So the player sees the power bar drain and nothing happen. **No code path in acdream
carries a locally-produced `WeenieError` to any display surface** — the only
`WeenieError → text` conversions are triggered by inbound wire events.
---
## 7. Recommended port shape
Layered per `docs/architecture/acdream-architecture.md` and the Code Structure Rules.
### 7.1 `AcDream.Core` — the type space and the strings
1. **`AcDream.Core/Chat/TextMessageType.cs`** — a `uint`-backed enum mirroring the
client's 0x000x21 space, not ACE's truncated 0x000x1F. Names from ACE's
`ChatMessageType` where they exist; `SpewBox = 0x1A` for the one retail leaves
unnamed; explicit placeholders for `0x20`/`0x21` (coloured in retail, unnamed in
every server-side oracle). This retires the raw `0x1Au` literals already
scattered through `InteractionRetainedUiComposition` and
`SessionPlayerComposition`.
2. **Extend `WeenieErrorMessages`** into the full retail table: `(id) → (template,
TextMessageType)`. The `TextMessageType` column *is* the routing decision, taken
verbatim from `HandleFailureEvent` — the appendix below is the transcription.
Keep `_`/`{0}` interpolation for the `0x028B` parameter. Fold
`WeenieErrorText.cs` into it (it is a 4-entry duplicate).
3. **`ClientTextRefusals`** — the 11 client-local literals from §4.1 as named
constants, so the jump/posture sites and the `HandleFailureEvent` table share
one string exactly as retail does.
### 7.2 `AcDream.Core` / `AcDream.Runtime` — the chokepoint and the sinks
4. **One router**, the direct analogue of `ClientSystem::AddTextToScroll`:
`AddText(string text, TextMessageType type, uint windowId = 0)`. It owns:
trim → (future) plugin veto → branch: `type == SpewBox` bypasses timestamp and
log-file, everything else does not → publish one event.
Given J4.1, the natural owner is **`RuntimeCommunicationState`**
(`src/AcDream.Runtime/...`), which already owns the canonical transcript. It
should expose *two* borrowed views: the existing chat transcript and a new
**`SpewBoxState`**.
5. **`SpewBoxState`** in Runtime — pure state, no presentation:
- pending queue drained once per tick (retail's global message 3),
- `MaxConcurrentItems` (retail code default 1; the shipped value is DAT data —
see the open question in §8),
- insert at index 0,
- **dedupe against index 0 only** (identical text deletes the older entry first),
- drop index `count-1` on overflow,
- per-entry expiry timestamp. Retail's expiry lives in keystone; until it is
measured, this is a **divergence needing a register row** (see §7.5).
6. **Rewire the producers**: `GameEventWiring` `0x028A`/`0x028B`
look up `(template, type)` → router. New `0x02EB` handler → router with
`type = SpewBox`. `LiveSessionEventRouter:268` (`0xF7E0`) → router with the wire
`chatType` instead of `Chat.OnSystemMessage`.
7. **Wire the local refusals.** `PlayerMovementController.cs:2473` and `:2484-2514`
currently drop `WeenieError` values on the floor. Give both an `else` that calls
the router with the matching string at `TextMessageType.SpewBox`. Same for the
posture/emote family if/when `CommandInterpreter::MovePlayer` is ported.
### 7.3 `AcDream.UI.Abstractions` — the contract
8. A `SpewBoxVM` snapshot (ordered lines + remaining lifetime) beside the existing
`ChatVM`, per Code Structure Rule 3. Panels must not reach into Runtime.
### 7.4 `AcDream.App` — presentation
9. A `SpewBoxController` next to `PortalWaitNoticeController`, using the same proven
pattern: full-width `UiText` block, `ClickThrough`, high `ZOrder`, newest line at
the top. `PortalWaitNoticeController` is the template to copy — it is already the
right kind of object, it just holds one slot instead of a bounded list.
10. **Do not** reuse the chat colour table for it. The SpewBox colour is DAT-owned;
until the layout is dumped, pick a placeholder and put a register row on it.
### 7.5 Divergence-register rows this port must add
Per the mandatory bookkeeping rule, the following are deviations at the moment of
landing and each needs a row in
`docs/architecture/retail-divergence-register.md` **in the same commit**:
- SpewBox line lifetime / fade curve is invented, not measured (keystone-owned) —
risk: lines linger or vanish visibly faster/slower than retail.
- SpewBox screen position / font / colour are invented until the LayoutDesc is
dumped — risk: text in the wrong place or the wrong colour.
- `MaxConcurrentItems` uses the code default (1) rather than the authored DAT value
— risk: bursts of refusals collapse to one visible line where retail shows N.
- `0x0004 PopUpString` continues to route to chat rather than a modal dialog.
---
## 8. Open questions / next steps
1. **Which LayoutDesc hosts the SpewBox?** Enumerate LayoutDescs in
`client_local_English.dat` for an element of type `0x10000016`. That yields
position, extent, and the ListBox's `0x10000028` max-items property.
2. **What is the line lifetime?** Two options, both cheap:
(a) dump layout enum `0x10000012` element `0x1000004A` and read the authored
behaviour; (b) attach cdb to live retail with a breakpoint on
`gmSpewBoxUI::ListenToElementMessage` @ `0x004D57C0` and on
`gmSpewBoxUI::RecvNotice_DisplayFinalStringInfo` @ `0x004D60A0`, then spam
`You can't jump while in the air` and diff the timestamps. Option (b) also
answers "does it fade or does it pop?" if the item's alpha is sampled.
3. **What is `0x81C4D8`?** The one chat colour not yet read (types `0x04`/`0x0B`).
Trivial to grab in the same cdb session (`dd 0x81c4d8 L4`).
4. **Re-read the five truncated posture/emote literals** from the binary before
committing them.
5. Should the plugin `OnChatWindowText` veto hook be part of the acdream plugin
API? Retail lets a plugin suppress any line before it reaches any sink.
---
## Appendix A — `HandleFailureEvent` routing table
Transcribed from `ClientCommunicationSystem::HandleFailureEvent` @ `0x00571990`
(339 cases). `Type` is the literal argument passed to
`ClientSystem::AddTextToScroll`, i.e. the routing decision:
- **`0x1A`** → **SpewBox** (transient on-screen), 119 ids
- **`0x00`** → chat, default/broadcast colour (green), 162 ids
- **`0x07`** → chat, `Magic` channel (light blue), 58 ids
`%s` is the `0x028B` string parameter. Strings are the full binary literals where
recovery was unambiguous; `[AMBIG n]` marks a truncated prefix that matched *n*
candidates in the binary (the shortest is shown) and must be re-read before use.
| Error id | Type | Text |
|---|---|---|
| `0x017` | **0x1A** | You failed to go to non-combat mode. |
| `0x01D` | **0x1A** | You're too busy! |
| `0x01E` | **0x1A** | You must control both objects! |
| `0x020` | **0x1A** | You must control both objects! |
| `0x023` | **0x1A** | Unable to move to object! |
| `0x024` | **0x1A** | *(no literal — uses a shared string global; see §4.1)* |
| `0x026` | **0x1A** | That is not a valid command. |
| `0x028` | **0x1A** | The item is under someone else's control! |
| `0x029` | **0x1A** | You cannot pick that up! |
| `0x02A` | **0x1A** | You are too encumbered to carry that! |
| `0x02B` | 0x00 | cannot carry anymore.\n |
| `0x036` | **0x1A** | Action cancelled! |
| `0x037` | **0x1A** | Unable to move to object! |
| `0x038` | **0x1A** | Unable to move to object! |
| `0x039` | **0x1A** | Unable to move to object! |
| `0x03A` | **0x1A** | You can't do that... you're dead! |
| `0x03D` | **0x1A** | You charged too far! |
| `0x03E` | **0x1A** | You are too tired to do that! |
| `0x048` | **0x1A** | *(no literal — uses a shared string global; see §4.1)* |
| `0x049` | **0x1A** | *(no literal — uses a shared string global; see §4.1)* |
| `0x04A` | 0x00 | Ack! You killed yourself!\n |
| `0x04D` | **0x1A** | Invalid PK status! |
| `0x04E` | 0x07 | You fail to affect %s because you cannot affect anyone! [AMBIG 4] |
| `0x050` | 0x07 | You fail to affect %s because beneficial spells do not affect %s! |
| `0x051` | 0x07 | You fail to affect %s because you cannot affect anyone! [AMBIG 4] |
| `0x052` | 0x07 | You fail to affect %s because %s is not a player killer! |
| `0x053` | 0x07 | You fail to affect %s because you cannot affect anyone! [AMBIG 4] |
| `0x054` | 0x07 | You fail to affect %s because you cannot affect anyone! [AMBIG 4] |
| `0x3EF` | 0x00 | is not accepting gifts right now. |
| `0x3F1` | **0x1A** | You failed to go to non-combat mode. |
| `0x3F7` | **0x1A** | You are too fatigued to attack! |
| `0x3F8` | **0x1A** | You are out of ammunition! |
| `0x3F9` | **0x1A** | Your missile attack misfired! |
| `0x3FA` | **0x1A** | You've attempted an impossible spell path! |
| `0x3FE` | **0x1A** | You don't know that spell! |
| `0x3FF` | **0x1A** | Incorrect target type |
| `0x400` | **0x1A** | You don't have all the components for this spell. |
| `0x401` | **0x1A** | You don't have enough Mana to cast this spell. |
| `0x402` | 0x07 | Your spell fizzled.\n |
| `0x403` | **0x1A** | Your spell's target is missing! |
| `0x404` | **0x1A** | Your projectile spell mislaunched! |
| `0x407` | **0x1A** | Your spell cannot be cast outside |
| `0x40A` | **0x1A** | You are unprepared to cast a spell |
| `0x40B` | **0x1A** | You've already sworn your Allegiance |
| `0x40C` | **0x1A** | You don't have enough experience available to swear Allegiance |
| `0x413` | **0x1A** | %s is already one of your followers |
| `0x414` | **0x1A** | You are not in an allegiance! |
| `0x416` | **0x1A** | %s cannot have any more Vassals |
| `0x41D` | **0x1A** | You must be the leader of a Fellowship |
| `0x41E` | **0x1A** | Your Fellowship is full |
| `0x41F` | **0x1A** | That Fellowship name is not permitted |
| `0x422` | **0x1A** | That channel doesn't exist. |
| `0x423` | **0x1A** | You can't use that channel. |
| `0x424` | **0x1A** | You're already on that channel. |
| `0x425` | **0x1A** | You're not currently on that channel. |
| `0x427` | **0x1A** | You cannot merge different stacks! |
| `0x428` | **0x1A** | You cannot merge enchanted items! |
| `0x429` | **0x1A** | You must control at least one stack! |
| `0x432` | **0x1A** | Your craft attempt fails. |
| `0x433` | **0x1A** | Your craft attempt fails. |
| `0x434` | **0x1A** | Given that number of items, you cannot craft anything. |
| `0x435` | **0x1A** | Your craft attempt fails. |
| `0x437` | **0x1A** | Either you or one of the items involved does not pass the requirements for this craft interaction. |
| `0x438` | **0x1A** | You do not have all the neccessary items. |
| `0x439` | **0x1A** | Not all the items are avaliable. |
| `0x43A` | **0x1A** | You must be at rest in peace mode to do trade skills. |
| `0x43B` | **0x1A** | You are not trained in that trade skill. |
| `0x43C` | **0x1A** | Your hands must be free. |
| `0x43D` | 0x07 | You cannot link to that portal!\n |
| `0x43E` | 0x00 | You have solved this quest too recently! |
| `0x43F` | 0x00 | You have solved this quest too many times! |
| `0x445` | 0x00 | This item requires you to complete a specific quest before you can pick it up! |
| `0x45C` | 0x07 | Player killers may not interact with that portal! |
| `0x45D` | 0x07 | Non-player killers may not interact with that portal! |
| `0x45E` | **0x1A** | You do not own a house! |
| `0x45F` | **0x1A** | You do not own a house! |
| `0x466` | 0x07 | You must purchase Asheron's Call -- Dark Majesty to use this function. [AMBIG 6] |
| `0x469` | 0x00 | You have used all the hooks you are allowed to use for this house. |
| `0x46A` | 0x00 | doesn't know what to do with th… |
| `0x474` | 0x07 | You must complete a quest to interact with that portal. |
| `0x47F` | **0x1A** | You must own a house to use this command. |
| `0x480` | **0x1A** | Your monarch does not own a mansion or a villa! |
| `0x481` | **0x1A** | Your monarch does not own a mansion or a villa! |
| `0x482` | **0x1A** | Your monarch has closed the mansion to the Allegiance. |
| `0x488` | 0x00 | You must be above level %s to purchase this dwelling. |
| `0x489` | 0x00 | You must be at or below level %s to purchase this dwelling. |
| `0x48B` | 0x00 | You must be above allegiance rank %s to purchase this dwelling. |
| `0x48C` | 0x00 | You must be at or below allegiance rank %s to purchase this dwelling. |
| `0x48E` | **0x1A** | Your offer of Allegiance has been ignored. |
| `0x48F` | **0x1A** | You are already involved in something! |
| `0x490` | **0x1A** | You must be a monarch to use this command. |
| `0x491` | **0x1A** | You must specify a character to boot. [AMBIG 2] |
| `0x492` | **0x1A** | You can't boot yourself! |
| `0x493` | **0x1A** | That character does not exist. |
| `0x494` | **0x1A** | That person is not a member of your Allegiance! |
| `0x495` | **0x1A** | No patron from which to break! |
| `0x496` | 0x00 | Your Allegiance has been dissolved! |
| `0x497` | 0x00 | Your patron's Allegiance to you has been broken! |
| `0x498` | **0x1A** | You have moved too far! |
| `0x499` | **0x1A** | That is not a valid destination! |
| `0x49A` | **0x1A** | You must purchase Asheron's Call -- Dark Majesty to use this function. [AMBIG 6] |
| `0x49B` | 0x07 | You fail to link with the lifestone! |
| `0x49C` | 0x07 | You wandered too far to link with the lifestone! |
| `0x49D` | 0x07 | You successfully link with the lifestone! |
| `0x49E` | 0x07 | You must have linked with a lifestone in order to recall to it! |
| `0x49F` | 0x07 | You fail to recall to the lifestone! |
| `0x4A0` | 0x07 | You fail to link with the portal! |
| `0x4A1` | 0x07 | You successfully link with the portal! |
| `0x4A2` | 0x07 | You fail to recall to the portal! |
| `0x4A3` | 0x07 | You must have linked with a portal in order to summon it! [AMBIG 2] |
| `0x4A4` | 0x07 | You fail to summon the portal!\n |
| `0x4A5` | 0x07 | You must have linked with a portal in order to summon it! [AMBIG 2] |
| `0x4A6` | 0x07 | You fail to teleport!\n |
| `0x4A7` | 0x07 | You have been teleported too recently! |
| `0x4A8` | 0x07 | You must be an Advocate to interact with that portal. |
| `0x4AA` | 0x07 | Players may not interact with that portal. |
| `0x4AB` | 0x07 | You are not powerful enough to interact with that portal! |
| `0x4AC` | 0x07 | You are too powerful to interact with that portal! |
| `0x4AD` | 0x07 | You cannot recall to that portal! |
| `0x4AE` | 0x07 | You cannot summon that portal!\n |
| `0x4AF` | **0x1A** | The lock is already unlocked. |
| `0x4B0` | **0x1A** | You can't lock or unlock that! |
| `0x4B1` | **0x1A** | You can't lock or unlock what is open! |
| `0x4B2` | 0x00 | The key doesn't fit this lock.\n |
| `0x4B3` | **0x1A** | The lock has been used too recently. |
| `0x4B4` | **0x1A** | You aren't trained in lockpicking! |
| `0x4B5` | **0x1A** | You must specify a character to boot. [AMBIG 2] |
| `0x4B6` | **0x1A** | Please use the allegiance panel to view your own information. |
| `0x4B7` | **0x1A** | You have used that command too recently. |
| `0x4B8` | 0x00 | You do not own that salvage tool! |
| `0x4B9` | 0x00 | You do not own that salvage tool! |
| `0x4BA` | 0x00 | You do not own that salvage tool! |
| `0x4BD` | 0x00 | You do not own that salvage tool! |
| `0x4BE` | 0x00 | You do not own that item!\n |
| `0x4BF` | **0x1A** | The %s was not suitable for salvaging. |
| `0x4C0` | **0x1A** | The %s contains the wrong material. |
| `0x4C1` | 0x00 | The material cannot be created.\n |
| `0x4C2` | 0x00 | The list of items you are attempting to salvage is invalid. |
| `0x4C3` | 0x00 | You cannot salvage items that you are trading! |
| `0x4C4` | 0x07 | You must be a guest in this house to interact with that portal. |
| `0x4C5` | **0x1A** | Your Allegiance Rank is too low to use that item's magic. |
| `0x4C6` | **0x1A** | You must be %s to use that item's magic. |
| `0x4C7` | **0x1A** | Your Arcane Lore skill is too low to use that item's magic. |
| `0x4C8` | **0x1A** | That item doesn't have enough Mana. |
| `0x4C9` | **0x1A** | Your %s is too low to use that item's magic. |
| `0x4CA` | **0x1A** | Only %s may use that item's magic. |
| `0x4CB` | **0x1A** | You must have %s specialized to use that item's magic. |
| `0x4CC` | 0x07 | You have been involved in a player killer battle too recently to do that! |
| `0x4CE` | 0x00 | is too busy to accept gifts right now. |
| `0x4CF` | 0x00 | cannot accept stacked objects. … |
| `0x4D0` | 0x00 | You have failed to alter your skill. |
| `0x4D1` | 0x00 | Your %s skill must be trained, not untrained or specialized, in order to be altered in this way! |
| `0x4D2` | 0x00 | You do not have enough skill credits to specialize your %s skill. |
| `0x4D3` | 0x00 | You have too many available experience points to be able to absorb the experience points from your %s skill. Please spend some of your experience points and try again. |
| `0x4D4` | 0x00 | Your %s skill is already untrained! |
| `0x4D5` | 0x00 | You are currently wielding items which require a certain level of %s. Your %s skill cannot be lowered while you are wielding these items. Please remove these items and try again. [AMBIG 2] |
| `0x4D6` | 0x00 | You have succeeded in specializing your %s skill! |
| `0x4D7` | 0x00 | You have succeeded in lowering your %s skill from specialized to trained! |
| `0x4D8` | 0x00 | You have succeeded in untraining your %s skill! |
| `0x4D9` | 0x00 | Although you cannot untrain your %s skill, you have succeeded in recovering all the experience you had invested in it. |
| `0x4DA` | 0x00 | You have too many credits invested in specialized skills already! Before you can specialize your %s skill, you will need to unspecialize some other skill. |
| `0x4DD` | 0x00 | You have failed to alter your attributes. |
| `0x4DE` | 0x00 | *(no literal — uses a shared string global; see §4.1)* |
| `0x4DF` | 0x00 | *(no literal — uses a shared string global; see §4.1)* |
| `0x4E0` | 0x00 | You are currently wielding items which require a certain level of %s. Your %s skill cannot be lowered while you are wielding these items. Please remove these items and try again. [AMBIG 2] |
| `0x4E1` | 0x00 | You have succeeded in transferring your attributes! |
| `0x4E2` | 0x00 | This hook is a duplicated housing object. You may not add items to a duplicated housing object. Please empty the hook and allow it to reset. |
| `0x4E3` | 0x00 | That item is of the wrong type to be placed on this hook. |
| `0x4E4` | 0x00 | This chest is a duplicated housing object. You may not add items to a duplicated housing object. Please empty everything -- including backpacks -- out of the chest and allow the chest to reset. |
| `0x4E5` | 0x00 | This hook was a duplicated housing object. Since it is now empty, it will be deleted momentarily. Once it is gone, it is safe to use the other, non-duplicated hook that is here. |
| `0x4E6` | 0x00 | This chest was a duplicated housing object. Since it is now empty, it will be deleted momentarily. Once it is gone, it is safe to use the other, non-duplicated chest that is here. |
| `0x4E7` | 0x00 | You cannot swear allegiance to anyone because you own a monarch-only house. Please abandon your house and try again. |
| `0x4E9` | 0x00 | The %s cannot be used while on a hook and only the owner may open the hook. [AMBIG 2] |
| `0x4EA` | 0x00 | The %s can only be used while on a hook. |
| `0x4EB` | **0x1A** | You can't do that while in the air! |
| `0x4EC` | 0x00 | You cannot modify your player killer status while you are recovering from a PK death. |
| `0x4ED` | 0x00 | Advocates may not change their player killer status! |
| `0x4EE` | 0x00 | Your level is too low to change your player killer status with this object. |
| `0x4EF` | 0x00 | Your level is too high to change your player killer status with this object. |
| `0x4F0` | 0x00 | You feel a harsh dissonance, and you sense that an act of killing you have committed recently is interfering with the conversion. |
| `0x4F1` | 0x00 | Bael'Zharon's power flows through you again. You are once more a player killer. |
| `0x4F2` | 0x00 | Bael'Zharon has granted you respite after your moment of weakness. You are temporarily no longer a player killer. |
| `0x4F3` | 0x07 | Lite Player Killers may not interact with that portal! |
| `0x4F4` | 0x07 | %s fails to affect you because $… |
| `0x4F5` | 0x07 | %s fails to affect you because y… |
| `0x4F6` | 0x07 | %s fails to affect you because %… |
| `0x4F7` | 0x07 | fails to affect you because you… |
| `0x4F8` | 0x07 | fails to affect you because you… |
| `0x4F9` | 0x07 | fails to affect you across a ho… |
| `0x4FA` | 0x07 | is an invalid target.\n |
| `0x4FB` | 0x07 | You are an invalid target for the spell of %s. |
| `0x4FC` | **0x1A** | You aren't trained in healing! |
| `0x4FD` | **0x1A** | You don't own that healing kit! |
| `0x4FE` | **0x1A** | You can't heal that! |
| `0x4FF` | **0x1A** | is already at full health! |
| `0x500` | **0x1A** | You aren't ready to heal! |
| `0x501` | **0x1A** | You can only use Healing Kits on player characters. |
| `0x502` | 0x07 | The Lifestone's magic protects you from the attack! |
| `0x503` | 0x07 | The portal's residual energy protects you from the attack! |
| `0x504` | 0x00 | You are enveloped in a feeling of warmth as you are brought back into the protection of the Light. You are once again a Non-Player Killer. |
| `0x505` | **0x1A** | You're too close to your sanctuary! |
| `0x506` | **0x1A** | You can't do that -- you're trading! |
| `0x507` | 0x00 | Only Non-Player Killers may enter PK Lite. Please see @help pklite for more details about this command. |
| `0x508` | 0x00 | A cold wind touches your heart. You are now a Player Killer Lite. |
| `0x509` | 0x07 | has no appropriate targets equi… |
| `0x50A` | 0x07 | You have no appropriate targets equipped for %s's spell. |
| `0x50B` | 0x00 | is now an open fellowship; anyo… |
| `0x50C` | 0x00 | is now a closed fellowship.\n |
| `0x50D` | 0x00 | is now the leader of this fello… |
| `0x50E` | 0x00 | You have passed leadership of the fellowship to %s |
| `0x50F` | **0x1A** | You do not belong to a Fellowship. |
| `0x510` | 0x00 | You may not hook any more %s on your house. You already have the maximum number of %s hooked or you are not permitted to hook any on your type of house. |
| `0x512` | 0x00 | You are now using the maximum number of hooks. You cannot use another hook until you take an item off one of your hooks. |
| `0x513` | 0x00 | You are no longer using the maximum number of hooks. You may again add items to your hooks. |
| `0x514` | 0x00 | You now have the maximum number of %s hooked. You cannot hook any additional %s until you remove one or more from your house. |
| `0x515` | 0x00 | You no longer have the maximum number of %s hooked. You may hook additional %s. |
| `0x516` | 0x00 | You are not permitted to use that hook. |
| `0x517` | 0x00 | is not close enough to your lev… |
| `0x518` | 0x00 | cannot be recruited into the fe… |
| `0x519` | 0x00 | The fellowship is locked, you were not added to the fellowship. |
| `0x51A` | **0x1A** | Only the original owner may use that item's magic. |
| `0x51B` | 0x00 | You have entered the %s channel. |
| `0x51C` | 0x00 | You have left the %s channel.\n |
| `0x51E` | 0x00 | will not receive your message, please use urgent assistance to speak with an in-game representative |
| `0x51F` | **0x1A** | Message Blocked: %s |
| `0x520` | 0x00 | You cannot add anymore people to the list of players that you can hear. |
| `0x521` | 0x00 | has been added to the list of p… |
| `0x522` | 0x00 | has been removed from the list … |
| `0x523` | 0x00 | You are now deaf to player's screams. |
| `0x524` | 0x00 | You can hear all players once again. |
| `0x525` | 0x00 | You fail to remove %s from your loud list. |
| `0x526` | **0x1A** | You chicken out. |
| `0x527` | **0x1A** | You cannot posssibly succeed. |
| `0x528` | 0x00 | The fellowship is locked; you cannot open locked fellowships. |
| `0x529` | **0x1A** | Trade Complete! |
| `0x52A` | **0x1A** | That is not a salvaging tool. |
| `0x52B` | **0x1A** | That person is not available now. |
| `0x52C` | 0x00 | You are now snooping on %s.\n |
| `0x52D` | 0x00 | You are no longer snooping on %s. |
| `0x52E` | 0x00 | You fail to snoop on %s.\n |
| `0x52F` | 0x00 | %s attempted to snoop on you.\n |
| `0x530` | 0x00 | %s is already being snooped on, … |
| `0x531` | 0x00 | %s is in limbo and cannot receive your message. |
| `0x532` | 0x00 | You must wait 30 days after purchasing a house before you may purchase another with any character on the same account. This applies to all housing except apartments. |
| `0x533` | 0x00 | You have been booted from your allegiance chat room. Use "@allegiance chat on" to rejoin. (%s). |
| `0x534` | 0x00 | %s has been booted from the alle… |
| `0x535` | 0x00 | You do not have the authority within your allegiance to do that. |
| `0x536` | 0x00 | The account of %s is already banned from the allegiance. |
| `0x537` | 0x00 | The account of %s is not banned from the allegiance. |
| `0x538` | 0x00 | The account of %s was not unbanned from the allegiance. |
| `0x539` | 0x00 | The account of %s has been banned from the allegiance. |
| `0x53A` | 0x00 | The account of %s is no longer banned from the allegiance. |
| `0x53B` | 0x00 | Banned Characters: |
| `0x53E` | 0x00 | %s is banned from the allegiance… |
| `0x53F` | 0x00 | You are banned from %s's allegiance! |
| `0x540` | 0x00 | You have the maximum number of accounts banned.! |
| `0x541` | 0x00 | %s is now an allegiance officer.… |
| `0x542` | 0x00 | An unspecified error occurred while attempting to set %s as an allegiance officer. [AMBIG 2] |
| `0x543` | 0x00 | %s is no longer an allegiance of… |
| `0x544` | 0x00 | An unspecified error occurred while attempting to set %s as an allegiance officer. [AMBIG 2] |
| `0x545` | 0x00 | You already have the maximum number of allegiance officers. You must remove some before you add any more. |
| `0x546` | 0x00 | Your allegiance officers have been cleared. |
| `0x547` | 0x00 | You must wait %s before communicating again! |
| `0x548` | 0x00 | You cannot join any chat channels while gagged. |
| `0x549` | 0x00 | Your allegiance officer status has been modified. You now hold the position of: %s. |
| `0x54A` | 0x00 | You are no longer an allegiance officer. |
| `0x54B` | 0x00 | %s is already an allegiance offi… |
| `0x54C` | 0x00 | Your allegiance does not have a hometown. |
| `0x54D` | **0x1A** | The %s is currently in use.\n |
| `0x54E` | 0x00 | The hook does not contain a usable item. Use the '@house hooks on'command to make the hook openable. [AMBIG 2] |
| `0x54F` | 0x00 | The hook does not contain a usable item. Use the '@house hooks on'command to make the hook openable. [AMBIG 2] |
| `0x550` | **0x1A** | Out of Range! |
| `0x551` | 0x00 | You are not listening to the %s channel! |
| `0x552` | **0x1A** | You must purchase Asheron's Call -- Dark Majesty to use this function. [AMBIG 6] |
| `0x553` | **0x1A** | You must purchase Asheron's Call -- Dark Majesty to use this function. [AMBIG 6] |
| `0x554` | **0x1A** | You must purchase Asheron's Call -- Dark Majesty to use this function. [AMBIG 6] |
| `0x555` | **0x1A** | You must purchase Asheron's Call -- Dark Majesty to use this function. [AMBIG 6] |
| `0x556` | 0x00 | You have failed to complete the augmentation. |
| `0x557` | 0x00 | You have used this augmentation too many times already. |
| `0x558` | 0x00 | You have used augmentations of this type too many times already. |
| `0x559` | 0x00 | You do not have enough unspent experience available to purchase this augmentation. |
| `0x55A` | 0x00 | *(no literal — uses a shared string global; see §4.1)* |
| `0x55B` | 0x00 | Congratulations! You have succeeded in acquiring the %s augmentation. |
| `0x55C` | 0x00 | Although your augmentation will not allow you to untrain your %s skill, you have succeeded in recovering all the experience you had invested in it. |
| `0x55D` | 0x00 | You must exit the Training Academy before that command will be available to you. |
| `0x55E` | 0x00 | *(no literal — uses a shared string global; see §4.1)* |
| `0x55F` | 0x00 | Only Player Killer characters may use this command! |
| `0x560` | 0x00 | Only Player Killer Lite characters may use this command! |
| `0x561` | **0x1A** | You may only have a maximum of 50 friends at once. If you wish to add more friends, you must first remove some. |
| `0x562` | 0x00 | %s is already on your friends li… |
| `0x563` | 0x00 | That character is not on your friends list! |
| `0x564` | 0x00 | Only the character who owns the house may use this command. |
| `0x565` | 0x00 | That allegiance name is invalid because it is empty. Please use the @allegiance name clear command to clear your allegiance name. |
| `0x566` | 0x00 | That allegiance name is too long. Please choose another name. |
| `0x567` | 0x00 | That allegiance name contains illegal characters. Please choose another name using only letters, spaces, - and '. |
| `0x568` | 0x00 | That allegiance name is not appropriate. Please choose another name. |
| `0x569` | 0x00 | That allegiance name is already in use. Please choose another name. |
| `0x56A` | 0x00 | You may only change your allegiance name once every 24 hours. You may change your allegiance name again in %s. |
| `0x56B` | 0x00 | Your allegiance name has been cleared. |
| `0x56C` | 0x00 | That is already the name of your allegiance! |
| `0x56D` | 0x00 | %s is the monarch and cannot be … |
| `0x56E` | 0x00 | That level of allegiance officer is now known as: %s. |
| `0x56F` | 0x00 | That is an invalid officer level. |
| `0x570` | 0x00 | That allegiance officer title is not appropriate. |
| `0x571` | 0x00 | That allegiance name is too long. Please choose another name. |
| `0x572` | 0x00 | All of your allegiance officer titles have been cleared. |
| `0x573` | 0x00 | That allegiance title contains illegal characters. Please choose another name using only letters, spaces, - and '. |
| `0x574` | 0x00 | Your allegiance is currently: %s. |
| `0x575` | 0x00 | Your allegiance is now: %s.\n |
| `0x576` | 0x00 | You may not accept the offer of allegiance from %s because your allegiance is locked. |
| `0x577` | 0x00 | You may not swear allegiance at this time because the allegiance of %s is locked. |
| `0x578` | 0x00 | You have pre-approved %s to join your allegiance. |
| `0x579` | 0x00 | You have not pre-approved any vassals to join your allegiance. |
| `0x57A` | 0x00 | %s is already a member of your a… |
| `0x57B` | 0x00 | %s has been pre-approved to join… |
| `0x57C` | 0x00 | You have cleared the pre-approved vassal for your allegiance. |
| `0x57D` | 0x00 | That character is already gagged! |
| `0x57E` | 0x00 | That character is not currently gagged! |
| `0x57F` | 0x00 | Your allegiance chat privileges have been restored. [AMBIG 3] |
| `0x580` | 0x00 | %s is now temporarily unable to … |
| `0x581` | 0x00 | Your allegiance chat privileges have been restored. [AMBIG 3] |
| `0x582` | 0x00 | Your allegiance chat privileges have been restored. [AMBIG 3] |
| `0x583` | 0x00 | You have restored allegiance chat privileges to %s. |
| `0x584` | **0x1A** | You cannot pick up more of that item! |
| `0x585` | **0x1A** | You are restricted to clothes and armor created for your race. |
| `0x586` | **0x1A** | That item was specifically created for another race. |
| `0x587` | 0x07 | Olthoi cannot interact with that! |
| `0x588` | 0x07 | Olthoi cannot use regular lifestones! Asheron would not allow it! |
| `0x589` | 0x07 | The vendor looks at you in horror! |
| `0x58A` | 0x00 | %s cowers from you!\n |
| `0x58B` | 0x07 | As a mindless engine of destruction an Olthoi cannot join a fellowship! |
| `0x58C` | 0x07 | The Olthoi only have an allegiance to the Olthoi Queen! |
| `0x58D` | 0x07 | You cannot use that item!\n |
| `0x58E` | 0x07 | This person will not interact with you! |
| `0x58F` | 0x07 | Only Olthoi may pass through this portal! |
| `0x590` | 0x07 | Olthoi may not pass through this portal! |
| `0x591` | 0x07 | You may not pass through this portal while Vitae weakens you! |
| `0x592` | 0x07 | This character must be two weeks old or have been created on an account at least two weeks old to use this portal! |
| `0x593` | 0x07 | Olthoi characters can only use Lifestone and PK Arena recalls! |
---
## Appendix B — text types seen in this build
| Type | ACE `ChatMessageType` | Notes |
|---|---|---|
| `0x00` | `Broadcast` | default colour (green) |
| `0x01` | `AllChannels` | |
| `0x02` | `Speech` | white |
| `0x03` | `Tell` | yellow |
| `0x04` | `OutgoingTell` | |
| `0x05` | `System` | bright purple |
| `0x06` | `Combat` | dark red |
| `0x07` | `Magic` | light blue — the spell/portal failure family |
| `0x08` `0x09` | `Channel` / `ChannelSend` | pink |
| `0x0A` `0x0B` | `Social` / `SocialSend` | |
| `0x0C` | `Emote` | grey |
| `0x0D` | `Advancement` | cyan |
| `0x0E` | `Abuse` | |
| `0x0F` | `Help` | dark red |
| `0x10` | `Appraisal` | |
| `0x11` | `Spellcasting` | light blue |
| `0x12` | `Allegiance` | orange |
| `0x13` | `Fellowship` | yellow |
| `0x14` | `WorldBroadcast` | |
| `0x15` `0x16` | `CombatEnemy` / `CombatSelf` | |
| `0x17` | `Recall` | |
| `0x18` `0x19` | `Craft` / `Salvaging` | |
| **`0x1A`** | *commented out in ACE* | **SpewBox** |
| `0x1B``0x1E` | unnamed in ACE | blue-grey |
| `0x1F` | `AdminTell` | yellow |
| `0x20` `0x21` | **absent from ACE** | coloured by the client (blue-grey / orange) |

View file

@ -0,0 +1,778 @@
# Chat side channels vs current ACE — R4 research lane
**Campaign:** CH (chat & interface-text retail parity),
`docs/plans/2026-08-09-chat-parity-campaign.md` lane R4.
**Date:** 2026-08-09. **Mode:** research only, no code changed.
**Sources:** current vendored ACE at `references/ACE/`, named retail decomp at
`docs/research/named-retail/`, holtburger + Chorizite.ACProtocol cross-checks,
acdream `src/` as of `2914e43a`.
---
## 0. Verdict summary
| Family | Verdict |
|---|---|
| **The 26-day-old claim** ("ACE doesn't run a TurbineChat server") | **FALSE.** ACE has a complete TurbineChat implementation, on by default, and we have live logs proving it reaches us. |
| **Turbine General / Trade / LFG** | Wire-correct end to end. Should already work. If they don't, the cause is a live-state issue, not a codec issue — see the probe in §7.1. |
| **Turbine Roleplay** | **Broken, root cause found.** ACE never joins the player to Roleplay because `HearRoleplayChat` is not in ACE's `CharacterOptions2.Default`, ACE then filters the sender out of its own broadcast, and acdream neither gates the send nor echoes locally → total silence. |
| **Turbine Society / Olthoi** | Correctly unavailable (no society, not an Olthoi player). Retail shows a specific refusal; we show nothing. |
| **Turbine Allegiance (`/a`)** | Wrong transport when the player has no allegiance: acdream silently downgrades to the legacy `AllegianceBroadcast` bitflag. Retail never does this. |
| **Legacy `/f /v /p /m /cv`** | Wire-correct outbound and inbound. Two presentation defects: every line **double-prints**, and the "not in a fellowship / not in an allegiance" refusals do reach us (they are wired) but the send still looks like it worked. |
| **Client-side Hear\* settings** | The Settings panel's six "Hear … Chat" toggles are **dead** — never applied to display, never sent to the server. |
| **`SetCharacterOptions` (0x01A1)** | acdream's builder is **malformed** — latent, currently unreachable from the UI, but it would corrupt server-side options if ever wired up. |
| **`AddChannel` / `RemoveChannel` (0x0145/0x0146)** | acdream's builders send the wrong payload type (string vs u32 bitfield). Unused today; admin-only on ACE. |
**One-line root cause for "side channels don't work":** we treat the
`Hear<Channel>Chat` character options as a local display preference. Retail and
ACE treat them as **channel membership**. ACE filters the sender out of its own
broadcast when the sender's option is off, and retail refuses the send outright
with a named error. We do neither, so the message vanishes with no feedback.
---
## 1. The refuted claim
`docs/ISSUES.md:15214` and `docs/plans/2026-04-11-roadmap.md:429,948` both carry:
> **Note: ACE doesn't run a TurbineChat server — codec is ready for
> retail-server-emulating setups.**
This is wrong, and has been wrong the whole time. Evidence, strongest first:
1. **ACE source.** `references/ACE/Source/ACE.Server/Network/Handlers/TurbineChatHandler.cs`
is a full 387-line inbound handler registered
`[GameMessage(GameMessageOpcode.TurbineChat, SessionState.WorldConnected)]`.
`GameMessageTurbineChat.cs` is the outbound writer.
`TurbineChatChannel.cs` holds the room-id constants.
2. **It is on by default.** `PropertyManager.cs:608`
`("use_turbine_chat", new Property<bool>(true, …))`.
3. **We have already received it, repeatedly.** Our own launch logs contain the
parsed 0x0295 payload with live room ids, e.g.
`docs/research/2026-08-07-339-portal-space-hang.log:55` and eleven
2026-05-21/2026-05-23 capture logs:
```
chat: SetTurbineChatChannels parsed enabled=True general=0x00000002
trade=0x00000003 lfg=0x00000004 roleplay=0x00000005 society=0x00000000
olthoi=0x0000000A allegiance=0x00000000
```
Those are exactly ACE's `TurbineChatChannel` constants. `TurbineChatState.Enabled`
has been `true` against local ACE since at least 2026-05-21.
**Action:** both the ISSUES entry and the two roadmap rows must be corrected in
the CH3 commit.
---
## 2. acdream inventory (current truth)
### 2.1 Codec — `src/AcDream.Core.Net/Messages/TurbineChat.cs`
Builds and parses the 0xF7DE message. Header is opcode + 9 u32 (36 bytes):
| Off | Field | Build value (outbound request) |
|---|---|---|
| 0 | `u32 opcode` | `0xF7DE` |
| 4 | `u32 sizeFirst` | `40 + payloadLen` |
| 8 | `u32 blobType` | `3` RequestBinary |
| 12 | `u32 dispatchType` | `2` SendToRoomById |
| 16 | `u32 targetType` | `1` |
| 20 | `u32 targetId` | `0` |
| 24 | `u32 transportType` | `0` |
| 28 | `u32 transportId` | `0` |
| 32 | `u32 cookie` | `0` |
| 36 | `u32 sizeSecond` | `8 + payloadLen` |
Request payload (`WritePayload`, `TurbineChat.cs:371-381`):
`u32 contextId`, `u32 2`, `u32 2`, `u32 roomId`, turbine-string message,
`u32 0x0C`, `u32 senderId`, `u32 0` hresult, `u32 chatType`.
Turbine string codec (`ReadTurbineString`/`WriteTurbineString`,
`TurbineChat.cs:406-463`): 1-or-2-byte packed length in **UTF-16 code units**,
then UTF-16LE bytes, no padding. This is the only acdream string type that is
not CP1252 String16L.
Parse handles three shapes: `(EventBinary, SendToRoomByName)`,
`(RequestBinary, SendToRoomById)` with a hard reject unless the inner
response/method ids are both `2`, and `(ResponseBinary, *)`. Unknown
blob/dispatch pairs are captured verbatim rather than rejected.
### 2.2 Room table — `src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs`
Parses the 40-byte 0x0295 GameEvent payload as 10 u32 in order:
allegiance, general, trade, lfg, roleplay, olthoi, society, societyCelHan,
societyEldWeb, societyRadBlo. Registered in the `WorldSession` ctor
(`WorldSession.cs:837`) **and** again in `GameEventWiring.cs:184-213`; the
latter is the one that actually feeds `TurbineChatState.OnChannelsReceived`
and prints the diagnostic line quoted in §1. `WorldSession.TurbineChannelsReceived`
has **no production subscriber** — dead event surface, harmless.
### 2.3 State — `src/AcDream.Core/Chat/TurbineChatState.cs`
Holds `Enabled` + the ten room ids + a per-session context cookie starting at 1
and wrapping to 1. `Reset()` clears everything on session replace. `RoomFor()`
maps a `ChatChannelKindLite` to a room id. **Nothing in this class knows about
the Hear\* options.**
### 2.4 Channel classification — `src/AcDream.Core/Chat/ChatChannelInfo.cs`
`Legacy(channelId)` vs `Turbine(roomId, chatType, dispatchType)`, plus
`IsSelfEchoChannel()`: true for legacy Fellow/Vassals/Patron/Monarch/CoVassals,
false for Turbine. **This type is never consulted by any production send path.**
It exists only for its own unit tests (`ChatChannelInfoTests`). The
`ChannelResolver` referenced in the brief lives at
`src/AcDream.UI.Abstractions/ChannelResolver.cs`, not under `Core/Chat/`.
### 2.5 Legacy resolver — `src/AcDream.UI.Abstractions/ChannelResolver.cs`
| Kind | Id | ACE `Channel` | Match |
|---|---|---|---|
| Fellowship | `0x00000800` | `Fellow` | ✔ |
| Allegiance | `0x02000000` | `AllegianceBroadcast` | ✔ value, ✘ semantics (§5.3) |
| Vassals | `0x00001000` | `Vassals` | ✔ |
| Patron | `0x00002000` | `Patron` | ✔ |
| Monarch | `0x00004000` | `Monarch` | ✔ |
| CoVassals | `0x01000000` | `CoVassals` | ✔ |
### 2.6 Outbound send paths
**Graphical**, `src/AcDream.App/Net/LiveSessionCommandRouter.cs:247-286`:
```
SendChatCmd
├ Say → SendTalk (no local echo — waits for the authoritative line)
├ Tell → SendTell + Chat.OnSelfSent(Tell)
├ TurbineChatRouting.Resolve(kind, TurbineChat) → non-null?
│ → SendTurbineChat(room, chatType, SendToRoomById, playerGuid, text, cookie)
│ (NO OnSelfSent)
├ ChannelResolver.Resolve(kind) → non-null?
│ → SendChannel(legacyId, text) + Chat.OnSelfSent(Channel, displayName)
└ else → log "dropped" and return
```
`TurbineChatRouting.Resolve` (same file, ~line 388) gates only on
`state.Enabled` and `Room != 0`.
**Headless**, `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:949-990`
— same order, same gate, no echo either way.
**Wire builders:**
- `WorldSession.SendTurbineChatTo` (`WorldSession.cs:2602-2638`) — hard-codes
`targetType=1`, everything else 0, `extraDataSize=0x0C`, outer `cookie=0`,
inner `contextId` = the caller's cookie. Sends through `SendGameAction`
`SendGameMessage``GameMessageGroup.UIQueue`.
- `WorldSession.SendChannel` (`WorldSession.cs:2090-2096`) →
`ChatRequests.BuildChatChannel` (`ChatRequests.cs:89-100`):
`[0xF7B1][seq][0x0147][u32 channelId][String16L message]`, CP1252,
4-byte aligned.
### 2.7 Inbound handling
- **0xF7DE**`WorldSession.cs:1855-1866` parses and raises `TurbineChatReceived`;
`LiveSessionEventRouter.cs:280-283` subscribes;
`RouteTurbineChat` (`LiveSessionEventRouter.cs:479-489`) forwards **only**
`EventSendToRoom` into `ChatLog.OnChannelBroadcast(roomId, sender, text, displayName)`.
`Response` (the ack) and `Unknown` are **silently discarded** — no logging, no
hResult inspection.
- **0x0147 ChannelBroadcast**`GameEventWiring.cs:102-106`
`GameEvents.ParseChannelBroadcast` (`u32 channelId`, `String16L sender`,
`String16L message`) → `ChatLog.OnChannelBroadcast`.
- **0x028A / 0x028B WeenieError(WithString)** — wired at
`GameEventWiring.cs:220-229` into `ChatLog.OnWeenieError`, and
`WeenieErrorMessages` has the relevant strings: `0x051D` "Turbine Chat is
enabled.", `0x051B/0x051C` channel enter/leave, `0x0414` not in allegiance,
`0x050F` not in a Fellowship.
- **Rendering**`ChatVM.FormatEntry` renders `ChatKind.Channel` as
`[Name] Sender says, "text"`, or `[Name] You say, "text"` when the sender is
empty or "You". `ChatWindowController.RetailChatColor` gives Channel
`colorLightBlue`. No filtering by channel anywhere.
### 2.8 Command parsing
`ChatCommandRouter.Submit` → retail client-command catalog → local `/help`
degenerate-prefix guard → unknown-verb `@`-passthrough → `ChatInputParser.Parse`.
`ChatInputParser.ChannelVerbs` (`ChatInputParser.cs:55-82`) covers
`/g /general /gen /f /fellow /fellowship /a /allegiance /m /monarch /p /patron
/v /vassals /cv /covassals /lfg /lookingforgroup /trade /tr /role /rp /roleplay
/society /olthoi`. No collisions with `RetailClientCommandCatalog`. **The verb
layer is fine.**
---
## 3. The ACE contract
### 3.1 TurbineChat inbound — `TurbineChatHandler.cs:20-60`
Registered `[GameMessage(GameMessageOpcode.TurbineChat, SessionState.WorldConnected)]`.
Reads, in order (all little-endian u32 unless noted):
| # | Field | ACE comment / use |
|---|---|---|
| 1 | size | "Bytes to follow" — read and discarded |
| 2 | `chatBlobType` | must be `3` NETBLOB_REQUEST_BINARY, else "Unhandled" console line |
| 3 | `chatBlobDispatchType` | `1` byName / `2` byId — only affects channel re-derivation |
| 4 | — | "Always 1" |
| 58 | — | "Always 0" ×4 |
| 9 | size | "Bytes to follow" — read and discarded |
| — | *gag check* | `session.Player.IsGagged``SendGagError()`, return |
| 10 | `contextId` | echoed in the ack |
| 11 | — | "Always 2" |
| 12 | — | "Always 2" |
| 13 | `channelID` | room id |
| 14 | message | packed-byte length (`&0x80` ⇒ 2-byte), then `len*2` bytes, `Encoding.Unicode` |
| 15 | — | "Always 0x0C" |
| 16 | `senderID` | client-supplied, not validated |
| 17 | — | "Always 0" |
| 18 | `chatType` | `ChatType` enum |
**acdream's outbound matches this field-for-field.** Sizes match too: ACE
backpatches `firstSize = end - firstSizePos + 4` = `40 + payloadLen` and
`secondSize = end - secondSizePos + 4` = `8 + payloadLen`, identical to
`TurbineChat.Build` lines 332-333.
### 3.2 TurbineChat outbound — `GameMessageTurbineChat.cs:79-136`
Sent on `GameMessageGroup.LoginQueue`.
**`NETBLOB_EVENT_BINARY` (the broadcast):** header is
`size, 1, 1, 1, 0x000B00B5, 1, 0x000B00B5, 0, size`; payload is
`u32 channel`, packed-byte + UTF-16 `senderName`, packed-byte + UTF-16 `message`,
`u32 0x0C`, `u32 senderID`, `u32 0`, `u32 chatType`. Dispatch type is always
`ASYNCMETHOD_SENDTOROOMBYNAME (1)` even for a byId request.
**acdream's `(EventBinary, SendToRoomByName)` parse matches.**
**`NETBLOB_RESPONSE_BINARY` (the ack):** same header, payload
`u32 contextId, u32 2, u32 2, u32 0`.
**acdream parses it, then throws it away** (`RouteTurbineChat` early-returns).
*ACE bug worth knowing:* the ≥128-char branch of the `senderName` prefix writes
`message.Length`, not `senderName.Length` (`GameMessageTurbineChat.cs:98`).
Unreachable in practice (no AC character name is ≥128 chars).
### 3.3 Room-id table — `TurbineChatChannel.cs`
`Allegiance=1, General=2, Trade=3, LFG=4, Roleplay=5, Society=6,
SocietyCelestialHand=7, SocietyEldrytchWeb=8, SocietyRadiantBlood=9, Olthoi=10`.
`GameEventSetTurbineChatChannels` writes 10 u32:
`allegiance, 2, 3, 4, 5, 10, society, 7, 8, 9`. Only `allegiance` and `society`
are dynamic — `allegiance` is the allegiance's `Biota.Id` (a large uint, **not**
the sentinel `1`), `society` is 7/8/9 or 0.
**Critical:** this table is sent whole every time, regardless of which channels
the player is actually joined to. `roleplay=0x00000005` in our log means "the
Roleplay room's id is 5", **not** "you are in the Roleplay room."
### 3.4 When ACE pushes 0x0295 — `Player_Networking.cs:85-110`
```csharp
if (PropertyManager.GetBool("use_turbine_chat").Item) {
EnqueueSend(new GameEventWeenieError(Session, WeenieError.TurbineChatIsEnabled));
if (IsOlthoiPlayer) JoinTurbineChatChannel("Olthoi");
else {
if (GetCharacterOption(ListenToAllegianceChat) && Allegiance != null) JoinTurbineChatChannel("Allegiance");
if (GetCharacterOption(ListenToGeneralChat)) JoinTurbineChatChannel("General");
if (GetCharacterOption(ListenToTradeChat)) JoinTurbineChatChannel("Trade");
if (GetCharacterOption(ListenToLFGChat)) JoinTurbineChatChannel("LFG");
if (GetCharacterOption(ListenToRoleplayChat)) JoinTurbineChatChannel("Roleplay");
if (GetCharacterOption(ListenToSocietyChat) && Society != FactionBits.None) JoinTurbineChatChannel("Society");
}
}
```
`JoinTurbineChatChannel` sends `WeenieErrorWithString.YouHaveEnteredThe_Channel`
(0x051B) then `SendTurbineChatChannels()`. Also fired from
`GameActionSetSingleCharacterOption` when the client toggles a Hear\* option,
and from allegiance changes.
### 3.5 Character options — the gate that matters
`CharacterOptions2` (`ACE.Entity/Enum/CharacterOptions2.cs`):
| Bit | Name | In `Default`? |
|---|---|---|
| `0x00000100` | HearGeneralChat | **yes** |
| `0x00000200` | HearTradeChat | **yes** |
| `0x00000400` | HearLFGChat | **yes** |
| `0x00000800` | HearRoleplayChat | **NO** |
| `0x00080000` | HearSocietyChat | **NO** |
`Default = 0x00948700`. `CharacterOptions1.Default` includes
`HearAllegianceChat (0x40000000)`; `CharacterOptions1.Default = 0x50C48D4A`.
`PlayerFactory.CharacterCreateSetDefaultCharacterOptions` sets exactly these
two defaults on every new character.
acdream's `RuntimeCharacterOptionsState.DefaultOptions2 = 0x00948700`
(`RuntimeCharacterState.cs:609`) — **byte-identical to ACE.** We already model
the right value; we just don't act on it.
### 3.6 Delivery gates on a room send
Every branch of `TurbineChatHandler` broadcasts with
`foreach (var recipient in PlayerManager.GetAllOnline())` — **the sender is in
that set.** There is no `if (recipient == session.Player) continue`. So the
sender's own copy comes back through the normal event path, subject to the same
per-recipient filter:
```csharp
if (channelID == General && !recipient.GetCharacterOption(ListenToGeneralChat) ||
channelID == Trade && !recipient.GetCharacterOption(ListenToTradeChat) ||
channelID == LFG && !recipient.GetCharacterOption(ListenToLFGChat) ||
channelID == Roleplay && !recipient.GetCharacterOption(ListenToRoleplayChat))
continue;
```
Separately, the sender always gets a `NETBLOB_RESPONSE_BINARY` ack.
**Config gates, with defaults from `PropertyManager.cs`:**
| Property | Default | Effect if tripped |
|---|---|---|
| `use_turbine_chat` | **true** | whole 0xF7DE path is a no-op |
| `chat_disable_general/trade/lfg/roleplay` | false | per-channel kill switch |
| `chat_echo_only` | false | loops back to sender only |
| `chat_requires_account_15days` | **false** | reject |
| `chat_requires_account_time_seconds` | 0 | reject |
| `chat_requires_player_age` | 0 | reject |
| `chat_requires_player_level` | 0 | reject |
| `chat_echo_reject` / `chat_inform_reject` | — | whether a rejected sender sees anything |
| `Player.IsGagged` | false | drop + gag error |
**On a stock local ACE none of these block General/Trade/LFG.** The only live
filter is the per-recipient `CharacterOption`.
### 3.7 Legacy channels — `GameActionChatChannel.cs`
One multiplexed action, `GameActionType.ChatChannel = 0x0147`:
`u32 Channel` then `String16L message`. **acdream's `BuildChatChannel` matches.**
Outbound `GameEventChannelBroadcast` (GameEventType `0x0147`):
`u32 channel`, `String16L senderName`, `String16L messageText`.
**acdream's `ParseChannelBroadcast` matches.**
| Channel | Precondition | Success | Failure |
|---|---|---|---|
| `Fellow 0x800` | has Fellowship | broadcast to members (real name) **+ self-echo with `senderName=""`** | `WeenieError.YouDoNotBelongToAFellowship (0x050F)` |
| `Vassals 0x1000` | allegiance + `TotalVassals != 0` | broadcast + self-echo `""` | `YouAreNotInAllegiance (0x0414)` / `YouCantUseThatChannel (0x0423)` |
| `Patron 0x2000` | allegiance + `PatronId` | send to patron + self-echo `""` | same pair |
| `Monarch 0x4000` | allegiance + `MonarchId` | send to monarch + self-echo `""` | same pair |
| `CoVassals 0x1000000` | allegiance + `PatronId` | patron + siblings + self-echo `""` | same pair |
| `AllegianceBroadcast 0x2000000` | allegiance + permission ≥ Speaker | broadcast to all members, sender included **with real name** (no `""` echo) | `YouAreNotInAllegiance` / `YouDoNotHaveAuthorityInAllegiance (0x0535)` |
| `Help 0x400` | none | placeholder text, **explicitly unfinished** | — |
| unknown | — | `Console.WriteLine`, **silently dropped, nothing sent to client** | — |
*ACE quirk:* the Fellow branch's `else` fires the self-echo once per
non-qualifying member, so a fellowship with N squelchers gives the sender N+1
self-echoes.
`AddChannel (0x0145)` / `RemoveChannel (0x0146)` read a **u32 `Channel`
bitfield**, not a string, and return early unless the player is an
Advocate/admin with the channel in `ChannelsAllowed`.
### 3.8 `SetCharacterOptions (0x01A1)``GameActionSetCharacterOptions.cs`
```
u32 flags (CharacterOptionDataFlag)
i32 characterOptions1 → SetCharacterOptions1() [always read]
[Shortcut block if flags & 0x01]
u32 numTab1Spells + spells [always read]
[MultiSpellList 0x04] [ExtendedMultiSpellLists 0x10] [SpellLists8 0x400]
[DesiredComps 0x08] [SpellbookFilters 0x20]
[CharacterOptions2 0x40] → i32 → SetCharacterOptions2()
[TimestampFormat 0x80] [GenericQualitiesData 0x100] [GameplayOptions 0x200]
```
Also refused entirely before `FirstEnterWorldDone`.
`SetSingleCharacterOption` is `GameActionType = 0x0005`, payload
`u32 option, u32 value`. For the six `ListenTo*` options its handler sets the
option **and** calls `JoinTurbineChatChannel` / `LeaveTurbineChatChannel`. **This
is the only wire message that changes Turbine room membership.**
---
## 4. Retail truth
### 4.1 Rooms are pushed, never requested
`ClientCommunicationSystem::Handle_Communication__Recv_ChatRoomTracker @ 0x0056e4f0`:
```c
gmCCommunicationSystem::SetChatRoomTracker(arg2);
if (tracker) {
// BN renders the field read as GetMouseX(tracker) — heuristic artifact
bool hasAllegRoom = tracker->m_allegianceRoomID != 0;
gmCCommunicationSystem::SetTalkFocusEnabled(7, hasAllegRoom);
if (WantsToBeInAllegChat() && hasAllegRoom) SetTalkFocus(7);
}
```
Pure passive storage plus a UI affordance. No join request is emitted anywhere
in the decomp. The retail struct (`acclient.h:40716`) is 10 × u32 in exactly the
order ACE writes:
```c
struct ChatRoomTracker : PackObj {
unsigned int m_allegianceRoomID, mGeneralChatRoomID, mTradeChatRoomID,
mLFGChatRoomID, mRoleplayChatRoomID, mOlthoiChatRoomID,
mSocietyChatRoomID, mSocietyCelHanChatRoomID,
mSocietyEldWebChatRoomID, mSocietyRadBloChatRoomID;
};
```
### 4.2 What retail sends into a room — and the gate we're missing
Each per-channel entry point reads its own field off the tracker and passes the
player's own Hear\* option as the last argument
(`acclient_2013_pseudo_c.txt:394282-394447`):
```c
DoTurbineChat_General → SendTurbineChat(this, tracker.mGeneralChatRoomID, General_ChatTypeEnum, &text, PlayerModule::HearGeneralChat(...));
DoTurbineChat_Trade → SendTurbineChat(this, tracker.mTradeChatRoomID, Trade_ChatTypeEnum, &text, PlayerModule::HearTradeChat(...));
DoTurbineChat_LFG → SendTurbineChat(this, tracker.mLFGChatRoomID, LFG_ChatTypeEnum, &text, PlayerModule::HearLFGChat(...));
DoTurbineChat_Roleplay → SendTurbineChat(this, tracker.mRoleplayChatRoomID, Roleplay_ChatTypeEnum, &text, PlayerModule::HearRoleplayChat(...));
DoTurbineChat_Olthoi → SendTurbineChat(this, tracker.mOlthoiChatRoomID, Olthoi_ChatTypeEnum, &text, CPlayerSystem::IsOlthoi() != 0);
DoTurbineChat_Allegiance → SendTurbineChat(this, tracker.m_allegianceRoomID, Allegiance_ChatTypeEnum, &text, PlayerModule::HearAllegianceChat(...));
```
`ClientCommunicationSystem::SendTurbineChat @ 0x0057db10` — **this is the
load-bearing function for the whole diagnosis**:
```c
if (!CCommunicationSystem::IsUsingTurbineChat() || roomId <= 0) {
AddTextToScroll("Turbine chat is not available.\n"); // local, nothing sent
return;
}
if (hearOption == 0) { // 0x0057db37
ChannelSystem::GetGlobalChannelName(chatType, &name);
HandleFailureEvent(this, 0x551, &name); // YouAreNotListeningTo_Channel
return 1; // NOTHING IS SENT
}
if (!IsMessageSafe(text)) return;
if (IsMessageSpam()) { AddTextToScroll("You must wait %ds before communi…"); return 0; }
blob = new TurbineChatBlob{ m_targetID = GetPlayerID(), m_ChatType = chatType };
if (CCommunicationSystem::CSendToTurbineRoomByID(roomId, wideText, ...) failed)
AddTextToScroll("Failed to send text to channel: …");
```
Three distinct retail refusals, all locally raised, none of which acdream has:
| Condition | Retail behaviour |
|---|---|
| Turbine off, or room id 0 | prints `Turbine chat is not available.` |
| Player's own Hear\* option off | raises `0x0551 YouAreNotListeningTo_Channel`*"You are not listening to the &lt;X&gt; channel."* |
| Spam throttle | prints `You must wait Ns before communicating again` |
`0x0551` is confirmed as `YouAreNotListeningTo_Channel` in
`references/ACE/Source/ACE.Entity/Enum/WeenieErrorWithString.cs:479`.
Retail emits **no local echo** on success — it relies on the server's broadcast
coming back, exactly as ACE's `GetAllOnline()` loop provides. That is consistent
and correct: the option gate is what guarantees the echo will arrive.
### 4.3 `/a` is Turbine, not legacy
`StartupTurbineChatSystem @ 0x00594451ff.` binds the command strings `"a"` and
`"guild"` to `DoTurbineChat_Allegiance`. Once Turbine chat is up, `/a` is
**overridden away** from the generic `ChannelSystem::GetChannelID("allegiance")
→ 0x02000000 → Event_ChannelBroadcast` path. Retail never silently downgrades
`/a` to the legacy bitflag — with no allegiance room it hits the
`roomId <= 0` branch and prints *"Turbine chat is not available."*
### 4.4 Legacy family wire shape
`CM_Communication::Event_ChannelBroadcast @ 0x006a4030` writes
`u32 0x0147`, `u32 channelBitmask`, packed string, DWORD-aligned — inside the
ordinary GameAction (`OrderHdr`) envelope. Call sites confirm the bitmasks:
`0x800` Fellow, `0x2000` Patron, `0x4000` Monarch, `0x1000` Vassals,
`0x2000000` AllegianceBroadcast, `0x400` Help.
**acdream's `BuildChatChannel` is byte-identical.**
### 4.5 Inbound 0xF7DE in retail
`Client::ProcessLogonEventQueue` (line 18426, pumped every frame from
`Client::UseTime`) strips the 4-byte tag + 4-byte length and hands the raw
remainder to `IChatClient` vtable slot 3 — implemented in `chatclient.dll`,
outside the PDB. So the inner blob layout is **not** directly observable in
acclient.exe; our field table is ACE's reconstruction, independently
corroborated by `TurbineChatBlob` being exactly 12 bytes (matching the hard-coded
`extraDataSize = 0x0C`) and by `ChatRoomTracker`'s field order.
**Reference-quality caveat:** holtburger's `turbine.rs` fixtures are labelled
`Generated from ACE: SyntheticProtocolTests.GenerateTurbineChatFixtures`. It
corroborates ACE; it is **not** an independent retail capture. Our codec cites
holtburger throughout — that citation chain ultimately terminates at ACE, which
is fine here because ACE is the server we talk to, but it should not be
described as retail-verified.
---
## 5. Per-family defect diagnosis
### 5.1 Turbine General / Trade / LFG — **wire-correct, should already work**
- Outbound bytes match `TurbineChatHandler`'s reader field-for-field, including
both self-referential size dwords (§3.1).
- Inbound `EventBinary/SendToRoomByName` matches ACE's writer (§3.2), the
payload-length arithmetic reconciles (`sizeSecond - 8 == payloadLen`), and
`RouteTurbineChat` delivers it to `ChatLog` with the right display name.
- ACE's defaults leave `HearGeneralChat/TradeChat/LFGChat` **on**, so the sender
is not filtered out of `GetAllOnline()` and their own line comes back.
- No config gate blocks these on stock ACE.
**Defect (latent, becomes fatal the moment the option is off):** we never check
the option before sending, and we emit no local echo. If the user (or a future
Settings sync) ever turns General off server-side, `/g` becomes silent with zero
feedback instead of retail's *"You are not listening to the General channel."*
**Defect (real, minor):** we discard the `ResponseBinary` ack including its
`hResult`, so a server-side rejection is invisible.
### 5.2 Turbine Roleplay — **BROKEN. This is the headline defect.**
Causal chain, every link verified:
1. ACE gives new characters `CharacterOptions2.Default = 0x00948700`, which
**omits `HearRoleplayChat (0x800)`** (`CharacterOptions2.cs`).
2. At login ACE therefore skips `JoinTurbineChatChannel("Roleplay")`
(`Player_Networking.cs:104`). The player is never in the room.
3. 0x0295 still reports `roleplay=0x00000005` because the table is a constant
dump — this is exactly the log line that made the channel look available.
4. acdream's `TurbineChatRouting.Resolve` sees `RoleplayRoom == 5 != 0` and
`Enabled == true`, so `/rp hello` **is sent**, correctly framed.
5. ACE parses it fine, builds the broadcast, then in the recipient loop hits
`channelID == Roleplay && !recipient.GetCharacterOption(ListenToRoleplayChat)
→ continue` — **and the sender is one of those recipients.**
6. The sender receives only the `ResponseBinary` ack, which
`RouteTurbineChat` throws away.
7. acdream emits no local echo for Turbine channels.
**Net: the message is silently swallowed.** Retail would have refused at step 4
with `YouAreNotListeningTo_Channel`.
Same chain applies to **Society** (`HearSocietyChat` also absent from Default,
plus no society → `SocietyRoom == 0`, so we at least fall through to the
"dropped" log) and **Olthoi** (`IsOlthoiPlayer` false → ACE `return`s at
`TurbineChatHandler.cs:146`, no error to the client at all).
**Aggravating factor:** `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs`
defaults `HearRoleplayChat: true` and the Settings panel shows a checked box —
the UI actively tells the user Roleplay is on while the server has it off. Those
six toggles are read from and written to `settings.json` and **consumed by
nothing**: no display filter, no wire message. Confirmed by grep — the only
non-Settings references are the store and the panel itself.
### 5.3 Turbine Allegiance (`/a`) — **wrong transport on the no-allegiance path**
`TurbineChatRouting.Resolve` returns null when `AllegianceRoom == 0`, so
`/a` falls through to `ChannelResolver` → legacy `AllegianceBroadcast 0x02000000`
→ ACE replies `YouAreNotInAllegiance`. The user does get an error, so this is
low-severity, but:
- Retail binds `/a` to `DoTurbineChat_Allegiance` unconditionally (§4.3) and
prints *"Turbine chat is not available."* instead.
- With an allegiance, retail's `/a` and acdream's `/a` agree (both Turbine).
- We have **no verb for `/ab`** (allegiance broadcast), which is the command
that legitimately maps to `0x02000000`. So we have the right id bound to the
wrong verb, and the right verb missing.
Also unhandled: ACE's allegiance room id is `Allegiance.Biota.Id`, a large uint
well above `Olthoi (10)`. `TurbineChatDisplayNames.Resolve` keys off `chatType`
not `roomId`, so it renders correctly — no defect, worth a note.
### 5.4 Legacy `/f /v /p /m /cv` — **wire-correct, presentation broken**
Outbound `[0x0147][channelId][String16L]` matches both ACE's reader and retail's
`Event_ChannelBroadcast`. Inbound parse matches `GameEventChannelBroadcast`.
Channel ids all match. Errors are wired and have strings.
**Defect — double print.** ACE sends the sender a second
`GameEventChannelBroadcast` with `senderName = ""`, which `ChatVM` renders as
`[Fellowship] You say, "…"`. `LiveSessionCommandRouter.cs:283-286` **also**
calls `Chat.OnSelfSent(ChatKind.Channel, …)`, which renders the same line.
Result: every `/f`, `/v`, `/p`, `/m`, `/cv` line appears **twice**.
`ChatChannelInfo.IsSelfEchoChannel()` exists precisely to prevent this — its
doc comment says "caller should suppress optimistic local echo to avoid
double-printing" — and **no production code calls it.** The abstraction was
built and never wired.
*(Note `AllegianceBroadcast 0x02000000` does not get the `""` echo — the sender
appears in the normal member iteration with their real name — so for that one
channel the local `OnSelfSent` is arguably right. A correct fix must branch, not
blanket-remove.)*
**Defect — silent failure looks like success.** On `YouDoNotBelongToAFellowship`
the user does get the error line (it's wired), but they also get the
`OnSelfSent` echo first, so the transcript reads "You say X" followed by "You do
not belong to a Fellowship." Fixing the double-print fixes this too.
**Unimplemented on ACE:** `Channel.Help (0x400)` is an explicit placeholder;
unknown channel ids are dropped with no client-visible response.
### 5.5 Latent wire defects (not currently reachable, fix before they bite)
**`SetCharacterOptions (0x01A1)` is malformed.**
`SocialActions.BuildSetCharacterOptions` (`SocialActions.cs:136-144`) emits a
16-byte body: `[0xF7B1][seq][0x01A1][optionsBitmap]`. ACE reads that single u32
as `flags`, then reads `characterOptions1` past the end of the payload.
Worse, `CharacterOptionDataFlag.CharacterOptions2 = 0x40` collides with
`CharacterOptions1.AllowGive = 0x40`, which **is** set in
`CharacterOptions1.Default (0x50C48D4A)` — so ACE would also try to read an
options2 value. Reachable only via `IGameRuntimeCommands.SetOptions1`, which has
no production call site (grep: only tests). **Latent, but this is the exact
message a Settings-sync feature would reach for.**
**`AddChannel (0x0145)` / `RemoveChannel (0x0146)` send the wrong type.**
`SocialActions` documents them as `string16L channelName`; ACE reads
`(Channel)ReadUInt32()`. Both are admin-only on ACE and neither has a caller.
**No `SetSingleCharacterOption (0x0005)` sender exists.** This is the only
message that changes Turbine room membership, and we cannot send it.
---
## 6. Ordered fix list (CH3)
Ordered so each step is independently gate-able and the earliest steps are the
ones that make the user-visible symptom go away.
1. **Correct the false claim.** Delete the "ACE doesn't run a TurbineChat
server" note from `docs/ISSUES.md:15214` and
`docs/plans/2026-04-11-roadmap.md:429,948`. Same commit as any code.
2. **Make `Hear*` a first-class gate on the outbound Turbine path** (retail
`SendTurbineChat @ 0x0057db10`). `TurbineChatRouting.Resolve` — and its
headless twin `DirectGameRuntimeCommandAdapter.TrySendChannel` — must consult
`RuntimeCharacterOptionsState.Options2` (and `Options1.HearAllegianceChat`,
and `IsOlthoi` for Olthoi) before sending, and raise the retail refusal
locally instead of sending:
- Turbine off or room id 0 → `"Turbine chat is not available."`
- option off → `WeenieErrorWithString 0x0551 YouAreNotListeningTo_Channel`
with the channel name → add `[0x0551] = "You are not listening to the _
channel."` to `WeenieErrorMessages`.
This alone converts the Roleplay/Society/Olthoi silence into retail-correct
feedback. **Do this before step 3** so the behaviour is right even if the
user chooses to leave the channel off.
3. **Implement `SetSingleCharacterOption (0x0005)`**`u32 option, u32 value`
and wire the six Settings "Hear … Chat" toggles to it. ACE's handler will
call `JoinTurbineChatChannel`/`LeaveTurbineChatChannel` and re-push 0x0295,
and we already render the resulting `YouHaveEnteredThe_Channel` line. Option
ids from ACE `CharacterOption.cs`: `ListenToAllegianceChat`,
`ListenToGeneralChat 0x23`, `ListenToTradeChat 0x24`, `ListenToLFGChat 0x25`,
`ListenToRoleplayChat 0x26`, `ListenToSocietyChat`. This is what actually
turns Roleplay **on**.
4. **Seed `ChatSettings` from the server, not from a local default.** Its
`HearRoleplayChat: true` default is a lie relative to ACE's
`CharacterOptions2.Default`. Drive the panel from
`RuntimeCharacterOptionsState.Options2` (already parsed out of
PlayerDescription) so the checkbox reflects server truth, and make the
toggle publish step 3's command. Remove the "local-only" claim from the
`ChatSettings` doc comment.
5. **Kill the legacy double-print.** Make `LiveSessionCommandRouter` consult
`ChatChannelInfo.IsSelfEchoChannel()` (finally wiring the type that exists
for this) and skip `OnSelfSent` for
Fellow/Vassals/Patron/Monarch/CoVassals, keeping it for
`AllegianceBroadcast`, Say and Tell. Add a conformance test per channel id.
6. **Stop discarding the TurbineChat ack.** `RouteTurbineChat` should inspect
`Payload.Response.HResult` and surface a non-zero result as a system line;
at minimum log it. Today a server-side rejection is completely invisible.
7. **Route `/a` through Turbine unconditionally** (retail
`StartupTurbineChatSystem`), i.e. remove the silent legacy downgrade, and add
`/ab` / `/allegiancebroadcast` bound to legacy `0x02000000`. Register row for
any residual divergence.
8. **Fix or delete the malformed builders.** Either implement
`BuildSetCharacterOptions` against ACE's real `GameActionSetCharacterOptions`
layout (§3.8) or delete it and `SetOptions1` until there is a caller; same
for `BuildAddChannel`/`BuildRemoveChannel` (u32 bitfield, not string). Do not
leave a malformed message one call site away from production.
9. **Register + memory.** Rows in
`docs/architecture/retail-divergence-register.md` for anything that stays
divergent (e.g. no spam throttle, `Channel.Help` unimplemented server-side);
update `claude-memory/project_chat_pipeline.md`, whose line 111 carries the
same false "ACE doesn't run a TurbineChat server" claim.
---
## 7. Live probes (only where the source cannot settle it)
Everything above is settled from source **except** whether General/Trade/LFG are
actually working today. The static analysis says they are; the user reports side
channels don't work. Two cheap probes discriminate, and neither requires new
code beyond one log line.
### 7.1 Is General round-tripping at all?
Already-existing instrumentation covers the send side —
`LiveSessionCommandRouter.cs:254-257` logs
`chat: outbound TurbineChat General room=0x… chatType=2 cookie=0x… sender=0x… len=…`.
Procedure: launch against local ACE, type `/g test`, then grep the launch log
for `outbound TurbineChat`.
- **Line present, `/g test` appears in the chat window** → General works;
the reported breakage is Roleplay/Society/Olthoi/`/a` only, and steps 24 are
the whole fix.
- **Line present, nothing in the window** → the sender is being filtered
(`HearGeneralChat` off on this character despite the default, e.g. a
previously-persisted value) or the inbound `EventBinary` is not being
delivered. Distinguish with 7.2.
- **Line absent**`TurbineChatState.Enabled` is false or the room id is 0 for
this session; check for the `chat: SetTurbineChatChannels parsed` line at
login.
### 7.2 Which inbound 0xF7DE variants arrive?
The one genuinely missing measurement. `RouteTurbineChat`
(`LiveSessionEventRouter.cs:479-489`) early-returns on everything that is not
`EventSendToRoom`, so we currently cannot tell "ACE sent nothing" from "ACE sent
an ack and we dropped it". Add a temporary line at the top of `RouteTurbineChat`:
```
[turbine-in] blob=<BlobType> dispatch=<DispatchType> room/context=<…> hresult=<…>
```
- **`ResponseBinary` only, no `EventBinary`** → confirms the §5.2 chain: ACE
accepted the send and filtered the sender out of its own broadcast. The fix is
the character option, not the codec.
- **Neither** → the send never reached the handler; capture loopback UDP on
`127.0.0.1:9000` with WireMCP and check the 0xF7DE bytes against §3.1.
- **`EventBinary` present but no chat line** → the defect is downstream in
`ChatLog`/`ChatVM`, not on the wire.
Strip the line once the answer is in hand.
### 7.3 Server-side confirmation (no client change)
ACE's `LogTurbineChat` (`TurbineChatHandler.cs:345-385`) writes
`[CHAT][General] +Acdream says, "…"` to the ACE log when `chat_log_general` is
set. Enabling that property and watching the ACE console proves whether the
message reached the handler at all — the cleanest single discriminator, and it
requires nothing from the client.
---
## 8. Files touched by any fix
| Path | Why |
|---|---|
| `src/AcDream.App/Net/LiveSessionCommandRouter.cs` | Turbine option gate; legacy self-echo suppression |
| `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs` | same gate for the headless path |
| `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | ack/hResult handling in `RouteTurbineChat` |
| `src/AcDream.Core/Chat/WeenieErrorMessages.cs` | `0x0551 YouAreNotListeningTo_Channel` |
| `src/AcDream.Core.Net/Messages/SocialActions.cs` | `SetSingleCharacterOption`; fix/remove 0x01A1, 0x0145, 0x0146 |
| `src/AcDream.Core.Net/WorldSession.cs` | `SendSetSingleCharacterOption` |
| `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs` + `SettingsPanel.cs` | seed from server options; publish the command |
| `src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs` | `/ab` verb |
| `src/AcDream.UI.Abstractions/ChannelResolver.cs` | `/a` vs `/ab` split |
| `docs/ISSUES.md`, `docs/plans/2026-04-11-roadmap.md`, `claude-memory/project_chat_pipeline.md` | retract the false claim |
| `docs/architecture/retail-divergence-register.md` | rows for residual divergences |