From e306c979aeba8a1f9157f2c14aac0ec90d3af253 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 15:25:29 +0200 Subject: [PATCH] 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 --- docs/plans/2026-08-09-chat-parity-campaign.md | 8 +- ...2026-08-09-chat-retail-command-registry.md | 399 ++++++++ .../2026-08-09-chat-retail-interface-text.md | 931 ++++++++++++++++++ .../2026-08-09-chat-side-channels-vs-ace.md | 778 +++++++++++++++ 4 files changed, 2112 insertions(+), 4 deletions(-) create mode 100644 docs/research/2026-08-09-chat-retail-command-registry.md create mode 100644 docs/research/2026-08-09-chat-retail-interface-text.md create mode 100644 docs/research/2026-08-09-chat-side-channels-vs-ace.md diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 7bb0aedf..1917a469 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -1,7 +1,7 @@ # Campaign CH — chat & interface-text retail parity -**Status:** ACTIVE 2026-08-09 — research lanes running; slices finalize when -they land. +**Status:** ACTIVE 2026-08-09 — R1–R4 research complete (four docs under +`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 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 | |---|---|---|---|---| -| R1–R4 research | — | — | — | — | -| CH1 colors | `06f448fc` | 11,833 passed / 4 skipped / 0 failed | — | pending | +| R1–R4 research | `see docs/research/2026-08-09-chat-retail-*` | — | — | — | +| CH1 colors | `172c6f9a` | 11,833 passed / 4 skipped / 0 failed | in review | pending | | CH2 interface text | — | — | — | — | | CH3 side channels | — | — | — | — | | CH4 commands | — | — | — | — | diff --git a/docs/research/2026-08-09-chat-retail-command-registry.md b/docs/research/2026-08-09-chat-retail-command-registry.md new file mode 100644 index 00000000..0a7e66b2 --- /dev/null +++ b/docs/research/2026-08-09-chat-retail-command-registry.md @@ -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::PStringBase` 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>, 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*)`), `+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 `, 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` | `` | 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` | `, ` | 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` | `` | 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` | `` | 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` | `` | Same as `mr` (verified at `0x005830C1`). | MISSING (same note) | +| `retell` | `rt` | `DoReTell @ 0x00577BD0` | `` | 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` | `` | 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` | `` | `GetChannelID` then `Event_ChannelList(id)`; bad tag → WeenieError `0x422`; no arg → "Please specify the channel name." ACE: `ListChannels`. | MISSING | +| `on` | — | `DoChannelOn @ 0x0057AA80` | `` | `Event_AddToChannel(id)`. ACE: `AddChannel`. | MISSING | +| `off` | — | `DoChannelOff @ 0x0057AB50` | `` | `Event_RemoveFromChannel(id)`. ACE: `RemoveChannel`. | MISSING | +| `title` | — | `DoTitle @ 0x0057A640` | `` | 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` | `-` | 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` | `` | `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` | ` [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` | `` | Monarch broadcast to the whole allegiance. | MISSING | +| `alh` | `ah` | `DoAllegianceHometown @ 0x0056EF10` | — | ACE: `GameActionType.RecallAllegianceHometown`. | MISSING | +| `motd` | — | `DoMotd @ 0x00577150` | `[set \|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` | ` [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` | `` | 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 ` / `remove ` — 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 ` 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 ` 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 ` — 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 ` / `@help ` + 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 ` — 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 ` — ACE `AddPlayerPermission` / `RemovePlayerPermission`. **W-new** +20. `@hslist ` — ACE `ListAvailableHouses`. **W-new** +21. `@index` / `@clist` / `@on` / `@off` — ACE `IndexChannels`, `ListChannels`, + `AddChannel`, `RemoveChannel`. **W-new** (four small parameterless/one-dword actions) +22. `@allegiance ` — 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 ` — 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, , nullptr)` call (later blocks) +or the `*(esi + 8) = ` / `*(esi + 0x10) = ` 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. diff --git a/docs/research/2026-08-09-chat-retail-interface-text.md b/docs/research/2026-08-09-chat-retail-interface-text.md new file mode 100644 index 00000000..fc2d8785 --- /dev/null +++ b/docs/research/2026-08-09-chat-retail-interface-text.md @@ -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, uint type, uint8 allowPluginFilter, uint windowId)` | `0x00563C50` | **the single chokepoint** for all player-visible text | +| `ClientSystem::AddTextToScroll(PStringBase, ...)` | `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 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::PStringBase(&var, u"…")`) or via + `PStringBase::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 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 (0x00–0x21) | 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 0x00–0x21 space, not ACE's truncated 0x00–0x1F. 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) | diff --git a/docs/research/2026-08-09-chat-side-channels-vs-ace.md b/docs/research/2026-08-09-chat-side-channels-vs-ace.md new file mode 100644 index 00000000..95d74916 --- /dev/null +++ b/docs/research/2026-08-09-chat-side-channels-vs-ace.md @@ -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 +`HearChat` 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(true, …))`. +3. **We have already received it, repeatedly.** Our own launch logs contain the + parsed 0x0295 payload with live room ids, e.g. + `docs/research/2026-08-07-339-portal-space-hang.log:55` and eleven + 2026-05-21/2026-05-23 capture logs: + + ``` + chat: SetTurbineChatChannels parsed enabled=True general=0x00000002 + trade=0x00000003 lfg=0x00000004 roleplay=0x00000005 society=0x00000000 + olthoi=0x0000000A allegiance=0x00000000 + ``` + + Those are exactly ACE's `TurbineChatChannel` constants. `TurbineChatState.Enabled` + has been `true` against local ACE since at least 2026-05-21. + +**Action:** both the ISSUES entry and the two roadmap rows must be corrected in +the CH3 commit. + +--- + +## 2. acdream inventory (current truth) + +### 2.1 Codec — `src/AcDream.Core.Net/Messages/TurbineChat.cs` + +Builds and parses the 0xF7DE message. Header is opcode + 9 u32 (36 bytes): + +| Off | Field | Build value (outbound request) | +|---|---|---| +| 0 | `u32 opcode` | `0xF7DE` | +| 4 | `u32 sizeFirst` | `40 + payloadLen` | +| 8 | `u32 blobType` | `3` RequestBinary | +| 12 | `u32 dispatchType` | `2` SendToRoomById | +| 16 | `u32 targetType` | `1` | +| 20 | `u32 targetId` | `0` | +| 24 | `u32 transportType` | `0` | +| 28 | `u32 transportId` | `0` | +| 32 | `u32 cookie` | `0` | +| 36 | `u32 sizeSecond` | `8 + payloadLen` | + +Request payload (`WritePayload`, `TurbineChat.cs:371-381`): +`u32 contextId`, `u32 2`, `u32 2`, `u32 roomId`, turbine-string message, +`u32 0x0C`, `u32 senderId`, `u32 0` hresult, `u32 chatType`. + +Turbine string codec (`ReadTurbineString`/`WriteTurbineString`, +`TurbineChat.cs:406-463`): 1-or-2-byte packed length in **UTF-16 code units**, +then UTF-16LE bytes, no padding. This is the only acdream string type that is +not CP1252 String16L. + +Parse handles three shapes: `(EventBinary, SendToRoomByName)`, +`(RequestBinary, SendToRoomById)` with a hard reject unless the inner +response/method ids are both `2`, and `(ResponseBinary, *)`. Unknown +blob/dispatch pairs are captured verbatim rather than rejected. + +### 2.2 Room table — `src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs` + +Parses the 40-byte 0x0295 GameEvent payload as 10 u32 in order: +allegiance, general, trade, lfg, roleplay, olthoi, society, societyCelHan, +societyEldWeb, societyRadBlo. Registered in the `WorldSession` ctor +(`WorldSession.cs:837`) **and** again in `GameEventWiring.cs:184-213`; the +latter is the one that actually feeds `TurbineChatState.OnChannelsReceived` +and prints the diagnostic line quoted in §1. `WorldSession.TurbineChannelsReceived` +has **no production subscriber** — dead event surface, harmless. + +### 2.3 State — `src/AcDream.Core/Chat/TurbineChatState.cs` + +Holds `Enabled` + the ten room ids + a per-session context cookie starting at 1 +and wrapping to 1. `Reset()` clears everything on session replace. `RoomFor()` +maps a `ChatChannelKindLite` to a room id. **Nothing in this class knows about +the Hear\* options.** + +### 2.4 Channel classification — `src/AcDream.Core/Chat/ChatChannelInfo.cs` + +`Legacy(channelId)` vs `Turbine(roomId, chatType, dispatchType)`, plus +`IsSelfEchoChannel()`: true for legacy Fellow/Vassals/Patron/Monarch/CoVassals, +false for Turbine. **This type is never consulted by any production send path.** +It exists only for its own unit tests (`ChatChannelInfoTests`). The +`ChannelResolver` referenced in the brief lives at +`src/AcDream.UI.Abstractions/ChannelResolver.cs`, not under `Core/Chat/`. + +### 2.5 Legacy resolver — `src/AcDream.UI.Abstractions/ChannelResolver.cs` + +| Kind | Id | ACE `Channel` | Match | +|---|---|---|---| +| Fellowship | `0x00000800` | `Fellow` | ✔ | +| Allegiance | `0x02000000` | `AllegianceBroadcast` | ✔ value, ✘ semantics (§5.3) | +| Vassals | `0x00001000` | `Vassals` | ✔ | +| Patron | `0x00002000` | `Patron` | ✔ | +| Monarch | `0x00004000` | `Monarch` | ✔ | +| CoVassals | `0x01000000` | `CoVassals` | ✔ | + +### 2.6 Outbound send paths + +**Graphical**, `src/AcDream.App/Net/LiveSessionCommandRouter.cs:247-286`: + +``` +SendChatCmd + ├ Say → SendTalk (no local echo — waits for the authoritative line) + ├ Tell → SendTell + Chat.OnSelfSent(Tell) + ├ TurbineChatRouting.Resolve(kind, TurbineChat) → non-null? + │ → SendTurbineChat(room, chatType, SendToRoomById, playerGuid, text, cookie) + │ (NO OnSelfSent) + ├ ChannelResolver.Resolve(kind) → non-null? + │ → SendChannel(legacyId, text) + Chat.OnSelfSent(Channel, displayName) + └ else → log "dropped" and return +``` + +`TurbineChatRouting.Resolve` (same file, ~line 388) gates only on +`state.Enabled` and `Room != 0`. + +**Headless**, `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:949-990` +— same order, same gate, no echo either way. + +**Wire builders:** +- `WorldSession.SendTurbineChatTo` (`WorldSession.cs:2602-2638`) — hard-codes + `targetType=1`, everything else 0, `extraDataSize=0x0C`, outer `cookie=0`, + inner `contextId` = the caller's cookie. Sends through `SendGameAction` → + `SendGameMessage` → `GameMessageGroup.UIQueue`. +- `WorldSession.SendChannel` (`WorldSession.cs:2090-2096`) → + `ChatRequests.BuildChatChannel` (`ChatRequests.cs:89-100`): + `[0xF7B1][seq][0x0147][u32 channelId][String16L message]`, CP1252, + 4-byte aligned. + +### 2.7 Inbound handling + +- **0xF7DE** — `WorldSession.cs:1855-1866` parses and raises `TurbineChatReceived`; + `LiveSessionEventRouter.cs:280-283` subscribes; + `RouteTurbineChat` (`LiveSessionEventRouter.cs:479-489`) forwards **only** + `EventSendToRoom` into `ChatLog.OnChannelBroadcast(roomId, sender, text, displayName)`. + `Response` (the ack) and `Unknown` are **silently discarded** — no logging, no + hResult inspection. +- **0x0147 ChannelBroadcast** — `GameEventWiring.cs:102-106` → + `GameEvents.ParseChannelBroadcast` (`u32 channelId`, `String16L sender`, + `String16L message`) → `ChatLog.OnChannelBroadcast`. +- **0x028A / 0x028B WeenieError(WithString)** — wired at + `GameEventWiring.cs:220-229` into `ChatLog.OnWeenieError`, and + `WeenieErrorMessages` has the relevant strings: `0x051D` "Turbine Chat is + enabled.", `0x051B/0x051C` channel enter/leave, `0x0414` not in allegiance, + `0x050F` not in a Fellowship. +- **Rendering** — `ChatVM.FormatEntry` renders `ChatKind.Channel` as + `[Name] Sender says, "text"`, or `[Name] You say, "text"` when the sender is + empty or "You". `ChatWindowController.RetailChatColor` gives Channel + `colorLightBlue`. No filtering by channel anywhere. + +### 2.8 Command parsing + +`ChatCommandRouter.Submit` → retail client-command catalog → local `/help` → +degenerate-prefix guard → unknown-verb `@`-passthrough → `ChatInputParser.Parse`. +`ChatInputParser.ChannelVerbs` (`ChatInputParser.cs:55-82`) covers +`/g /general /gen /f /fellow /fellowship /a /allegiance /m /monarch /p /patron +/v /vassals /cv /covassals /lfg /lookingforgroup /trade /tr /role /rp /roleplay +/society /olthoi`. No collisions with `RetailClientCommandCatalog`. **The verb +layer is fine.** + +--- + +## 3. The ACE contract + +### 3.1 TurbineChat inbound — `TurbineChatHandler.cs:20-60` + +Registered `[GameMessage(GameMessageOpcode.TurbineChat, SessionState.WorldConnected)]`. +Reads, in order (all little-endian u32 unless noted): + +| # | Field | ACE comment / use | +|---|---|---| +| 1 | size | "Bytes to follow" — read and discarded | +| 2 | `chatBlobType` | must be `3` NETBLOB_REQUEST_BINARY, else "Unhandled" console line | +| 3 | `chatBlobDispatchType` | `1` byName / `2` byId — only affects channel re-derivation | +| 4 | — | "Always 1" | +| 5–8 | — | "Always 0" ×4 | +| 9 | size | "Bytes to follow" — read and discarded | +| — | *gag check* | `session.Player.IsGagged` → `SendGagError()`, return | +| 10 | `contextId` | echoed in the ack | +| 11 | — | "Always 2" | +| 12 | — | "Always 2" | +| 13 | `channelID` | room id | +| 14 | message | packed-byte length (`&0x80` ⇒ 2-byte), then `len*2` bytes, `Encoding.Unicode` | +| 15 | — | "Always 0x0C" | +| 16 | `senderID` | client-supplied, not validated | +| 17 | — | "Always 0" | +| 18 | `chatType` | `ChatType` enum | + +**acdream's outbound matches this field-for-field.** Sizes match too: ACE +backpatches `firstSize = end - firstSizePos + 4` = `40 + payloadLen` and +`secondSize = end - secondSizePos + 4` = `8 + payloadLen`, identical to +`TurbineChat.Build` lines 332-333. + +### 3.2 TurbineChat outbound — `GameMessageTurbineChat.cs:79-136` + +Sent on `GameMessageGroup.LoginQueue`. + +**`NETBLOB_EVENT_BINARY` (the broadcast):** header is +`size, 1, 1, 1, 0x000B00B5, 1, 0x000B00B5, 0, size`; payload is +`u32 channel`, packed-byte + UTF-16 `senderName`, packed-byte + UTF-16 `message`, +`u32 0x0C`, `u32 senderID`, `u32 0`, `u32 chatType`. Dispatch type is always +`ASYNCMETHOD_SENDTOROOMBYNAME (1)` even for a byId request. +**acdream's `(EventBinary, SendToRoomByName)` parse matches.** + +**`NETBLOB_RESPONSE_BINARY` (the ack):** same header, payload +`u32 contextId, u32 2, u32 2, u32 0`. +**acdream parses it, then throws it away** (`RouteTurbineChat` early-returns). + +*ACE bug worth knowing:* the ≥128-char branch of the `senderName` prefix writes +`message.Length`, not `senderName.Length` (`GameMessageTurbineChat.cs:98`). +Unreachable in practice (no AC character name is ≥128 chars). + +### 3.3 Room-id table — `TurbineChatChannel.cs` + +`Allegiance=1, General=2, Trade=3, LFG=4, Roleplay=5, Society=6, +SocietyCelestialHand=7, SocietyEldrytchWeb=8, SocietyRadiantBlood=9, Olthoi=10`. + +`GameEventSetTurbineChatChannels` writes 10 u32: +`allegiance, 2, 3, 4, 5, 10, society, 7, 8, 9`. Only `allegiance` and `society` +are dynamic — `allegiance` is the allegiance's `Biota.Id` (a large uint, **not** +the sentinel `1`), `society` is 7/8/9 or 0. + +**Critical:** this table is sent whole every time, regardless of which channels +the player is actually joined to. `roleplay=0x00000005` in our log means "the +Roleplay room's id is 5", **not** "you are in the Roleplay room." + +### 3.4 When ACE pushes 0x0295 — `Player_Networking.cs:85-110` + +```csharp +if (PropertyManager.GetBool("use_turbine_chat").Item) { + EnqueueSend(new GameEventWeenieError(Session, WeenieError.TurbineChatIsEnabled)); + if (IsOlthoiPlayer) JoinTurbineChatChannel("Olthoi"); + else { + if (GetCharacterOption(ListenToAllegianceChat) && Allegiance != null) JoinTurbineChatChannel("Allegiance"); + if (GetCharacterOption(ListenToGeneralChat)) JoinTurbineChatChannel("General"); + if (GetCharacterOption(ListenToTradeChat)) JoinTurbineChatChannel("Trade"); + if (GetCharacterOption(ListenToLFGChat)) JoinTurbineChatChannel("LFG"); + if (GetCharacterOption(ListenToRoleplayChat)) JoinTurbineChatChannel("Roleplay"); + if (GetCharacterOption(ListenToSocietyChat) && Society != FactionBits.None) JoinTurbineChatChannel("Society"); + } +} +``` + +`JoinTurbineChatChannel` sends `WeenieErrorWithString.YouHaveEnteredThe_Channel` +(0x051B) then `SendTurbineChatChannels()`. Also fired from +`GameActionSetSingleCharacterOption` when the client toggles a Hear\* option, +and from allegiance changes. + +### 3.5 Character options — the gate that matters + +`CharacterOptions2` (`ACE.Entity/Enum/CharacterOptions2.cs`): + +| Bit | Name | In `Default`? | +|---|---|---| +| `0x00000100` | HearGeneralChat | **yes** | +| `0x00000200` | HearTradeChat | **yes** | +| `0x00000400` | HearLFGChat | **yes** | +| `0x00000800` | HearRoleplayChat | **NO** | +| `0x00080000` | HearSocietyChat | **NO** | + +`Default = 0x00948700`. `CharacterOptions1.Default` includes +`HearAllegianceChat (0x40000000)`; `CharacterOptions1.Default = 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 <X> channel."* | +| Spam throttle | prints `You must wait Ns before communicating again` | + +`0x0551` is confirmed as `YouAreNotListeningTo_Channel` in +`references/ACE/Source/ACE.Entity/Enum/WeenieErrorWithString.cs:479`. + +Retail emits **no local echo** on success — it relies on the server's broadcast +coming back, exactly as ACE's `GetAllOnline()` loop provides. That is consistent +and correct: the option gate is what guarantees the echo will arrive. + +### 4.3 `/a` is Turbine, not legacy + +`StartupTurbineChatSystem @ 0x00594451ff.` binds the command strings `"a"` and +`"guild"` to `DoTurbineChat_Allegiance`. Once Turbine chat is up, `/a` is +**overridden away** from the generic `ChannelSystem::GetChannelID("allegiance") +→ 0x02000000 → Event_ChannelBroadcast` path. Retail never silently downgrades +`/a` to the legacy bitflag — with no allegiance room it hits the +`roomId <= 0` branch and prints *"Turbine chat is not available."* + +### 4.4 Legacy family wire shape + +`CM_Communication::Event_ChannelBroadcast @ 0x006a4030` writes +`u32 0x0147`, `u32 channelBitmask`, packed string, DWORD-aligned — inside the +ordinary GameAction (`OrderHdr`) envelope. Call sites confirm the bitmasks: +`0x800` Fellow, `0x2000` Patron, `0x4000` Monarch, `0x1000` Vassals, +`0x2000000` AllegianceBroadcast, `0x400` Help. +**acdream's `BuildChatChannel` is byte-identical.** + +### 4.5 Inbound 0xF7DE in retail + +`Client::ProcessLogonEventQueue` (line 18426, pumped every frame from +`Client::UseTime`) strips the 4-byte tag + 4-byte length and hands the raw +remainder to `IChatClient` vtable slot 3 — implemented in `chatclient.dll`, +outside the PDB. So the inner blob layout is **not** directly observable in +acclient.exe; our field table is ACE's reconstruction, independently +corroborated by `TurbineChatBlob` being exactly 12 bytes (matching the hard-coded +`extraDataSize = 0x0C`) and by `ChatRoomTracker`'s field order. + +**Reference-quality caveat:** holtburger's `turbine.rs` fixtures are labelled +`Generated from ACE: SyntheticProtocolTests.GenerateTurbineChatFixtures`. It +corroborates ACE; it is **not** an independent retail capture. Our codec cites +holtburger throughout — that citation chain ultimately terminates at ACE, which +is fine here because ACE is the server we talk to, but it should not be +described as retail-verified. + +--- + +## 5. Per-family defect diagnosis + +### 5.1 Turbine General / Trade / LFG — **wire-correct, should already work** + +- Outbound bytes match `TurbineChatHandler`'s reader field-for-field, including + both self-referential size dwords (§3.1). +- Inbound `EventBinary/SendToRoomByName` matches ACE's writer (§3.2), the + payload-length arithmetic reconciles (`sizeSecond - 8 == payloadLen`), and + `RouteTurbineChat` delivers it to `ChatLog` with the right display name. +- ACE's defaults leave `HearGeneralChat/TradeChat/LFGChat` **on**, so the sender + is not filtered out of `GetAllOnline()` and their own line comes back. +- No config gate blocks these on stock ACE. + +**Defect (latent, becomes fatal the moment the option is off):** we never check +the option before sending, and we emit no local echo. If the user (or a future +Settings sync) ever turns General off server-side, `/g` becomes silent with zero +feedback instead of retail's *"You are not listening to the General channel."* + +**Defect (real, minor):** we discard the `ResponseBinary` ack including its +`hResult`, so a server-side rejection is invisible. + +### 5.2 Turbine Roleplay — **BROKEN. This is the headline defect.** + +Causal chain, every link verified: + +1. ACE gives new characters `CharacterOptions2.Default = 0x00948700`, which + **omits `HearRoleplayChat (0x800)`** (`CharacterOptions2.cs`). +2. At login ACE therefore skips `JoinTurbineChatChannel("Roleplay")` + (`Player_Networking.cs:104`). The player is never in the room. +3. 0x0295 still reports `roleplay=0x00000005` because the table is a constant + dump — this is exactly the log line that made the channel look available. +4. acdream's `TurbineChatRouting.Resolve` sees `RoleplayRoom == 5 != 0` and + `Enabled == true`, so `/rp hello` **is sent**, correctly framed. +5. ACE parses it fine, builds the broadcast, then in the recipient loop hits + `channelID == Roleplay && !recipient.GetCharacterOption(ListenToRoleplayChat) + → continue` — **and the sender is one of those recipients.** +6. The sender receives only the `ResponseBinary` ack, which + `RouteTurbineChat` throws away. +7. acdream emits no local echo for Turbine channels. + +**Net: the message is silently swallowed.** Retail would have refused at step 4 +with `YouAreNotListeningTo_Channel`. + +Same chain applies to **Society** (`HearSocietyChat` also absent from Default, +plus no society → `SocietyRoom == 0`, so we at least fall through to the +"dropped" log) and **Olthoi** (`IsOlthoiPlayer` false → ACE `return`s at +`TurbineChatHandler.cs:146`, no error to the client at all). + +**Aggravating factor:** `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs` +defaults `HearRoleplayChat: true` and the Settings panel shows a checked box — +the UI actively tells the user Roleplay is on while the server has it off. Those +six toggles are read from and written to `settings.json` and **consumed by +nothing**: no display filter, no wire message. Confirmed by grep — the only +non-Settings references are the store and the panel itself. + +### 5.3 Turbine Allegiance (`/a`) — **wrong transport on the no-allegiance path** + +`TurbineChatRouting.Resolve` returns null when `AllegianceRoom == 0`, so +`/a` falls through to `ChannelResolver` → legacy `AllegianceBroadcast 0x02000000` +→ ACE replies `YouAreNotInAllegiance`. The user does get an error, so this is +low-severity, but: + +- Retail binds `/a` to `DoTurbineChat_Allegiance` unconditionally (§4.3) and + prints *"Turbine chat is not available."* instead. +- With an allegiance, retail's `/a` and acdream's `/a` agree (both Turbine). +- We have **no verb for `/ab`** (allegiance broadcast), which is the command + that legitimately maps to `0x02000000`. So we have the right id bound to the + wrong verb, and the right verb missing. + +Also unhandled: ACE's allegiance room id is `Allegiance.Biota.Id`, a large uint +well above `Olthoi (10)`. `TurbineChatDisplayNames.Resolve` keys off `chatType` +not `roomId`, so it renders correctly — no defect, worth a note. + +### 5.4 Legacy `/f /v /p /m /cv` — **wire-correct, presentation broken** + +Outbound `[0x0147][channelId][String16L]` matches both ACE's reader and retail's +`Event_ChannelBroadcast`. Inbound parse matches `GameEventChannelBroadcast`. +Channel ids all match. Errors are wired and have strings. + +**Defect — double print.** ACE sends the sender a second +`GameEventChannelBroadcast` with `senderName = ""`, which `ChatVM` renders as +`[Fellowship] You say, "…"`. `LiveSessionCommandRouter.cs:283-286` **also** +calls `Chat.OnSelfSent(ChatKind.Channel, …)`, which renders the same line. +Result: every `/f`, `/v`, `/p`, `/m`, `/cv` line appears **twice**. + +`ChatChannelInfo.IsSelfEchoChannel()` exists precisely to prevent this — its +doc comment says "caller should suppress optimistic local echo to avoid +double-printing" — and **no production code calls it.** The abstraction was +built and never wired. + +*(Note `AllegianceBroadcast 0x02000000` does not get the `""` echo — the sender +appears in the normal member iteration with their real name — so for that one +channel the local `OnSelfSent` is arguably right. A correct fix must branch, not +blanket-remove.)* + +**Defect — silent failure looks like success.** On `YouDoNotBelongToAFellowship` +the user does get the error line (it's wired), but they also get the +`OnSelfSent` echo first, so the transcript reads "You say X" followed by "You do +not belong to a Fellowship." Fixing the double-print fixes this too. + +**Unimplemented on ACE:** `Channel.Help (0x400)` is an explicit placeholder; +unknown channel ids are dropped with no client-visible response. + +### 5.5 Latent wire defects (not currently reachable, fix before they bite) + +**`SetCharacterOptions (0x01A1)` is malformed.** +`SocialActions.BuildSetCharacterOptions` (`SocialActions.cs:136-144`) emits a +16-byte body: `[0xF7B1][seq][0x01A1][optionsBitmap]`. ACE reads that single u32 +as `flags`, then reads `characterOptions1` past the end of the payload. +Worse, `CharacterOptionDataFlag.CharacterOptions2 = 0x40` collides with +`CharacterOptions1.AllowGive = 0x40`, which **is** set in +`CharacterOptions1.Default (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 2–4 are + the whole fix. +- **Line present, nothing in the window** → the sender is being filtered + (`HearGeneralChat` off on this character despite the default, e.g. a + previously-persisted value) or the inbound `EventBinary` is not being + delivered. Distinguish with 7.2. +- **Line absent** → `TurbineChatState.Enabled` is false or the room id is 0 for + this session; check for the `chat: SetTurbineChatChannels parsed` line at + login. + +### 7.2 Which inbound 0xF7DE variants arrive? + +The one genuinely missing measurement. `RouteTurbineChat` +(`LiveSessionEventRouter.cs:479-489`) early-returns on everything that is not +`EventSendToRoom`, so we currently cannot tell "ACE sent nothing" from "ACE sent +an ack and we dropped it". Add a temporary line at the top of `RouteTurbineChat`: + +``` +[turbine-in] blob= dispatch= 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 |