docs: Campaign CH R1/R2/R4 research + ledger correction (CH1 = 172c6f9a)
Commits the command-registry, interface-text (SpewBox), and side-channels-vs-ACE research docs (R3 color-table landed with CH1). Corrects the CH1 ledger SHA the implementer recorded pre-amend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
172c6f9aa3
commit
e306c979ae
4 changed files with 2112 additions and 4 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
# Campaign CH — chat & interface-text retail parity
|
# Campaign CH — chat & interface-text retail parity
|
||||||
|
|
||||||
**Status:** ACTIVE 2026-08-09 — research lanes running; slices finalize when
|
**Status:** ACTIVE 2026-08-09 — R1–R4 research complete (four docs under
|
||||||
they land.
|
`docs/research/2026-08-09-chat-retail-*.md`); CH1 landed, in Opus review.
|
||||||
|
|
||||||
**Why now:** first track of the alpha-release program (chat is the most
|
**Why now:** first track of the alpha-release program (chat is the most
|
||||||
visible daily surface for the friend-alpha). User-directed 2026-08-09.
|
visible daily surface for the friend-alpha). User-directed 2026-08-09.
|
||||||
|
|
@ -98,8 +98,8 @@ implementer per slice against a pinned contract (per
|
||||||
|
|
||||||
| Slice | Commit | Suite | Review | User gate |
|
| Slice | Commit | Suite | Review | User gate |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| R1–R4 research | — | — | — | — |
|
| R1–R4 research | `see docs/research/2026-08-09-chat-retail-*` | — | — | — |
|
||||||
| CH1 colors | `06f448fc` | 11,833 passed / 4 skipped / 0 failed | — | pending |
|
| CH1 colors | `172c6f9a` | 11,833 passed / 4 skipped / 0 failed | in review | pending |
|
||||||
| CH2 interface text | — | — | — | — |
|
| CH2 interface text | — | — | — | — |
|
||||||
| CH3 side channels | — | — | — | — |
|
| CH3 side channels | — | — | — | — |
|
||||||
| CH4 commands | — | — | — | — |
|
| CH4 commands | — | — | — | — |
|
||||||
|
|
|
||||||
399
docs/research/2026-08-09-chat-retail-command-registry.md
Normal file
399
docs/research/2026-08-09-chat-retail-command-registry.md
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
# Retail client slash-command registry — complete enumeration + acdream audit
|
||||||
|
|
||||||
|
Date: 2026-08-09
|
||||||
|
Status: RESEARCH ONLY. No production code was changed.
|
||||||
|
|
||||||
|
Oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
|
||||||
|
build) plus byte-level decode of the PDB-paired binary
|
||||||
|
`C:\Users\erikn\Downloads\acclient.exe` (v11.4186, CodeView GUID
|
||||||
|
`9e847e2f-777c-4bd9-886c-22256bb87f32`). Every verb string in this document was
|
||||||
|
read out of `.rdata` at the exact `push imm32` operand feeding
|
||||||
|
`PStringBase<char>::PStringBase<char>` inside the registration loop — none were
|
||||||
|
inferred from Binary Ninja's symbolic rendering, which mislabels several of the
|
||||||
|
pooled single-character strings as wide-string slices
|
||||||
|
(`&*U"fvpca"[4]`) and two as vtable fields.
|
||||||
|
|
||||||
|
Extends `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`,
|
||||||
|
which already proved out the families acdream ported (lifestone/marketplace/
|
||||||
|
arena/age/birth/framerate/lockui/die/loc/corpse/clear/UI-layout/friends/afk/
|
||||||
|
consent/squelch/filter/emote/fillcomps). Those pseudocode blocks are not
|
||||||
|
repeated here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Registry mechanism
|
||||||
|
|
||||||
|
There is **one** command table and **two** functions that populate it:
|
||||||
|
`ClientCommunicationSystem::m_hashCommands`, an
|
||||||
|
`IntrusiveHashTable<CaseInsensitiveStringBase<PStringBase<char>>, CmdHashData*>`
|
||||||
|
sized to 100 buckets in the ctor at `0x0058555D`.
|
||||||
|
`ClientCommunicationSystem::InitializeCommands @ 0x00581970` (spanning to
|
||||||
|
`0x00585520`) registers **116** entries; `CmdHashData::CmdHashData @ 0x0056ED30`
|
||||||
|
stores `+0x00` verb, `+0x08` `func` (`uint8_t (*)(this, int argc, char** argv)`),
|
||||||
|
`+0x0C` unused-in-practice, `+0x10` `help`
|
||||||
|
(`uint8_t (*)(this, HelpType, char const*, PStringBase<char>*)`), `+0x14` unused.
|
||||||
|
`ClientCommunicationSystem::StartupTurbineChatSystem @ 0x0057EFB0` runs only when
|
||||||
|
the server enables Turbine Chat; it **removes** the `a` entry and adds 15
|
||||||
|
entries (`a`, `guild`, `gu`, `general`, `cg`, `trade`, `ct`, `lfg`, `clfg`,
|
||||||
|
`roleplay`, `crp`, `society`, `soc`, `olthoi`, `o`), i.e. 14 net-new verbs.
|
||||||
|
Nothing else ever calls `IntrusiveHashTable<…CmdHashData*>::add` — verified by
|
||||||
|
grepping every call site (18 in `0x0057xxxx`, all inside
|
||||||
|
`StartupTurbineChatSystem` plus the two hash-table helpers themselves; 116 in
|
||||||
|
`0x0058xxxx`, all inside `InitializeCommands`).
|
||||||
|
|
||||||
|
Dispatch is `ClientCommunicationSystem::OnChatCommand @ 0x00581320` →
|
||||||
|
`ClientCommunicationSystem::DoCommand @ 0x0057E2E0`. `OnChatCommand` switches on
|
||||||
|
`firstChar - 0x2F`: `'/'` (case 0) is **rewritten in place to `'@'`** and falls
|
||||||
|
into `DoCommand`; `'@'` (case 0x11) enters `DoCommand` directly; `':'` and `';'`
|
||||||
|
(cases 0x0B/0x0C) have their first character replaced with a space and the whole
|
||||||
|
line prefixed with the literal `"@emote"` before `DoCommand` — that is retail's
|
||||||
|
emote shorthand. Anything else routes to `PublicChat` / talk-focus.
|
||||||
|
`DoCommand` splits the line on `" \t"` (`PSUtils::FindAllWords`, delimiter set at
|
||||||
|
`0x007E0F5C`), takes `words[0].substring(1)` as the verb, **right-trims `','`**
|
||||||
|
from it (trim char set `0x0079452C`, `trim(left=0, right=1)`) so `@f, hello`
|
||||||
|
works, then looks the verb up. If the entry is absent **or its `func` is NULL**,
|
||||||
|
it calls `ClientCommunicationSystem::DoChannelCommand @ 0x005774A0`, which tries
|
||||||
|
`ChannelSystem::GetChannelID @ 0x005CF1F0` on the verb and, on a hit, sends
|
||||||
|
`CM_Communication::Event_ChannelBroadcast(id, joinedArgs)`. Only if *that* also
|
||||||
|
fails does the client fall back to `CM_Communication::Event_Talk` with the
|
||||||
|
**original `@`-prefixed line** — which is exactly the passthrough ACE relies on
|
||||||
|
(`references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionTalk.cs:21`,
|
||||||
|
`if (message.StartsWith("@"))` → `CommandManager`). A registered handler that
|
||||||
|
returns 0 raises `HandleFailureEvent(0x26, "")`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Complete retail command table
|
||||||
|
|
||||||
|
One row per handler. `A` in the Turbine column = the row exists only after
|
||||||
|
`StartupTurbineChatSystem`. acdream paths are relative to the repo root.
|
||||||
|
|
||||||
|
### 2.1 Help / group nodes
|
||||||
|
|
||||||
|
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `help` | `?` | `DoHelp @ 0x0057F9E0` | `[command\|group]` | No args: prints the group index + "Note: You may substitute a forward slash (/) for the at symbol (@)." With an arg: looks the verb up and calls its `help` fn. | **PARTIAL** — `ChatCommandRouter.TryHandleLocalPresentationCommand` (`src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs:96`) prints one flat blob for `/help`, `/?`, `/h`. No `@help <command>`, no group topics. `/h` is an acdream invention (not a retail verb). |
|
||||||
|
| `commands` | — | *(NULL func)*, help `HelpAllGroup @ 0x0057E7F0` | — | Help-topic node only. Typing it alone falls through to channel lookup then server. | MISSING |
|
||||||
|
| `allegiances` | — | *(NULL func)*, `HelpAllegiancesGroup @ 0x0057D4C0` | — | Help topic. | MISSING |
|
||||||
|
| `channels` | — | *(NULL func)*, `HelpChannelsGroup @ 0x005773E0` | — | Help topic. | MISSING |
|
||||||
|
| `chatting` | — | *(NULL func)*, `HelpChattingGroup @ 0x0057B1A0` | — | Help topic. | MISSING |
|
||||||
|
| `death` | — | *(NULL func)*, `HelpDeathGroup @ 0x0057B8B0` | — | Help topic. | MISSING |
|
||||||
|
| `status` | — | *(NULL func)*, `HelpStatusGroup @ 0x0057C410` | — | Help topic. | MISSING |
|
||||||
|
| `text` | — | *(NULL func)*, `HelpTextGroup @ 0x0057C6C0` | — | Help topic. | MISSING |
|
||||||
|
|
||||||
|
### 2.2 Local chat routing
|
||||||
|
|
||||||
|
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `say` | `s` | `DoSay @ 0x00581240` | `<text>` | Joins + trims args, `PublicChat`. Empty → "You must specify the text you wish to say." | IMPLEMENTED-EQUIVALENT — `ChatInputParser` `SayAliases = {"/say","/s"}` (`ChatInputParser.cs:41`). Not a catalog command; routed as `SendChatCmd(Say)`. |
|
||||||
|
| `tell` | `t`, `send`, `whisper`, `w` | `DoTell @ 0x00577E40` | `<name>, <text>` | Splits on the **first comma**; name = left, text = right. Sets `SetLastTelleeName`, sends `CM_Communication::Event_TalkDirectByName`. Retail help is explicit: "you must put a comma after the character's name." | **PARTIAL** — `ChatInputParser.TellAliases = {"/tell","/t"}` only; `send`/`whisper`/`w` MISSING. acdream splits on **whitespace first** and merely trims a trailing comma off the target (`ChatInputParser.cs:208-220`), so a multi-word name (`/t Lord Gnarly, hi`) resolves the wrong target where retail resolves it correctly. |
|
||||||
|
| `reply` | `r`, `rp` | `DoReply @ 0x00577910` | `<text>` | Sends `Event_TalkDirect` to `gmCCommunicationSystem::GetLastTeller()` (a **guid**, not a name). No teller → "Someone must @tell you first!" | **PARTIAL/DIVERGENT** — `ReplyAliases = {"/reply","/r"}`; `rp` is MISSING **and is bound to Roleplay in acdream** (`ChatInputParser.cs:78`). acdream replies by *name*, retail by guid. |
|
||||||
|
| `mr` | — | **NULL func**, help `HelpReply @ 0x00577A50` | `<text>` | Documented as "reply to the last person who @m'd you (monarchs only)" but the 2013 build registers it with a **null function pointer** (verified at `0x00583041`: `xor edi,edi; xor ebp,ebp` before the ctor call — arg3 is 0). It therefore falls through to channel lookup (miss) and is sent to the server as text. A retail defect, not an acdream one. | MISSING (and **do not** implement client-side — retail does not) |
|
||||||
|
| `pr` | — | **NULL func**, help `HelpReply` | `<text>` | Same as `mr` (verified at `0x005830C1`). | MISSING (same note) |
|
||||||
|
| `retell` | `rt` | `DoReTell @ 0x00577BD0` | `<text>` | Re-sends to the last person **you** tell'd. | **PARTIAL** — `RetellAliases = {"/retell"}`; `rt` MISSING. |
|
||||||
|
| `chat` | — | `DoChatToggle @ 0x0056FAD0` | `on\|off` | `on` → `Event_ModifyGlobalSquelch(remove, 2)`; `off` → `Event_ModifyGlobalSquelch(add, 2)` — i.e. it is a global **Speech** filter, not a separate flag. | MISSING (cheap: acdream already has `ModifyGlobalSquelch`) |
|
||||||
|
| `notell` | — | `DoNoTell @ 0x0056FBD0` | `on\|off` | Same mechanism with message type **3 (Tell)**. | MISSING (same) |
|
||||||
|
| `join` | — | `DoJoinChat @ 0x0056F510` | `<Allegiance\|General\|Trade\|LFG\|Roleplay\|Society\|Soc>` | Sets the matching `PlayerModule::SetHear*Chat(true)` Turbine-room flag. | MISSING |
|
||||||
|
| `leave` | — | `DoLeaveChat @ 0x0056F7F0` | same tags | Clears the matching flag. | MISSING |
|
||||||
|
| `index` | — | `DoChannelIndex @ 0x0056E640` | — | `CM_Communication::Event_ChannelIndex()`. ACE: `GameActionType.IndexChannels`. | MISSING |
|
||||||
|
| `clist` | — | `DoChannelList @ 0x0057A9B0` | `<channel>` | `GetChannelID` then `Event_ChannelList(id)`; bad tag → WeenieError `0x422`; no arg → "Please specify the channel name." ACE: `ListChannels`. | MISSING |
|
||||||
|
| `on` | — | `DoChannelOn @ 0x0057AA80` | `<channel>` | `Event_AddToChannel(id)`. ACE: `AddChannel`. | MISSING |
|
||||||
|
| `off` | — | `DoChannelOff @ 0x0057AB50` | `<channel>` | `Event_RemoveFromChannel(id)`. ACE: `RemoveChannel`. | MISSING |
|
||||||
|
| `title` | — | `DoTitle @ 0x0057A640` | `<text>` | Sets the popup chat window's title. Pure local UI. | MISSING |
|
||||||
|
| `log` | — | `DoSetOutput @ 0x0057E4F0` | `[filename]` | Toggles chat-to-file logging; appends `.txt`; "Copying chat to %s. Run command again with no arguments to turn off logging." | MISSING |
|
||||||
|
| `clear` | — | `DoClear @ 0x0056E600` | `[all]` | IMPLEMENTED | **IMPLEMENTED** — `RetailClientCommandCatalog.cs:115`, `ClientCommandController.cs:165`. |
|
||||||
|
| `filter` / `unfilter` | — | `DoFilter @ 0x0057C860` / `DoUnFilter @ 0x0057C880` | `-<type>` | IMPLEMENTED | **IMPLEMENTED** — `ClientCommandController.ExecuteGlobalFilter`. |
|
||||||
|
| `messagetypes` | `message_types`, `msgtypes`, `msg_types` | `DoMessageTypes @ 0x0057A010` | — | Lists squelch/filter categories. | **PARTIAL** — only `messagetypes` registered (`RetailClientCommandCatalog.cs:251`); the three underscore/short aliases are MISSING. |
|
||||||
|
| `loadfile` | — | `DoLoadFile @ 0x00581870` / `LoadFile @ 0x00581710` | `<file>` | `fopen` the file, then feed every line back through `OnChatCommand` after `MakeLoadFileVariableSubstitutions`. A script player. No arg → "You must provide a file name."; open failure → "Cannot open file %hs". | MISSING (see §3 risk note) |
|
||||||
|
|
||||||
|
### 2.3 Chat channels (`DoStupidChannelHack @ 0x0057B130` → `DoChannelCommand`)
|
||||||
|
|
||||||
|
All of these are one handler: no args → "You must specify the text you wish to
|
||||||
|
say."; otherwise `ChannelSystem::GetChannelID(verb)` then
|
||||||
|
`CM_Communication::Event_ChannelBroadcast(id, text)`.
|
||||||
|
|
||||||
|
| Channel | Registered verbs | Channel id | acdream status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Allegiance | `a`, `ab` (also `allegiance`/`all` **as a command**, see §2.4) | `0x02000000` | `/a` IMPLEMENTED-EQUIVALENT (`ChatInputParser.cs:63`). `ab` MISSING. **`/allegiance` is bound to the Allegiance channel in acdream but is a COMMAND in retail** — divergence. |
|
||||||
|
| Co-vassals | `co-vassals`, `covassals`, `covassal`, `c` | `0x01000000` | `covassals` OK; `c`, `covassal`, `co-vassals` MISSING. acdream's `/cv` is an invention. |
|
||||||
|
| Monarch | `monarch`, `m` | `0x4000` | IMPLEMENTED-EQUIVALENT |
|
||||||
|
| Patron | `patron`, `p` | `0x2000` | IMPLEMENTED-EQUIVALENT |
|
||||||
|
| Vassals | `vassals`, `vassal`, `v` | `0x1000` | `vassals`, `v` OK; `vassal` MISSING. |
|
||||||
|
| Fellowship | `fellowship`, `fellows`, `fellow`, `f`, `group`, `g`, `party` | `0x800` | **DIVERGENT** — retail's `g` and `group` and `party` are **Fellowship**. acdream maps `/g` to **General** (`ChatInputParser.cs:57`). `fellows`, `group`, `party` MISSING. |
|
||||||
|
|
||||||
|
`ChannelSystem::GetChannelID` also resolves 22 tags that are **not** in the
|
||||||
|
command table. Because `DoCommand` falls through to `DoChannelCommand` for any
|
||||||
|
unregistered verb, these still work as channel broadcasts:
|
||||||
|
|
||||||
|
| Tags | Channel id |
|
||||||
|
|---|---|
|
||||||
|
| `av`, `av1`, `advocate`, `advocate1` | `0x08` |
|
||||||
|
| `av2`, `advocate2` | `0x10` |
|
||||||
|
| `av3`, `advocate3` | `0x20` |
|
||||||
|
| `abuse` | `0x01` |
|
||||||
|
| `ad`, `admin` | `0x02` |
|
||||||
|
| `au`, `audit` | `0x04` |
|
||||||
|
| `sent`, `sentinel` | `0x200` |
|
||||||
|
| `celestialhand`, `celhan` | `0x08000000` |
|
||||||
|
| `eldrytchweb`, `eldweb` | `0x10000000` |
|
||||||
|
| `radiantblood`, `radblo` | `0x20000000` |
|
||||||
|
| `ol` (and `olthoi` pre-Turbine) | `0x40000000` |
|
||||||
|
| `help` | `0x400` — **explicitly rejected** by `DoChannelCommand` at `0x005774FD` (`id == 0 || id == 0x400` → return 0) |
|
||||||
|
|
||||||
|
All 22 are SERVER-PASSTHROUGH in acdream today (they reach ACE as `@abuse …`
|
||||||
|
text rather than a channel broadcast).
|
||||||
|
|
||||||
|
### 2.4 Turbine Chat (registered only by `StartupTurbineChatSystem @ 0x0057EFB0`)
|
||||||
|
|
||||||
|
Each sends `ClientCommunicationSystem::SendTurbineChat @ 0x0057DB10` with the
|
||||||
|
matching `ChatTypeEnum` and the player's `PlayerModule::Hear*Chat` gate.
|
||||||
|
|
||||||
|
| Verb | Aliases | Handler @addr | acdream status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `a` | `guild`, `gu` | `DoTurbineChat_Allegiance @ 0x0057EBA0` | `/a` routed as a plain Allegiance channel message; `guild`/`gu` MISSING |
|
||||||
|
| `general` | `cg` | `DoTurbineChat_General @ 0x0057EC50` | `general` OK; `cg` MISSING. acdream's `/gen` is an invention. |
|
||||||
|
| `trade` | `ct` | `DoTurbineChat_Trade @ 0x0057ECE0` | `trade` OK; `ct` MISSING. `/tr` is an invention. |
|
||||||
|
| `lfg` | `clfg` | `DoTurbineChat_LFG @ 0x0057ED70` | `lfg` OK; `clfg` MISSING. `/lookingforgroup` is an invention. |
|
||||||
|
| `roleplay` | `crp` | `DoTurbineChat_Roleplay @ 0x0057EE00` | `roleplay` OK; `crp` MISSING. `/role` and `/rp` are inventions (`/rp` collides with retail's reply alias). |
|
||||||
|
| `society` | `soc` | `DoTurbineChat_Society @ 0x0057EF20` | `society` OK; `soc` MISSING |
|
||||||
|
| `olthoi` | `o` | `DoTurbineChat_Olthoi @ 0x0057EE90` | `olthoi` OK; `o` MISSING |
|
||||||
|
|
||||||
|
### 2.5 Allegiance management
|
||||||
|
|
||||||
|
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `allegiance` | `all` | `DoAllegiance @ 0x0057D5A0` | `<sub> [args]` | Subcommand dispatcher. Verbs read out of the handler: `boot` (`DoAllegianceBoot @ 0x0057AEF0`), `info` (`DoAllegianceInfo @ 0x00576560`), `chat`/`ch` (`DoAllegianceChat @ 0x00575CB0`), `broadcast`/`br` (`DoAllegianceBroadcast @ 0x005761F0`), `ban` (`DoAllegianceBan @ 0x005762A0`), `officer` (`DoAllegianceOfficer @ 0x00576650`), `title` (`DoAllegianceOfficerTitle @ 0x00576A10`), `hometown`/`ho` (`DoAllegianceHometown @ 0x0056EF10`), `motd` (`DoMotd @ 0x00577150`), `name` (`DoAllegianceName @ 0x00576C80`), `lock` (`DoAllegianceLock @ 0x00576E70`), `house` (`DoAllegianceHouse @ 0x0056EF70`). | MISSING (and `/allegiance` currently mis-bound as a chat channel) |
|
||||||
|
| `ab` | — | `DoAllegianceBroadcast @ 0x005761F0` | `<text>` | Monarch broadcast to the whole allegiance. | MISSING |
|
||||||
|
| `alh` | `ah` | `DoAllegianceHometown @ 0x0056EF10` | — | ACE: `GameActionType.RecallAllegianceHometown`. | MISSING |
|
||||||
|
| `motd` | — | `DoMotd @ 0x00577150` | `[set <text>\|clear]` | Displays, sets (monarch-only), or clears the allegiance MOTD. | MISSING |
|
||||||
|
| `speaker` | — | `DoSpeaker @ 0x0057DAB0` | — | Prints exactly "This command is no longer in use, please see @allegiance officer.\n". Pure local. | MISSING (trivial) |
|
||||||
|
|
||||||
|
### 2.5b Housing
|
||||||
|
|
||||||
|
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `house` | `hou` | `DoHouse @ 0x00580860` | `<sub> [args]` | Subcommand verbs read out of the handler: `open`, `close`, `recall`/`re`, `mansion_recall`/`alleg_recall`/`ma`, `storage` (`DoHouseStorage @ 0x00579560`), `remove`, `boot` (`DoHouseBoot @ 0x00579970`), `boot_all`, `remove_all`, `guest` (`DoHouseGuests @ 0x005791A0`), `abandon`, `available`, `hooks`, `on`, `off`. | **PARTIAL** — `RetailClientCommandCatalog.TryMatch` (`RetailClientCommandCatalog.cs:279-301`) recognizes only `recall`, `mansion_recall`, `alleg_recall`. `hou`, `re`, `ma` and all 12 other subcommands are MISSING, **and the catalog swallows them locally** with an invalid-args message instead of letting them reach ACE. |
|
||||||
|
| `hor` | `hr` | `DoHouseRecall @ 0x00570450` | — | IMPLEMENTED | **IMPLEMENTED** |
|
||||||
|
| `hom` | `hoa` | `DoMansionRecall @ 0x005704B0` | — | IMPLEMENTED | **IMPLEMENTED** |
|
||||||
|
| `hslist` | — | `DoHouseAvailableList @ 0x00570510` | `<apartment\|cottage\|villa\|mansion>` | Lists available houses. ACE: `GameActionType.ListAvailableHouses`. | MISSING |
|
||||||
|
|
||||||
|
### 2.6 Death / recall / PK
|
||||||
|
|
||||||
|
| Verb | Aliases | Handler @addr | acdream status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `lifestone` | `lif`, `ls` | `DoLifestone @ 0x0056FC70` | **IMPLEMENTED** |
|
||||||
|
| `marketplace` | `mar`, `mp` | `DoMarketplace @ 0x0056FCE0` | **IMPLEMENTED** |
|
||||||
|
| `pkarena` | `pka` | `DoPKArena @ 0x005788D0` | **IMPLEMENTED** |
|
||||||
|
| `pklarena` | `pla` | `DoPKLArena @ 0x005789D0` | **IMPLEMENTED** |
|
||||||
|
| `pklite` | `pkl` | `DoPKLite @ 0x0057A490` | **PARTIAL** — `pkl` alias MISSING (`RetailClientCommandCatalog.cs:218` registers only `pklite`) |
|
||||||
|
| `die` | — | `DoDie @ 0x00580050` | **IMPLEMENTED** |
|
||||||
|
| `corpse` | `cor` | `DoCorpse @ 0x00578220` | **IMPLEMENTED** |
|
||||||
|
| `consent` | — | `DoConsent @ 0x0057DDA0` | **IMPLEMENTED** |
|
||||||
|
| `permit` | — | `DoPermit @ 0x005785A0` | `add <name>` / `remove <name>` — grants/revokes corpse-loot permission. ACE: `AddPlayerPermission` / `RemovePlayerPermission`. **MISSING** |
|
||||||
|
|
||||||
|
### 2.7 Status / display
|
||||||
|
|
||||||
|
| Verb | Aliases | Handler @addr | Args | Behavior | acdream status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `age` | — | `DoAge @ 0x0057C5A0` | — | **IMPLEMENTED** |
|
||||||
|
| `birth` | — | `DoBirth @ 0x0056E5F0` | — | **IMPLEMENTED** |
|
||||||
|
| `day` | — | `DoDay @ 0x005706F0` | — | Toggles `LScape::m_fAlwaysDaylight` via `LScape::SetDay`, persists with `PlayerModule::SetPersistentAtDay`, echoes "Let there be light!" when enabling. Pure local. | MISSING |
|
||||||
|
| `endurance` | — | `DoEndurance @ 0x0057C5F0` | — | Prints a fixed paragraph beginning "The endurance attribute has a nu…". Pure local text. | MISSING (trivial) |
|
||||||
|
| `framerate` | — | `DoFrameRate @ 0x005707D0` | — | **IMPLEMENTED** |
|
||||||
|
| `loc` | — | `DoLoc @ 0x0057A250` | — | **IMPLEMENTED** |
|
||||||
|
| `version` | — | `DoVersion @ 0x0057E1B0` | — | Prints "Client version %s\n"; additionally prints "Using Turbine Chat.\n" when `IsUsingTurbineChat()`, and extra lines when `PlayerIsPSR()`. | **PARTIAL** — `ClientCommandController.cs:136` prints the version line only. |
|
||||||
|
| `render` | — | `DoRenderOption @ 0x0057E120` | `[options]` | Forwards to `SmartBox::HandleRenderOption`, prints its two out-strings. Pure local dev/render toggle. | MISSING |
|
||||||
|
|
||||||
|
### 2.8 Interface layout, emotes, social, components
|
||||||
|
|
||||||
|
| Verb | Aliases | Handler @addr | acdream status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `saveui` / `loadui` / `saveautoui` / `loadautoui` / `lockui` | — | `0x0056FFF0` / `0x00570150` / `0x005702B0` / `0x00570330` / `0x005703B0` | **IMPLEMENTED** |
|
||||||
|
| `emote` | `e`, `em`, `me` | `DoEmote @ 0x00578AD0` | **IMPLEMENTED** |
|
||||||
|
| `emotes` | — | `DoEmoteList @ 0x0057BB30` | **IMPLEMENTED** |
|
||||||
|
| `afk` | — | `DoAFK @ 0x0057B3F0` | **IMPLEMENTED** |
|
||||||
|
| `friends` / `friends_add` / `friends_remove` | — | `0x0057BC00` / `0x00578FB0` / `0x00579080` | **IMPLEMENTED** |
|
||||||
|
| `squelch` / `unsquelch` | — | `0x0057BF50` / `0x0057C070` | **IMPLEMENTED** |
|
||||||
|
| `fillcomps` | — | `DoFillComponents @ 0x0056FD50` | **IMPLEMENTED** |
|
||||||
|
|
||||||
|
### 2.9 Prefixes (not verbs)
|
||||||
|
|
||||||
|
| Prefix | Site | Behavior | acdream status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` | `OnChatCommand @ 0x00581431` | Rewritten to `@`, then dispatched. | **IMPLEMENTED** — `RetailClientCommandCatalog.TryMatch` accepts both (`RetailClientCommandCatalog.cs:267`). |
|
||||||
|
| `@` | `OnChatCommand @ 0x00581444` | Dispatched directly. | **IMPLEMENTED** |
|
||||||
|
| `:` , `;` | `OnChatCommand @ 0x0058144D` | First char replaced with a space, line prefixed with `"@emote"`, then dispatched — `:waves` ≡ `@emote waves`. | **MISSING** |
|
||||||
|
| trailing `,` on the verb | `DoCommand @ 0x0057E3CD` | Right-trimmed, so `@f, hi` ≡ `@f hi`. | **MISSING** — acdream's verb token keeps the comma, so `/f, hi` does not match any verb and is shipped to ACE as `@f,`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Prioritized missing-command list
|
||||||
|
|
||||||
|
Effort key: **L** = pure-local (no wire message); **W-have** = wire path already
|
||||||
|
exists in acdream; **W-new** = needs a new game action.
|
||||||
|
|
||||||
|
### Tier 1 — correctness bugs in what already ships (do these first)
|
||||||
|
|
||||||
|
1. **`/g` is bound to General; retail binds it to Fellowship** (`0x800`).
|
||||||
|
`ChatInputParser.cs:57`. One-line fix, but it silently sends fellowship
|
||||||
|
chatter to a global channel today. **L**
|
||||||
|
2. **`/rp` is bound to Roleplay; retail binds it to `reply`.** `ChatInputParser.cs:78`.
|
||||||
|
Same class of bug (a private reply becomes a global broadcast). **L**
|
||||||
|
3. **`/allegiance <text>` is bound to the Allegiance channel; retail's
|
||||||
|
`allegiance`/`all` is the allegiance *management command*.** Channel verbs are
|
||||||
|
`a`/`ab`/`guild`/`gu`. **L**
|
||||||
|
4. **`/house <anything-but-recall>` is swallowed locally.** `RetailClientCommandCatalog.cs:288-296`
|
||||||
|
returns a match with `HasValidArguments:false` for every unrecognized
|
||||||
|
subcommand, so `@house open`, `@house guest add X`, `@house abandon` never
|
||||||
|
reach ACE. Until `DoHouse` is ported, unrecognized `house` subcommands must
|
||||||
|
fall through to `SendServerCommandCmd`. **L**
|
||||||
|
5. **Verb-trailing-comma trim.** `@f, hi` / `@t Bob, hi` — retail right-trims `,`
|
||||||
|
off the verb in `DoCommand`. **L**
|
||||||
|
6. **`/tell` splits on the first comma, not the first space.** `ChatInputParser.cs:208`.
|
||||||
|
Multi-word names break today. **L**
|
||||||
|
|
||||||
|
### Tier 2 — pure-local commands, no wire work
|
||||||
|
|
||||||
|
7. `:` / `;` emote prefixes (`OnChatCommand` case 0x0B/0x0C). **L**
|
||||||
|
8. `@day` — daylight toggle + `SetPersistentAtDay`. **L**
|
||||||
|
9. `@endurance` — fixed help paragraph. **L**
|
||||||
|
10. `@speaker` — fixed deprecation line. **L**
|
||||||
|
11. `@title <text>` — chat window title. **L**
|
||||||
|
12. `@log [file]` — chat-to-file logging. **L**
|
||||||
|
13. `@version` — add the "Using Turbine Chat." line. **L**
|
||||||
|
14. `@render` — `SmartBox::HandleRenderOption` equivalent (acdream has no SmartBox;
|
||||||
|
map to the existing quality/debug toggles or leave as a documented divergence). **L**
|
||||||
|
15. `@help <command>` / `@help <group>` + the 7 group nodes (`commands`,
|
||||||
|
`allegiances`, `channels`, `chatting`, `death`, `status`, `text`). The exact
|
||||||
|
retail help strings for all 57 `Help*` functions are recoverable from the
|
||||||
|
binary — see §4 for the extraction recipe. **L**
|
||||||
|
16. **Missing aliases on already-implemented commands** (one dictionary edit
|
||||||
|
each): `pkl`, `hou`, `message_types`, `msgtypes`, `msg_types`, `rt`, `send`,
|
||||||
|
`whisper`, `w`, `vassal`, `covassal`, `co-vassals`, `c`, `fellows`, `group`,
|
||||||
|
`party`, `guild`, `gu`, `cg`, `ct`, `clfg`, `crp`, `soc`, `o`, `ab`.
|
||||||
|
Also delete the non-retail inventions `gen`, `cv`, `lookingforgroup`, `tr`,
|
||||||
|
`role`, `h`. **L**
|
||||||
|
|
||||||
|
### Tier 3 — wire messages acdream already has
|
||||||
|
|
||||||
|
17. `@chat on|off` and `@notell on|off` — both are `ModifyGlobalSquelch` with
|
||||||
|
message types 2 and 3. acdream already wires `ModifyGlobalSquelch` for
|
||||||
|
`/filter`. **W-have**
|
||||||
|
18. `@join` / `@leave <room>` — set `PlayerModule::SetHear*Chat`; these ride the
|
||||||
|
existing character-options path. **W-have**
|
||||||
|
|
||||||
|
### Tier 4 — new game actions (ACE-side handlers all exist)
|
||||||
|
|
||||||
|
19. `@permit add|remove <name>` — ACE `AddPlayerPermission` / `RemovePlayerPermission`. **W-new**
|
||||||
|
20. `@hslist <type>` — ACE `ListAvailableHouses`. **W-new**
|
||||||
|
21. `@index` / `@clist` / `@on` / `@off` — ACE `IndexChannels`, `ListChannels`,
|
||||||
|
`AddChannel`, `RemoveChannel`. **W-new** (four small parameterless/one-dword actions)
|
||||||
|
22. `@allegiance <sub>` — the 12-subcommand dispatcher. ACE handlers exist for
|
||||||
|
every one (`AllegianceInfoRequest`, `AllegianceChatBoot`, `AddAllegianceBan`,
|
||||||
|
`SetAllegianceOfficer`, `SetAllegianceOfficerTitle`, `RecallAllegianceHometown`,
|
||||||
|
`SetAllegianceName`, `DoAllegianceLockAction`, `DoAllegianceHouseAction`, …).
|
||||||
|
Largest single item; deserves its own slice. **W-new**
|
||||||
|
23. `@house <sub>` — 15 subcommands; ACE handlers exist (`SetOpenHouseStatus`,
|
||||||
|
`ChangeStoragePermission`, `BootSpecificHouseGuest`, `HouseBootAll`,
|
||||||
|
`AbandonHouse`, `HouseQuery`, `ModifyAllegianceGuestPermission`, …). **W-new**
|
||||||
|
24. `@motd [set|clear]` — allegiance MOTD. **W-new**
|
||||||
|
25. `@ab` — allegiance broadcast. **W-new**
|
||||||
|
26. `@alh` / `@ah` — `RecallAllegianceHometown`. **W-new**
|
||||||
|
27. Turbine-chat verbs as *Turbine* sends (`SendTurbineChat`) rather than plain
|
||||||
|
`ChannelBroadcast`. acdream already ships the 0xF7DE TurbineChat path, so
|
||||||
|
this is a routing decision, not new wire work. **W-have**
|
||||||
|
|
||||||
|
### Explicitly do NOT implement
|
||||||
|
|
||||||
|
- `@mr`, `@pr` — registered with a **null function pointer** in the 2013 build.
|
||||||
|
Retail ships them as help text only; the command itself falls through to the
|
||||||
|
server. Implementing them client-side would be a divergence.
|
||||||
|
- The 22 fallback-only channel tags (`abuse`, `admin`, `audit`, `advocate*`,
|
||||||
|
`sentinel`, `celhan`, `eldweb`, `radblo`, `ol`, …) are GM/faction channels.
|
||||||
|
They are reachable in retail only through `DoChannelCommand`'s fallback; the
|
||||||
|
cheapest faithful port is to add the `GetChannelID` table and let the existing
|
||||||
|
unknown-verb path consult it before falling back to `SendServerCommandCmd`.
|
||||||
|
- `@loadfile` — a client-side script player that re-enters `OnChatCommand` for
|
||||||
|
every line of an arbitrary file. Faithful, but it is a scripting surface;
|
||||||
|
acdream already has a designed plugin API for that, so porting `@loadfile`
|
||||||
|
should be an explicit product decision, not a parity checkbox.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Counts
|
||||||
|
|
||||||
|
| Measure | Count |
|
||||||
|
|---|---|
|
||||||
|
| Verbs registered by `InitializeCommands @ 0x00581970` | **116** |
|
||||||
|
| Verbs added by `StartupTurbineChatSystem @ 0x0057EFB0` | 15 (14 net-new; `a` is replaced) |
|
||||||
|
| **Total registered verbs** | **130** |
|
||||||
|
| …of which have a **null** `func` (help-only nodes) | 9 (`commands`, `allegiances`, `channels`, `chatting`, `death`, `status`, `text`, `mr`, `pr`) |
|
||||||
|
| Distinct handler functions behind those verbs | 68 (61 base + 7 Turbine) |
|
||||||
|
| Additional verbs reachable via the `GetChannelID` fallback (unregistered) | 22 |
|
||||||
|
| **Total client-parsed verbs** | **152** |
|
||||||
|
| Prefixes with special parsing | 4 (`/`, `@`, `:`, `;`) |
|
||||||
|
|
||||||
|
acdream, against the 130 registered verbs. Every verb is in exactly one row, so
|
||||||
|
the column sums to 130.
|
||||||
|
|
||||||
|
| Status | Verbs | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| IMPLEMENTED — typed `ExecuteClientCommandCmd` | **45** (33 `ClientCommandId` values) | `RetailClientCommandCatalog.cs` + `ClientCommandController.cs` |
|
||||||
|
| PARTIAL via typed catalog | **1** — `house` (3 of 15 subcommands, and it swallows the rest) | `RetailClientCommandCatalog.cs:279` |
|
||||||
|
| IMPLEMENTED-EQUIVALENT — chat alias → `SendChatCmd` | **21** | `ChatInputParser.cs` |
|
||||||
|
| PARTIAL via chat alias (argument shape wrong) | **3** — `tell` (space-split, not comma-split), `reply` (by name, not guid), `retell` | `ChatInputParser.cs` |
|
||||||
|
| PARTIAL via local presentation | **2** — `help`, `?` (flat blob; no per-command or group help) | `ChatCommandRouter.cs:96` |
|
||||||
|
| DIVERGENT — bound, wrong target | **3** — `g`→General (retail Fellowship), `rp`→Roleplay (retail reply), `allegiance`→channel (retail command) | `ChatInputParser.cs` |
|
||||||
|
| **MISSING** | **55** | — |
|
||||||
|
| **Total registered** | **130** | |
|
||||||
|
|
||||||
|
Plus **22** unregistered `GetChannelID` fallback tags, all MISSING → **77**
|
||||||
|
client-parsed verbs unimplemented out of 152.
|
||||||
|
|
||||||
|
Reading note: the §2 tables mark `messagetypes`, `pklite` and `version` PARTIAL
|
||||||
|
because an *alias* or an *output line* is missing. The counts above are
|
||||||
|
per-verb, so those three verbs sit in IMPLEMENTED while their missing aliases
|
||||||
|
(`message_types`, `msgtypes`, `msg_types`, `pkl`) are counted as four separate
|
||||||
|
MISSING verbs.
|
||||||
|
|
||||||
|
Everything not in the table above reaches ACE correctly as
|
||||||
|
`SendServerCommandCmd` → Talk (`ChatCommandRouter.cs:62`), which matches retail's
|
||||||
|
own final fallback (`DoCommand` → `Event_Talk`) except that retail consults the
|
||||||
|
channel-tag table first.
|
||||||
|
|
||||||
|
acdream verbs with **no retail counterpart** (candidates for removal):
|
||||||
|
`/h`, `/gen`, `/cv`, `/lookingforgroup`, `/tr`, `/role`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Reproduction recipes
|
||||||
|
|
||||||
|
Verb table (authoritative, resolves BN's mislabeled pooled strings):
|
||||||
|
|
||||||
|
```
|
||||||
|
# scan InitializeCommands for `push imm32` operands that point at short
|
||||||
|
# ASCII strings in .rdata — one per registered verb, in registration order
|
||||||
|
python pushes.py 581970 585520 # 116 verbs
|
||||||
|
python pushes.py 57efb0 57f9e0 # 15 Turbine verbs
|
||||||
|
```
|
||||||
|
|
||||||
|
Handler pairing: `sed -n '396604,399712p' acclient_2013_pseudo_c.txt` and read
|
||||||
|
the `CmdHashData::CmdHashData(ptr, &name, <func>, nullptr)` call (later blocks)
|
||||||
|
or the `*(esi + 8) = <func>` / `*(esi + 0x10) = <help>` stores (first ~22
|
||||||
|
blocks — BN renders the same code two different ways).
|
||||||
|
|
||||||
|
Exact retail help text for all 57 `Help*` functions: scan each
|
||||||
|
`ClientCommunicationSystem::Help*` symbol's byte extent for `push imm32` into
|
||||||
|
`.rdata` and read the C string. This recovered, verbatim, every usage line and
|
||||||
|
group listing quoted in §2 (e.g. the `@tell` comma requirement, the `@afk msg`
|
||||||
|
192-character limit, the `@hslist` house types).
|
||||||
|
|
||||||
|
`ChannelSystem::GetChannelID @ 0x005CF1F0`: alternate `push imm32` (tag) and
|
||||||
|
`mov eax, imm32` (channel id) down the function; the id immediately precedes the
|
||||||
|
next tag group.
|
||||||
|
|
||||||
|
Cross-checks performed: ACE `GameActionTalk.cs:21` (the `@`-passthrough
|
||||||
|
contract) and the `GameActionType` enumeration (confirms server-side handlers
|
||||||
|
exist for every Tier-4 item). holtburger's `commands.rs` was consulted and
|
||||||
|
**rejected as an oracle for aliases** — it binds `/g` to Allegiance and `/p` to
|
||||||
|
Fellowship, which matches neither retail nor acdream.
|
||||||
931
docs/research/2026-08-09-chat-retail-interface-text.md
Normal file
931
docs/research/2026-08-09-chat-retail-interface-text.md
Normal file
|
|
@ -0,0 +1,931 @@
|
||||||
|
# Retail's transient on-screen "interface text" — the SpewBox
|
||||||
|
|
||||||
|
**Date:** 2026-08-09
|
||||||
|
**Status:** RESEARCH ONLY. No production code changed.
|
||||||
|
**Oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
|
||||||
|
build, PDB-named), `docs/research/named-retail/symbols.json`,
|
||||||
|
`docs/research/named-retail/acclient.h`, plus byte-level string recovery from the
|
||||||
|
PDB-paired binary `C:\Users\erikn\Downloads\acclient.exe` (v11.4186, CodeView GUID
|
||||||
|
`9e847e2f-777c-4bd9-886c-22256bb87f32`). Server-side cross-check against
|
||||||
|
`references/ACE/` and `references/holtburger/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
The system is called the **SpewBox** — `gmSpewBoxUI` @ `0x004D5A30`.
|
||||||
|
|
||||||
|
The routing rule is one line: **`ClientSystem::AddTextToScroll(text, type, ...)`
|
||||||
|
broadcasts one notice to every text sink, and the sinks self-select — the SpewBox
|
||||||
|
takes `type == 0x1A` and nothing else, while every `ChatInterface` window is
|
||||||
|
constructed with a 64-bit type filter that has bit 26 (`1 << 0x1A`) cleared, so
|
||||||
|
`0x1A` is exactly the type the chat window refuses and the SpewBox accepts.**
|
||||||
|
|
||||||
|
**23 client-raised local refusal sites** were found (11 distinct message strings),
|
||||||
|
none of which involve a server round-trip. All of them use type `0x1A`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System identification
|
||||||
|
|
||||||
|
### 1.1 The display element
|
||||||
|
|
||||||
|
| Symbol | Address | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `gmSpewBoxUI::gmSpewBoxUI` | `0x004D5A30` | ctor; derives from `UIElement_Field` + `NoticeHandler` |
|
||||||
|
| `gmSpewBoxUI::Create` | `0x004D5C30` | factory (`operator new(0x610)`) |
|
||||||
|
| `gmSpewBoxUI::Register` | `0x004D5DD0` | `UIElement::RegisterElementClass(0x10000016, gmSpewBoxUI::Create)` |
|
||||||
|
| `gmSpewBoxUI::GetUIElementType` | `0x004D5AA0` | returns `0x10000016` |
|
||||||
|
| `gmSpewBoxUI::PostInit` | `0x004D5AB0` | binds the child ListBox, reads max-items, registers for notice `0x186B6` and global message `3` |
|
||||||
|
| `gmSpewBoxUI::RecvNotice_DisplayFinalStringInfo` | `0x004D60A0` | **the type filter** — `if (arg2 == 0x1A) m_spewBoxPending.AddToEnd(str)` |
|
||||||
|
| `gmSpewBoxUI::Update` | `0x004D5DF0` | drains the pending queue into the ListBox |
|
||||||
|
| `gmSpewBoxUI::ListenToGlobalMessage` | `0x004D6090` | `if (msg == 3) Update()` |
|
||||||
|
| `gmSpewBoxUI::ListenToElementMessage` | `0x004D57C0` | `if (idElement == 0x1000004A && idMessage == 0x10000003) DeleteItem()` — the expiry hook |
|
||||||
|
| `gmSpewBoxUI::~gmSpewBoxUI` | `0x004D5BD0` | unregisters |
|
||||||
|
|
||||||
|
It is registered alongside the rest of the HUD in the element-class registration
|
||||||
|
block at `0x0047A4A6` (`gmClient` init), between `gmSmartBoxUI::Register()` and the
|
||||||
|
`gmFloaty*UI` family.
|
||||||
|
|
||||||
|
### 1.2 The router
|
||||||
|
|
||||||
|
| Symbol | Address | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `ClientSystem::AddTextToScroll(PStringBase<wchar>, uint type, uint8 allowPluginFilter, uint windowId)` | `0x00563C50` | **the single chokepoint** for all player-visible text |
|
||||||
|
| `ClientSystem::AddTextToScroll(PStringBase<char>, ...)` | `0x004C2420` | narrow-string overload → widens → above |
|
||||||
|
| `ClientSystem::AddTextToScroll(char const*, ...)` | `0x00487FC0` | literal overload → widens → above |
|
||||||
|
| `ECM_UI::SendNotice_DisplayFinalStringInfo` | `0x00692550` | broadcast to notice id `0x186B6` |
|
||||||
|
| `ECM_UI::SendNotice_DisplayStringInfo` | `0x006925B0` | broadcast to notice id `0x186A5` |
|
||||||
|
| `ECM_UI::SendNotice_DisplayWeenieError` | `0x00692600` | broadcast to notice id `0x186B7` |
|
||||||
|
| `ClientCommunicationSystem::RecvNotice_DisplayStringInfo` | `0x0056E890` | notice `0x186A5` → `AddTextToScroll` |
|
||||||
|
| `ClientCommunicationSystem::RecvNotice_DisplayWeenieError` | `0x0057E700` | notice `0x186B7` → `HandleFailureEvent` |
|
||||||
|
| `ClientCommunicationSystem::HandleFailureEvent(uint errorId, PStringBase<wchar> param)` | `0x00571990` | **the error-id → text + destination switch** (339 cases) |
|
||||||
|
| `ChatInterface::RecvNotice_DisplayFinalStringInfo` | `0x004F4640` | the chat-window sink |
|
||||||
|
| `ChatInterface::TypeIsActive` | `0x004F2F10` | `(m_llTextTypeFilter >> type) & 1` |
|
||||||
|
| `ChatInterface::ChatInterface` (ctor) | `0x004F4550` | sets the default filter — see §2.2 |
|
||||||
|
| `ChatInterface::BuildChatColorLookupTable` | `0x004F31C0` | per-type chat colors |
|
||||||
|
|
||||||
|
### 1.3 The wire entry points
|
||||||
|
|
||||||
|
| Symbol | Address | Opcode | Behaviour |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ClientCommunicationSystem::Handle_Communication__TextboxString` | `0x0057D3A0` | **`0xF7E0`** (`ServerMessage`) | squelch check, then `AddTextToScroll(text, wireChatType, 1, 0)` — **the wire type decides the destination** |
|
||||||
|
| `ClientCommunicationSystem::Handle_Communication__TransientString` | `0x0057D460` | **`0x02EB`** (GameEvent) | `AddTextToScroll(text, 0x1A, 1, 0)` — **hardcoded to the SpewBox** |
|
||||||
|
| `ClientCommunicationSystem::Handle_Communication__PopUpString` | `0x0057FE80` | **`0x0004`** (GameEvent) | builds a `PropertyCollection` and calls `DialogFactory::MakeDialogInCurrentUI` — **a modal dialog, neither chat nor spew** |
|
||||||
|
| `ClientCommunicationSystem::RecvNotice_DisplayWeenieError` | `0x0057E700` | **`0x028A` / `0x028B`** | → `HandleFailureEvent` → per-id destination |
|
||||||
|
|
||||||
|
Dispatch table sites: `0x0055CA1F` (`0xF7E0`), `0x0055C581` (`0x02EB`),
|
||||||
|
`0x0055B0BD` (event `0x0004`), all inside the `UIQueueManager` message switch that
|
||||||
|
begins at `0x0055B000`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The routing model
|
||||||
|
|
||||||
|
### 2.1 One broadcast, self-selecting sinks
|
||||||
|
|
||||||
|
`ClientSystem::AddTextToScroll` @ `0x00563C50` does, in order:
|
||||||
|
|
||||||
|
1. **Plugin veto.** If `allowPluginFilter != 0` and the plugin API is ready, call
|
||||||
|
`IACPlugin::OnChatWindowText(bstr, type, &eat)`. If the plugin sets `eat`, the
|
||||||
|
message is dropped entirely — it reaches neither chat nor spew.
|
||||||
|
(`0x00563C7D`–`0x00563CB6`.)
|
||||||
|
2. **Trim** trailing whitespace.
|
||||||
|
3. **Censor.** If `PlayerModule::FilterLanguage()`, explode on spaces and replace
|
||||||
|
any word failing `TabooTableAdaptor::CheckCensorsW` with `****`.
|
||||||
|
4. **Branch on type** (`0x00563DE6`):
|
||||||
|
- `type == 0x1A` → **skip the timestamp, skip the chat log file**, jump straight
|
||||||
|
to the broadcast.
|
||||||
|
- otherwise → prepend `%#H:%M:%S ` if `PlayerModule::DisplayTimeStamps()`, and
|
||||||
|
`fprintf` the line to `ClientSystem::s_pLogFile` if a chat log is open.
|
||||||
|
5. **Broadcast** `ECM_UI::SendNotice_DisplayFinalStringInfo(type, mainStr,
|
||||||
|
prefixStr, windowId)`.
|
||||||
|
|
||||||
|
That last call goes to *every* registered handler of notice `0x186B6`. There are
|
||||||
|
exactly two kinds of subscriber:
|
||||||
|
|
||||||
|
- **`gmSpewBoxUI`** (`0x004D60A0`): `if (type == 0x1A) enqueue`. Nothing else, and
|
||||||
|
it ignores `windowId` entirely.
|
||||||
|
- **`ChatInterface`** (`0x004F4640`), one per chat window:
|
||||||
|
```
|
||||||
|
if (windowId == this->m_eWindowID) -> append
|
||||||
|
else if (windowId == 0 && TypeIsActive(type)) -> append
|
||||||
|
else -> ignore
|
||||||
|
```
|
||||||
|
|
||||||
|
So the routing rule is a **type filter on the receiver side**, not a switch on the
|
||||||
|
sender side. There is no "destination" field anywhere in the data.
|
||||||
|
|
||||||
|
The fact that type `0x1A` is *skipped* for timestamping and chat-log-file writing
|
||||||
|
(step 4) is the client author's own statement that `0x1A` is not chat.
|
||||||
|
|
||||||
|
### 2.2 Why `0x1A` never appears in the chat window
|
||||||
|
|
||||||
|
`ChatInterface::ChatInterface` @ `0x004F4550`:
|
||||||
|
|
||||||
|
```
|
||||||
|
0x004F45B8 this->m_llTextTypeFilter = 0xFFFFFFFF; // low dword
|
||||||
|
0x004F45BE ((uint32*)&m_llTextTypeFilter)[1] = 0xFFFFFFFF; // high dword
|
||||||
|
0x004F45F3 this->m_llTextTypeFilter &= 0xFBFFFFFF; // clear bit 26
|
||||||
|
```
|
||||||
|
|
||||||
|
`0xFBFFFFFF` = `~0x04000000` = `~(1 << 26)` = `~(1 << 0x1A)`.
|
||||||
|
|
||||||
|
**Every chat window is born with every text type enabled except `0x1A`.** That is
|
||||||
|
the whole mechanism. `TypeIsActive` @ `0x004F2F10` is just
|
||||||
|
`(m_llTextTypeFilter >> type) & 1`.
|
||||||
|
|
||||||
|
The filter is subsequently overwritten from a saved UI bitfield property
|
||||||
|
(`InqBitfield64` at `0x004F3109` and `0x004F3984`), so in principle a chat window
|
||||||
|
could be configured to show `0x1A` — but the shipped chat-options UI does not offer
|
||||||
|
it, which is why ACE's `ChatMessageType.cs:255-259` concluded "Client doesn't
|
||||||
|
display it" and commented `x1A` out. **That conclusion is wrong and worth
|
||||||
|
recording:** the client does display `0x1A`, just not in the chat scroll.
|
||||||
|
|
||||||
|
### 2.3 The complete destination model
|
||||||
|
|
||||||
|
| Destination | Owner | Trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| **Chat scroll** (one or more windows) | `ChatInterface` @ `0x004F4640` | `AddTextToScroll` with `type != 0x1A`, or with `windowId == this window` |
|
||||||
|
| **SpewBox** (transient screen text) | `gmSpewBoxUI` @ `0x004D60A0` | `AddTextToScroll` with `type == 0x1A` |
|
||||||
|
| **Modal dialog** | `DialogFactory::MakeDialogInCurrentUI` | `Handle_Communication__PopUpString` (event `0x0004`) only |
|
||||||
|
| **Chat log file** | `ClientSystem::s_pLogFile` | `AddTextToScroll` with `type != 0x1A` |
|
||||||
|
| **Plugin sink / veto** | `IACPlugin::OnChatWindowText` | every `AddTextToScroll` with `allowPluginFilter != 0` |
|
||||||
|
| **(dropped)** | — | plugin sets the `eat` out-param |
|
||||||
|
|
||||||
|
Note that a `type == 0x1A` message with a **non-zero `windowId`** lands in *both*
|
||||||
|
the SpewBox and that specific chat window. This is exactly what slash-command
|
||||||
|
output does: `ClientCommunicationSystem` emits its command responses as
|
||||||
|
`AddTextToScroll(text, 0x1A, 1, this->m_idCurrentCommandSource)` (~40 sites from
|
||||||
|
`0x0056EF3B` through `0x005707FB`), so a `/`-command's reply appears on screen
|
||||||
|
*and* is echoed into the window you typed it in.
|
||||||
|
|
||||||
|
### 2.4 Where the strings come from
|
||||||
|
|
||||||
|
**Not from `client_local_English.dat`.** Every player-visible error string in this
|
||||||
|
path is a **wide-char literal compiled into `acclient.exe`**:
|
||||||
|
|
||||||
|
- `HandleFailureEvent` @ `0x00571990` builds each one inline
|
||||||
|
(`PStringBase<unsigned short>::PStringBase<unsigned short>(&var, u"…")`) or via
|
||||||
|
`PStringBase<unsigned short>::sprintf(&s, u"The %s cannot be used …")` with the
|
||||||
|
`0x028B` string parameter substituted.
|
||||||
|
- The 11 movement/jump refusals are process-lifetime globals initialised by static
|
||||||
|
ctors at `0x00708F00`–`0x00709180` (see §4).
|
||||||
|
|
||||||
|
The DAT `StringTable` machinery (`StringInfo::SetTableEnum`,
|
||||||
|
`StringInfo::SetStringIDandTableEnum`) exists and is used for **UI chrome** —
|
||||||
|
option labels, tooltips, command aliases — but the failure-event text is hardcoded.
|
||||||
|
`StringInfo::SetLiteralValue` is what the failure path uses.
|
||||||
|
|
||||||
|
This matters for the port: **we do not need a DAT string table to reach parity on
|
||||||
|
this feature.** A C# table keyed by `WeenieError` id is exactly what retail does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Presentation parameters
|
||||||
|
|
||||||
|
### 3.1 What acclient owns (portable, measured)
|
||||||
|
|
||||||
|
From `gmSpewBoxUI::PostInit` @ `0x004D5AB0` and `gmSpewBoxUI::Update` @ `0x004D5DF0`:
|
||||||
|
|
||||||
|
| Behaviour | Evidence | Value |
|
||||||
|
|---|---|---|
|
||||||
|
| Backing widget | `0x004D5AD7` | a `UIElement_ListBox` found by `GetChildRecursive(0x10000049)` |
|
||||||
|
| Mouse | `0x004D5AC4`, `0x004D5AFE` | the SpewBox and its ListBox are both `SetMouseVisible(0)` — **click-through** |
|
||||||
|
| Background | `0x004D5ABB` | `SetShouldEraseBackground(1)` |
|
||||||
|
| Max concurrent lines | `0x004D5B34` | ListBox property `0x10000028`; **defaults to 1** if the property is absent or unreadable |
|
||||||
|
| Per-line widget | `0x004D5E42` | `CreateChildElementByEnum(parent=null, layoutEnum=0x10000012, elementId=0x1000004A)` — a DAT-authored `UIElement_Text` template |
|
||||||
|
| Text preprocessing | `0x004D5E9C` | `trim(leading=0, trailing=1, whitespace)` — trailing whitespace stripped |
|
||||||
|
| Sizing | `0x004D5EB1`–`0x004D5EE6` | resized to the ListBox's width, then `RecalculateGlyphList`, then resized again to the computed scrollable height (word wrap) |
|
||||||
|
| **Dedupe** | `0x004D5EF6`–`0x004D5F91` | if the current item 0 has **byte-identical text**, that older item is deleted first. A repeated message refreshes in place instead of stacking. |
|
||||||
|
| Insertion | `0x004D5F9F` | `InsertItem(item, 0)` — **newest at the top** |
|
||||||
|
| Overflow | `0x004D5FB6` | if `count > m_maxConcurrentItems`, `DeleteItem(count - 1)` — **oldest drops off** |
|
||||||
|
| Scroll | `0x004D601D` | `ScrollToShow(0)` after a batch |
|
||||||
|
| Drain cadence | `0x004D5BA6`, `0x0045CFFB` | global message `3`, broadcast once per UI tick from `UIElementManager::UseTime` @ `0x0045CFD0` |
|
||||||
|
| Expiry hook | `0x004D57D7` | the SpewBox deletes an item when it receives element message `0x10000003` from element id `0x1000004A` |
|
||||||
|
|
||||||
|
The queue is a `SmartArray<StringInfo,1> m_spewBoxPending`; `RecvNotice_*` only
|
||||||
|
enqueues, `Update` only drains. Enqueue and display are decoupled by one frame.
|
||||||
|
|
||||||
|
### 3.2 PRESENTATION-UNKNOWN (keystone / DAT-owned)
|
||||||
|
|
||||||
|
These could **not** be established from acclient and must not be guessed:
|
||||||
|
|
||||||
|
1. **Line lifetime / fade curve.** acclient never raises element message
|
||||||
|
`0x10000003`. I searched every `BroadcastElementMessage` / `ForwardElementMessage`
|
||||||
|
call site in the whole 66 MB listing: the only element message id above
|
||||||
|
`0x10000000` that acclient itself raises is `0x10000004`
|
||||||
|
(`0x004F0EC5`, a stat-type element). `UIElement` / `UIRegion` / `ElementDesc` /
|
||||||
|
`LayoutDesc` expose no `Duration` / `Lifetime` / `Fade` / `Expire` member at all.
|
||||||
|
**The timeout and any fade are owned by keystone.dll or by the authored
|
||||||
|
`ElementDesc` behaviour of layout `0x10000012` element `0x1000004A`.**
|
||||||
|
*Resolution path:* dump that LayoutDesc from `client_local_English.dat`, or set a
|
||||||
|
cdb breakpoint on `gmSpewBoxUI::ListenToElementMessage` (`0x004D57C0`) in a live
|
||||||
|
retail client and time the deltas between a message appearing and its removal.
|
||||||
|
2. **Screen position and extent.** Authored in whatever LayoutDesc declares an
|
||||||
|
element of class `0x10000016`. The natural host is the main game view
|
||||||
|
(`gmSmartBoxUI`, LayoutDesc `0x2100000F`) but **this was not confirmed** — no
|
||||||
|
dumped layout in `docs/research/retail-ui/` mentions it.
|
||||||
|
*Resolution path:* enumerate LayoutDescs and look for element type `0x10000016`.
|
||||||
|
3. **Font, size, justification, colour of the SpewBox line.** All from the same DAT
|
||||||
|
template. In particular, the SpewBox does **not** use the chat colour table:
|
||||||
|
`BuildChatColorLookupTable` writes to `ChatInterface::m_chatLog`, a different
|
||||||
|
element tree entirely.
|
||||||
|
4. **Max concurrent items in the shipped layout.** The code reads ListBox property
|
||||||
|
`0x10000028`; the authored value is DAT data. The *code default* is 1.
|
||||||
|
|
||||||
|
### 3.3 The chat-window colour table (adjacent, for completeness)
|
||||||
|
|
||||||
|
`ChatInterface::BuildChatColorLookupTable` @ `0x004F31C0` assigns
|
||||||
|
`RGBAColor` constants to text types. Colour values below are from
|
||||||
|
`claude-memory/reference_retail_chat_colors.md` (dumped live via cdb, 2026-06-16).
|
||||||
|
|
||||||
|
| Type(s) | Colour symbol | Addr | RGB |
|
||||||
|
|---|---|---|---|
|
||||||
|
| *default (all)* | `colorGreen` | `0x81C578` | 0.500, 1.000, 0.498 |
|
||||||
|
| `0x02` | `colorWhite` | `0x81C4B8` | 1, 1, 1 |
|
||||||
|
| `0x03 0x0A 0x13 0x1F` | (yellow, unnamed) | `0x81C4C8` | 1, 1, 0.247 |
|
||||||
|
| `0x04 0x0B` | (unnamed, not yet read) | `0x81C4D8` | — |
|
||||||
|
| `0x05` | `colorBrightPurple` | `0x81C4E8` | 1, 0.498, 1 |
|
||||||
|
| `0x06 0x0F 0x15` | `colorDarkRed` | `0x81C4F8` | 1, 0.247, 0.247 |
|
||||||
|
| `0x07 0x11` | `colorLightBlue` | `0x81C518` | 0.247, 0.749, 1 |
|
||||||
|
| `0x08 0x09` | `colorPink` | `0x81C528` | 1, 0.588, 0.588 |
|
||||||
|
| `0x0C` | `colorGrey` | `0x81C558` | 0.824, 0.824, 0.784 |
|
||||||
|
| `0x0D` | `colorCyan` | `0x81C538` | 0.247, 0.863, 0.863 |
|
||||||
|
| `0x0E 0x1B 0x1C 0x1D 0x1E 0x20` | `colorBlueGrey` | `0x81C548` | 0.706, 0.863, 0.941 |
|
||||||
|
| `0x12 0x21` | (orange, unnamed) | `0x81C568` | 0.933, 0.573, 0.118 |
|
||||||
|
| `0x16` | `colorLightRed` | `0x81C508` | 0.960, 0.459, 0.447 |
|
||||||
|
| `0x1A` | `colorBrightRed` | `0x81C4A8` | 1, 0, 0 |
|
||||||
|
|
||||||
|
Two things fall out of this table:
|
||||||
|
|
||||||
|
- The `0x1A` row exists purely for the case where a user manually enables the
|
||||||
|
filter bit. **It is not the SpewBox's colour.** Do not port it as such.
|
||||||
|
- Types `0x20` and `0x21` are real and coloured. **ACE's `ChatMessageType` stops at
|
||||||
|
`0x1F`** — the client's text-type space is wider than the server-side enum.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Client-raised local errors (no server round-trip)
|
||||||
|
|
||||||
|
Retail refuses several actions locally and prints the refusal itself. All of them
|
||||||
|
land on type `0x1A`.
|
||||||
|
|
||||||
|
### 4.1 The message globals
|
||||||
|
|
||||||
|
Static ctors at `0x00708F00`–`0x00709180`. Strings recovered verbatim from the
|
||||||
|
binary (the pseudo-C truncates at 33 chars).
|
||||||
|
|
||||||
|
| Global | Full text | Used? |
|
||||||
|
|---|---|---|
|
||||||
|
| `cant_jump_position` | `You can't jump from this position` | yes (3 sites) |
|
||||||
|
| `cant_jump_in_air` | `You can't jump while in the air` | yes (3 sites) |
|
||||||
|
| `cant_jump_load` | `You're too loaded down to jump` | yes (3 sites) |
|
||||||
|
| `cant_jump_stamina` | `You're too tired to jump!` | **dead in this build** |
|
||||||
|
| `cant_jump_recent` | `You've jumped too recently!` | **dead in this build** |
|
||||||
|
| `too_tired` | `You are too tired to move!` | yes (1 site) |
|
||||||
|
| `cant_sit_combat` | `You can't sit down while in combat` * | yes (1 site) |
|
||||||
|
| `cant_lie_down_combat` | `You can't lie down while in combat` * | yes (1 site) |
|
||||||
|
| `cant_crouch_combat` | `You can't crouch while in combat` * | yes (1 site) |
|
||||||
|
| `cant_emote_combat` | `You can't use chat emotes in combat` * | yes (1 site) |
|
||||||
|
| `cant_emote_position` | `You can't use chat emotes from this position` * | yes (1 site) |
|
||||||
|
|
||||||
|
\* these five were length-truncated in the listing; the prefixes are exact, the
|
||||||
|
tails are the obvious completion and should be re-read from the binary before being
|
||||||
|
committed as literals.
|
||||||
|
|
||||||
|
### 4.2 Raise sites
|
||||||
|
|
||||||
|
**Jump family** — the source of the codes is `CMotionInterp` and they are
|
||||||
|
`WeenieError` ids, the same numbering the server uses.
|
||||||
|
|
||||||
|
| Function | Addr | Codes it produces |
|
||||||
|
|---|---|---|
|
||||||
|
| `CMotionInterp::charge_jump` | `0x005281C0` | `0x49` if `CWeenieObject::CanJump(jump_extent)` fails; `0x48` if `forward_command` is a disallowed posture; `0` otherwise |
|
||||||
|
| `CMotionInterp::jump_is_allowed` | `0x005282B0` | `0x24` if not on the ground; `0x47` if fully constrained or out of stamina; else defers to `jump_charge_is_allowed` / `motion_allows_jump` |
|
||||||
|
|
||||||
|
| Consumer | Addr | Sites |
|
||||||
|
|---|---|---|
|
||||||
|
| `ClientCombatSystem::CommenceJump` | `0x0056AF90` | `0x0056AFE3` → `cant_jump_position` (0x48); `0x0056AFD7` → `cant_jump_load` (0x49); `0x0056AFCB` → `cant_jump_in_air` (fallback) |
|
||||||
|
| `ClientCombatSystem::DoJump` | `0x0056B110` | `0x0056B29A` → `cant_jump_in_air` (0x24); `0x0056B27E` → `cant_jump_position` (0x48); `0x0056B262` → `cant_jump_load` (0x49) |
|
||||||
|
| `ClientCommunicationSystem::HandleFailureEvent` | `0x00571990` | `0x00571DA1` (0x24), `0x00571D73` (0x48), `0x00571D8A` (0x49) — **the same three globals, reused for the server-sent ids** |
|
||||||
|
|
||||||
|
That last row is the important one: retail reuses one string table for
|
||||||
|
locally-detected and server-reported failures. The client-local path is a *latency
|
||||||
|
optimisation over the server's own answer*, not a separate feature.
|
||||||
|
|
||||||
|
**Movement / posture / emote family** — `CommandInterpreter::MovePlayer` @
|
||||||
|
`0x006B3F40`, switching on `CPhysicsObj::DoMotion`'s return:
|
||||||
|
|
||||||
|
| Code | Addr | Message | Emit |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `0x3E` | `0x006B43E4` | `too_tired` | `ECM_UI::SendNotice_DisplayStringInfo(0x1A, ...)` |
|
||||||
|
| `0x3F` | `0x006B4366` | `cant_crouch_combat` | same |
|
||||||
|
| `0x40` | `0x006B43A4` | `cant_sit_combat` | same |
|
||||||
|
| `0x41` | `0x006B43C4` | `cant_lie_down_combat` | same |
|
||||||
|
| `0x42` | `0x006B43F6` | `cant_emote_combat` | same |
|
||||||
|
| `0x44` | `0x006B4419` | `cant_emote_position` | same |
|
||||||
|
|
||||||
|
These take the `SendNotice_DisplayStringInfo` path (notice `0x186A5`) rather than
|
||||||
|
calling `AddTextToScroll` directly, but
|
||||||
|
`ClientCommunicationSystem::RecvNotice_DisplayStringInfo` @ `0x0056E890` immediately
|
||||||
|
forwards to `AddTextToScroll(str, 0x1A, 1, 0)`, so the outcome is identical.
|
||||||
|
|
||||||
|
**Vendor family** — `0x004C4575`, `AddTextToScroll("You need an open vendor.", 0x1A, 1, 0)`.
|
||||||
|
|
||||||
|
**Total: 23 client-raised sites, 6 enclosing functions, 11 distinct strings.**
|
||||||
|
|
||||||
|
### 4.3 What is *not* client-raised
|
||||||
|
|
||||||
|
Retail does **not** locally generate "You are too encumbered to carry that!" —
|
||||||
|
`0x2A` arrives from the server as `WeenieError` and is turned into text by
|
||||||
|
`HandleFailureEvent`. Likewise spell fizzle (`0x0402`) is server-sent. ACE confirms
|
||||||
|
this shape: `Player_Inventory.cs` sends the encumbrance message as
|
||||||
|
`GameEventCommunicationTransientString` (`0x02EB`) rather than as a `WeenieError`
|
||||||
|
at all, and `Player_Magic.cs:918` sends `SendWeenieError(YourSpellFizzled)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What the server sends (ACE cross-check)
|
||||||
|
|
||||||
|
| Opcode | Class | Payload | Client destination |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `0xF7E0` `ServerMessage` | `GameMessageSystemChat` | `string16L text`, `u32 chatType` | `AddTextToScroll(text, chatType, 1, 0)` — chat *or* spew depending on the type |
|
||||||
|
| GameEvent `0x02EB` `CommunicationTransientString` | `GameEventCommunicationTransientString` | `string16L text` only, **no type field** | hardcoded `0x1A` → **SpewBox** |
|
||||||
|
| GameEvent `0x028A` `WeenieError` | `GameEventWeenieError` | `u32 errorId` | `HandleFailureEvent` → per-id (see appendix) |
|
||||||
|
| GameEvent `0x028B` `WeenieErrorWithString` | `GameEventWeenieErrorWithString` | `u32 errorId`, `string16L param` | same, with `%s` substitution |
|
||||||
|
| GameEvent `0x0004` `PopUpString` | — | `string16L text` | `DialogFactory::MakeDialogInCurrentUI` → modal dialog |
|
||||||
|
|
||||||
|
ACE never resolves a `WeenieError` id to text — it always writes the bare u32
|
||||||
|
(`references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventWeenieError.cs`).
|
||||||
|
**The client owns every error string.** `0x48 = YouCantJumpFromThisPosition`,
|
||||||
|
`0x49 = CantJumpLoadedDown` (ACE marks the latter "client side only", consistent
|
||||||
|
with our `charge_jump` finding).
|
||||||
|
|
||||||
|
`references/holtburger/` is **not** a useful oracle here: it flattens `0x02EB` into
|
||||||
|
a plain system chat line
|
||||||
|
(`crates/holtburger-core/src/client/messages.rs:268-275`) and has no transient
|
||||||
|
destination at all. It *is* a useful oracle for id→text: its hand-written
|
||||||
|
`format_weenie_error` table (`crates/holtburger-core/src/errors.rs`) covers ~60
|
||||||
|
ids, and its `is_actually_weenie_error()` allowlist (`errors.rs:302-315`) correctly
|
||||||
|
notes that several "errors" are success notices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. acdream gap list
|
||||||
|
|
||||||
|
Verified against the worktree at `.claude/worktrees/eloquent-hugle-42119e`.
|
||||||
|
|
||||||
|
### 6.1 Routing
|
||||||
|
|
||||||
|
| Retail | acdream today | Gap |
|
||||||
|
|---|---|---|
|
||||||
|
| One `AddTextToScroll(text, type, ...)` chokepoint feeding N self-selecting sinks | `GameEventWiring.cs:223/228` calls `chat.OnWeenieError(...)`; `LiveSessionEventRouter.cs:268` calls `Chat.OnSystemMessage(text, chatType)` | **No chokepoint, no sink model.** Every producer writes directly into `ChatLog`. |
|
||||||
|
| Destination decided by text type on the receiver | `ChatLog` is the only destination | **The whole transient destination is missing.** |
|
||||||
|
| Text type `0x1A` = SpewBox | wire `chatType` *is* parsed and stored in `ChatEntry.ChannelId` but **never read for display**; colour comes solely from the 9-value `ChatKind` enum (`ChatWindowController.cs:542-555`) | The discriminator we need is on the wire, captured, and then thrown away. |
|
||||||
|
| 34-value text-type space (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) |
|
||||||
778
docs/research/2026-08-09-chat-side-channels-vs-ace.md
Normal file
778
docs/research/2026-08-09-chat-side-channels-vs-ace.md
Normal file
|
|
@ -0,0 +1,778 @@
|
||||||
|
# Chat side channels vs current ACE — R4 research lane
|
||||||
|
|
||||||
|
**Campaign:** CH (chat & interface-text retail parity),
|
||||||
|
`docs/plans/2026-08-09-chat-parity-campaign.md` lane R4.
|
||||||
|
**Date:** 2026-08-09. **Mode:** research only, no code changed.
|
||||||
|
**Sources:** current vendored ACE at `references/ACE/`, named retail decomp at
|
||||||
|
`docs/research/named-retail/`, holtburger + Chorizite.ACProtocol cross-checks,
|
||||||
|
acdream `src/` as of `2914e43a`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Verdict summary
|
||||||
|
|
||||||
|
| Family | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| **The 26-day-old claim** ("ACE doesn't run a TurbineChat server") | **FALSE.** ACE has a complete TurbineChat implementation, on by default, and we have live logs proving it reaches us. |
|
||||||
|
| **Turbine General / Trade / LFG** | Wire-correct end to end. Should already work. If they don't, the cause is a live-state issue, not a codec issue — see the probe in §7.1. |
|
||||||
|
| **Turbine Roleplay** | **Broken, root cause found.** ACE never joins the player to Roleplay because `HearRoleplayChat` is not in ACE's `CharacterOptions2.Default`, ACE then filters the sender out of its own broadcast, and acdream neither gates the send nor echoes locally → total silence. |
|
||||||
|
| **Turbine Society / Olthoi** | Correctly unavailable (no society, not an Olthoi player). Retail shows a specific refusal; we show nothing. |
|
||||||
|
| **Turbine Allegiance (`/a`)** | Wrong transport when the player has no allegiance: acdream silently downgrades to the legacy `AllegianceBroadcast` bitflag. Retail never does this. |
|
||||||
|
| **Legacy `/f /v /p /m /cv`** | Wire-correct outbound and inbound. Two presentation defects: every line **double-prints**, and the "not in a fellowship / not in an allegiance" refusals do reach us (they are wired) but the send still looks like it worked. |
|
||||||
|
| **Client-side Hear\* settings** | The Settings panel's six "Hear … Chat" toggles are **dead** — never applied to display, never sent to the server. |
|
||||||
|
| **`SetCharacterOptions` (0x01A1)** | acdream's builder is **malformed** — latent, currently unreachable from the UI, but it would corrupt server-side options if ever wired up. |
|
||||||
|
| **`AddChannel` / `RemoveChannel` (0x0145/0x0146)** | acdream's builders send the wrong payload type (string vs u32 bitfield). Unused today; admin-only on ACE. |
|
||||||
|
|
||||||
|
**One-line root cause for "side channels don't work":** we treat the
|
||||||
|
`Hear<Channel>Chat` character options as a local display preference. Retail and
|
||||||
|
ACE treat them as **channel membership**. ACE filters the sender out of its own
|
||||||
|
broadcast when the sender's option is off, and retail refuses the send outright
|
||||||
|
with a named error. We do neither, so the message vanishes with no feedback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The refuted claim
|
||||||
|
|
||||||
|
`docs/ISSUES.md:15214` and `docs/plans/2026-04-11-roadmap.md:429,948` both carry:
|
||||||
|
|
||||||
|
> **Note: ACE doesn't run a TurbineChat server — codec is ready for
|
||||||
|
> retail-server-emulating setups.**
|
||||||
|
|
||||||
|
This is wrong, and has been wrong the whole time. Evidence, strongest first:
|
||||||
|
|
||||||
|
1. **ACE source.** `references/ACE/Source/ACE.Server/Network/Handlers/TurbineChatHandler.cs`
|
||||||
|
is a full 387-line inbound handler registered
|
||||||
|
`[GameMessage(GameMessageOpcode.TurbineChat, SessionState.WorldConnected)]`.
|
||||||
|
`GameMessageTurbineChat.cs` is the outbound writer.
|
||||||
|
`TurbineChatChannel.cs` holds the room-id constants.
|
||||||
|
2. **It is on by default.** `PropertyManager.cs:608` —
|
||||||
|
`("use_turbine_chat", new Property<bool>(true, …))`.
|
||||||
|
3. **We have already received it, repeatedly.** Our own launch logs contain the
|
||||||
|
parsed 0x0295 payload with live room ids, e.g.
|
||||||
|
`docs/research/2026-08-07-339-portal-space-hang.log:55` and eleven
|
||||||
|
2026-05-21/2026-05-23 capture logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
chat: SetTurbineChatChannels parsed enabled=True general=0x00000002
|
||||||
|
trade=0x00000003 lfg=0x00000004 roleplay=0x00000005 society=0x00000000
|
||||||
|
olthoi=0x0000000A allegiance=0x00000000
|
||||||
|
```
|
||||||
|
|
||||||
|
Those are exactly ACE's `TurbineChatChannel` constants. `TurbineChatState.Enabled`
|
||||||
|
has been `true` against local ACE since at least 2026-05-21.
|
||||||
|
|
||||||
|
**Action:** both the ISSUES entry and the two roadmap rows must be corrected in
|
||||||
|
the CH3 commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. acdream inventory (current truth)
|
||||||
|
|
||||||
|
### 2.1 Codec — `src/AcDream.Core.Net/Messages/TurbineChat.cs`
|
||||||
|
|
||||||
|
Builds and parses the 0xF7DE message. Header is opcode + 9 u32 (36 bytes):
|
||||||
|
|
||||||
|
| Off | Field | Build value (outbound request) |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | `u32 opcode` | `0xF7DE` |
|
||||||
|
| 4 | `u32 sizeFirst` | `40 + payloadLen` |
|
||||||
|
| 8 | `u32 blobType` | `3` RequestBinary |
|
||||||
|
| 12 | `u32 dispatchType` | `2` SendToRoomById |
|
||||||
|
| 16 | `u32 targetType` | `1` |
|
||||||
|
| 20 | `u32 targetId` | `0` |
|
||||||
|
| 24 | `u32 transportType` | `0` |
|
||||||
|
| 28 | `u32 transportId` | `0` |
|
||||||
|
| 32 | `u32 cookie` | `0` |
|
||||||
|
| 36 | `u32 sizeSecond` | `8 + payloadLen` |
|
||||||
|
|
||||||
|
Request payload (`WritePayload`, `TurbineChat.cs:371-381`):
|
||||||
|
`u32 contextId`, `u32 2`, `u32 2`, `u32 roomId`, turbine-string message,
|
||||||
|
`u32 0x0C`, `u32 senderId`, `u32 0` hresult, `u32 chatType`.
|
||||||
|
|
||||||
|
Turbine string codec (`ReadTurbineString`/`WriteTurbineString`,
|
||||||
|
`TurbineChat.cs:406-463`): 1-or-2-byte packed length in **UTF-16 code units**,
|
||||||
|
then UTF-16LE bytes, no padding. This is the only acdream string type that is
|
||||||
|
not CP1252 String16L.
|
||||||
|
|
||||||
|
Parse handles three shapes: `(EventBinary, SendToRoomByName)`,
|
||||||
|
`(RequestBinary, SendToRoomById)` with a hard reject unless the inner
|
||||||
|
response/method ids are both `2`, and `(ResponseBinary, *)`. Unknown
|
||||||
|
blob/dispatch pairs are captured verbatim rather than rejected.
|
||||||
|
|
||||||
|
### 2.2 Room table — `src/AcDream.Core.Net/Messages/SetTurbineChatChannels.cs`
|
||||||
|
|
||||||
|
Parses the 40-byte 0x0295 GameEvent payload as 10 u32 in order:
|
||||||
|
allegiance, general, trade, lfg, roleplay, olthoi, society, societyCelHan,
|
||||||
|
societyEldWeb, societyRadBlo. Registered in the `WorldSession` ctor
|
||||||
|
(`WorldSession.cs:837`) **and** again in `GameEventWiring.cs:184-213`; the
|
||||||
|
latter is the one that actually feeds `TurbineChatState.OnChannelsReceived`
|
||||||
|
and prints the diagnostic line quoted in §1. `WorldSession.TurbineChannelsReceived`
|
||||||
|
has **no production subscriber** — dead event surface, harmless.
|
||||||
|
|
||||||
|
### 2.3 State — `src/AcDream.Core/Chat/TurbineChatState.cs`
|
||||||
|
|
||||||
|
Holds `Enabled` + the ten room ids + a per-session context cookie starting at 1
|
||||||
|
and wrapping to 1. `Reset()` clears everything on session replace. `RoomFor()`
|
||||||
|
maps a `ChatChannelKindLite` to a room id. **Nothing in this class knows about
|
||||||
|
the Hear\* options.**
|
||||||
|
|
||||||
|
### 2.4 Channel classification — `src/AcDream.Core/Chat/ChatChannelInfo.cs`
|
||||||
|
|
||||||
|
`Legacy(channelId)` vs `Turbine(roomId, chatType, dispatchType)`, plus
|
||||||
|
`IsSelfEchoChannel()`: true for legacy Fellow/Vassals/Patron/Monarch/CoVassals,
|
||||||
|
false for Turbine. **This type is never consulted by any production send path.**
|
||||||
|
It exists only for its own unit tests (`ChatChannelInfoTests`). The
|
||||||
|
`ChannelResolver` referenced in the brief lives at
|
||||||
|
`src/AcDream.UI.Abstractions/ChannelResolver.cs`, not under `Core/Chat/`.
|
||||||
|
|
||||||
|
### 2.5 Legacy resolver — `src/AcDream.UI.Abstractions/ChannelResolver.cs`
|
||||||
|
|
||||||
|
| Kind | Id | ACE `Channel` | Match |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Fellowship | `0x00000800` | `Fellow` | ✔ |
|
||||||
|
| Allegiance | `0x02000000` | `AllegianceBroadcast` | ✔ value, ✘ semantics (§5.3) |
|
||||||
|
| Vassals | `0x00001000` | `Vassals` | ✔ |
|
||||||
|
| Patron | `0x00002000` | `Patron` | ✔ |
|
||||||
|
| Monarch | `0x00004000` | `Monarch` | ✔ |
|
||||||
|
| CoVassals | `0x01000000` | `CoVassals` | ✔ |
|
||||||
|
|
||||||
|
### 2.6 Outbound send paths
|
||||||
|
|
||||||
|
**Graphical**, `src/AcDream.App/Net/LiveSessionCommandRouter.cs:247-286`:
|
||||||
|
|
||||||
|
```
|
||||||
|
SendChatCmd
|
||||||
|
├ Say → SendTalk (no local echo — waits for the authoritative line)
|
||||||
|
├ Tell → SendTell + Chat.OnSelfSent(Tell)
|
||||||
|
├ TurbineChatRouting.Resolve(kind, TurbineChat) → non-null?
|
||||||
|
│ → SendTurbineChat(room, chatType, SendToRoomById, playerGuid, text, cookie)
|
||||||
|
│ (NO OnSelfSent)
|
||||||
|
├ ChannelResolver.Resolve(kind) → non-null?
|
||||||
|
│ → SendChannel(legacyId, text) + Chat.OnSelfSent(Channel, displayName)
|
||||||
|
└ else → log "dropped" and return
|
||||||
|
```
|
||||||
|
|
||||||
|
`TurbineChatRouting.Resolve` (same file, ~line 388) gates only on
|
||||||
|
`state.Enabled` and `Room != 0`.
|
||||||
|
|
||||||
|
**Headless**, `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:949-990`
|
||||||
|
— same order, same gate, no echo either way.
|
||||||
|
|
||||||
|
**Wire builders:**
|
||||||
|
- `WorldSession.SendTurbineChatTo` (`WorldSession.cs:2602-2638`) — hard-codes
|
||||||
|
`targetType=1`, everything else 0, `extraDataSize=0x0C`, outer `cookie=0`,
|
||||||
|
inner `contextId` = the caller's cookie. Sends through `SendGameAction` →
|
||||||
|
`SendGameMessage` → `GameMessageGroup.UIQueue`.
|
||||||
|
- `WorldSession.SendChannel` (`WorldSession.cs:2090-2096`) →
|
||||||
|
`ChatRequests.BuildChatChannel` (`ChatRequests.cs:89-100`):
|
||||||
|
`[0xF7B1][seq][0x0147][u32 channelId][String16L message]`, CP1252,
|
||||||
|
4-byte aligned.
|
||||||
|
|
||||||
|
### 2.7 Inbound handling
|
||||||
|
|
||||||
|
- **0xF7DE** — `WorldSession.cs:1855-1866` parses and raises `TurbineChatReceived`;
|
||||||
|
`LiveSessionEventRouter.cs:280-283` subscribes;
|
||||||
|
`RouteTurbineChat` (`LiveSessionEventRouter.cs:479-489`) forwards **only**
|
||||||
|
`EventSendToRoom` into `ChatLog.OnChannelBroadcast(roomId, sender, text, displayName)`.
|
||||||
|
`Response` (the ack) and `Unknown` are **silently discarded** — no logging, no
|
||||||
|
hResult inspection.
|
||||||
|
- **0x0147 ChannelBroadcast** — `GameEventWiring.cs:102-106` →
|
||||||
|
`GameEvents.ParseChannelBroadcast` (`u32 channelId`, `String16L sender`,
|
||||||
|
`String16L message`) → `ChatLog.OnChannelBroadcast`.
|
||||||
|
- **0x028A / 0x028B WeenieError(WithString)** — wired at
|
||||||
|
`GameEventWiring.cs:220-229` into `ChatLog.OnWeenieError`, and
|
||||||
|
`WeenieErrorMessages` has the relevant strings: `0x051D` "Turbine Chat is
|
||||||
|
enabled.", `0x051B/0x051C` channel enter/leave, `0x0414` not in allegiance,
|
||||||
|
`0x050F` not in a Fellowship.
|
||||||
|
- **Rendering** — `ChatVM.FormatEntry` renders `ChatKind.Channel` as
|
||||||
|
`[Name] Sender says, "text"`, or `[Name] You say, "text"` when the sender is
|
||||||
|
empty or "You". `ChatWindowController.RetailChatColor` gives Channel
|
||||||
|
`colorLightBlue`. No filtering by channel anywhere.
|
||||||
|
|
||||||
|
### 2.8 Command parsing
|
||||||
|
|
||||||
|
`ChatCommandRouter.Submit` → retail client-command catalog → local `/help` →
|
||||||
|
degenerate-prefix guard → unknown-verb `@`-passthrough → `ChatInputParser.Parse`.
|
||||||
|
`ChatInputParser.ChannelVerbs` (`ChatInputParser.cs:55-82`) covers
|
||||||
|
`/g /general /gen /f /fellow /fellowship /a /allegiance /m /monarch /p /patron
|
||||||
|
/v /vassals /cv /covassals /lfg /lookingforgroup /trade /tr /role /rp /roleplay
|
||||||
|
/society /olthoi`. No collisions with `RetailClientCommandCatalog`. **The verb
|
||||||
|
layer is fine.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The ACE contract
|
||||||
|
|
||||||
|
### 3.1 TurbineChat inbound — `TurbineChatHandler.cs:20-60`
|
||||||
|
|
||||||
|
Registered `[GameMessage(GameMessageOpcode.TurbineChat, SessionState.WorldConnected)]`.
|
||||||
|
Reads, in order (all little-endian u32 unless noted):
|
||||||
|
|
||||||
|
| # | Field | ACE comment / use |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | size | "Bytes to follow" — read and discarded |
|
||||||
|
| 2 | `chatBlobType` | must be `3` NETBLOB_REQUEST_BINARY, else "Unhandled" console line |
|
||||||
|
| 3 | `chatBlobDispatchType` | `1` byName / `2` byId — only affects channel re-derivation |
|
||||||
|
| 4 | — | "Always 1" |
|
||||||
|
| 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=<BlobType> dispatch=<DispatchType> room/context=<…> hresult=<…>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`ResponseBinary` only, no `EventBinary`** → confirms the §5.2 chain: ACE
|
||||||
|
accepted the send and filtered the sender out of its own broadcast. The fix is
|
||||||
|
the character option, not the codec.
|
||||||
|
- **Neither** → the send never reached the handler; capture loopback UDP on
|
||||||
|
`127.0.0.1:9000` with WireMCP and check the 0xF7DE bytes against §3.1.
|
||||||
|
- **`EventBinary` present but no chat line** → the defect is downstream in
|
||||||
|
`ChatLog`/`ChatVM`, not on the wire.
|
||||||
|
|
||||||
|
Strip the line once the answer is in hand.
|
||||||
|
|
||||||
|
### 7.3 Server-side confirmation (no client change)
|
||||||
|
|
||||||
|
ACE's `LogTurbineChat` (`TurbineChatHandler.cs:345-385`) writes
|
||||||
|
`[CHAT][General] +Acdream says, "…"` to the ACE log when `chat_log_general` is
|
||||||
|
set. Enabling that property and watching the ACE console proves whether the
|
||||||
|
message reached the handler at all — the cleanest single discriminator, and it
|
||||||
|
requires nothing from the client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Files touched by any fix
|
||||||
|
|
||||||
|
| Path | Why |
|
||||||
|
|---|---|
|
||||||
|
| `src/AcDream.App/Net/LiveSessionCommandRouter.cs` | Turbine option gate; legacy self-echo suppression |
|
||||||
|
| `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs` | same gate for the headless path |
|
||||||
|
| `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | ack/hResult handling in `RouteTurbineChat` |
|
||||||
|
| `src/AcDream.Core/Chat/WeenieErrorMessages.cs` | `0x0551 YouAreNotListeningTo_Channel` |
|
||||||
|
| `src/AcDream.Core.Net/Messages/SocialActions.cs` | `SetSingleCharacterOption`; fix/remove 0x01A1, 0x0145, 0x0146 |
|
||||||
|
| `src/AcDream.Core.Net/WorldSession.cs` | `SendSetSingleCharacterOption` |
|
||||||
|
| `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs` + `SettingsPanel.cs` | seed from server options; publish the command |
|
||||||
|
| `src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs` | `/ab` verb |
|
||||||
|
| `src/AcDream.UI.Abstractions/ChannelResolver.cs` | `/a` vs `/ab` split |
|
||||||
|
| `docs/ISSUES.md`, `docs/plans/2026-04-11-roadmap.md`, `claude-memory/project_chat_pipeline.md` | retract the false claim |
|
||||||
|
| `docs/architecture/retail-divergence-register.md` | rows for residual divergences |
|
||||||
Loading…
Add table
Add a link
Reference in a new issue