acdream/docs/research/2026-08-09-chat-retail-interface-text.md
Erik e306c979ae 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>
2026-08-09 15:25:29 +02:00

931 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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