Merge remote-tracking branch 'origin/main' into codex/atmospheric-rendering-campaign
This commit is contained in:
commit
99cf26e00c
122 changed files with 14815 additions and 947 deletions
|
|
@ -1,5 +1,20 @@
|
|||
# Retail client slash-command registry — complete enumeration + acdream audit
|
||||
|
||||
> **CORRECTION 2026-08-21 — the "acdream status" columns below are STALE.**
|
||||
> They record the state on 2026-08-09, BEFORE slice CH4 landed. Every verb this
|
||||
> document calls MISSING has since been added and verified present in
|
||||
> `ChatInputParser.ChannelVerbs`: `cg`, `soc`, `o`, `co-vassals`, `covassal`,
|
||||
> `c`, `fellows`, `group`, `party`, `vassal`, `ab`, `guild`, `gu`, `ct`,
|
||||
> `clfg`, `crp`. The DIVERGENT row for `g` was also corrected — acdream now
|
||||
> maps `/g` to Fellowship, matching retail, confirmed against the live retail
|
||||
> client on 2026-08-21.
|
||||
>
|
||||
> The RETAIL side of this document (the registered verbs, handler addresses and
|
||||
> channel ids) remains accurate and is still the authority. Only the columns
|
||||
> describing what acdream does are out of date; verify against
|
||||
> `ChatInputParser.cs` before trusting them.
|
||||
|
||||
|
||||
Date: 2026-08-09
|
||||
Status: RESEARCH ONLY. No production code was changed.
|
||||
|
||||
|
|
|
|||
188
docs/research/2026-08-21-acdream-chat-ui-audit.md
Normal file
188
docs/research/2026-08-21-acdream-chat-ui-audit.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# acdream chat UI audit — window controllers, input bar, menus, filters, window management
|
||||
|
||||
Scope: acdream's own chat UI implementation (window controllers, view models,
|
||||
input bar, menus, filters, window management). Explicitly OUT of scope per
|
||||
task boundary: retail's glyph tag system, tag click dispatch, and the text
|
||||
rendering stack (owned by a parallel audit) — not covered here beyond
|
||||
incidental mentions needed to explain routing.
|
||||
|
||||
All claims below are cited `file.cs:line` against the current worktree
|
||||
(`C:\Users\erikn\source\repos\acdream\.claude\worktrees\objective-leavitt-0cbd10`).
|
||||
This document supersedes nothing in `claude-memory/project_chat_digest.md`;
|
||||
it verifies and extends it against the current code as of 2026-08-21.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inventory — what exists and what each surface owns
|
||||
|
||||
| Surface | File | Owns |
|
||||
|---|---|---|
|
||||
| Main chat window | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Binds LayoutDesc `0x2100006F` (retail `gmMainChatUI`/`ChatInterface`, `m_eWindowID==8`). Transcript (`UiText`), input (`UiField`), scrollbar, talk-focus channel menu (`UiMenu`), Send button, max/min toggle, the four floating-window indicator LEDs (mirror + click), the 8 resize-grip locked/live cosmetic swap seed. |
|
||||
| Floating chat windows ×4 | `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` | Binds LayoutDesc `0x2100005B` (retail `gmFloatyChatUI`, `m_eWindowID` 2-5) four times, one `FloatingChatWindowController` instance per `WindowId` 1-4. Transcript, input (channel hardcoded to Say), scrollbar, Send button, hardcoded `"Chat {windowId}"` title, Close button. |
|
||||
| Chat view-model | `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` | Formats `ChatLog` entries to display lines (`RecentLines`/`RecentLinesDetailed`), owns `/framerate`/`/loc` client-side output, the `ShowSystemMessage`/`ShowInterfaceText` (0x1A→SpewBox) split, and `ChatCommandTargetState` (last-tell-sender/target) via `_commandTargets`. |
|
||||
| Chat submit pipeline | `src/AcDream.Runtime/Chat/ChatCommandRouter.cs` | The one `Submit` chokepoint both `ChatWindowController.Bind`'s `Input.OnSubmit` (`ChatWindowController.cs:339-340`) and `FloatingChatWindowController.Bind`'s `Input.OnSubmit` (`FloatingChatWindowController.cs:157`) call. |
|
||||
| Chat parsing / catalog | `src/AcDream.Runtime/Chat/ChatInputParser.cs`, `RetailClientCommandCatalog.cs`, `RetailCommandHelpTable.cs`, `RetailChannelTagTable.cs` | Verb resolution, 152-verb registry, `/help` text. |
|
||||
| Per-window filter/open state | `src/AcDream.Core/Chat/ChatWindowState.cs` | The ONE canonical `ChatWindowState` (5 windows: id 0 main + 1-4 floaty) both controllers read live — filters, open/closed, `ShouldDisplay`/`TypeIsActive` (retail's `ChatInterface::TypeIsActive`/`RecvNotice_DisplayFinalStringInfo`). |
|
||||
| Chat colors | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs` | 34-value `RetailLogTextType`→RGBA table, hard-coded (matches retail; no user config — confirmed still true, see §2). |
|
||||
| Input widget | `src/AcDream.App/UI/UiField.cs` | Generic editable-field widget; the chat entry is one instance of this, built by `DatWidgetFactory.BuildText` for the DAT's Type-12 Editable element. |
|
||||
| Talk-focus / dropdown menu | `src/AcDream.App/UI/UiMenu.cs` | Generic dropdown popup widget; the chat channel selector is one instance. |
|
||||
| Chat-tab Settings surface | `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (+ `ChatOptionsDatDefaults.cs`) | Options panel → Chat tab: two opacity sliders + 5 per-window 13-row text-type filter blocks, all live-writing `ChatWindowState`/`RetailWindowOpacityController`. |
|
||||
| Persistence | `src/AcDream.App/UI/RetailUiRuntime.cs` (`SaveChatWindowFilters`, `SaveChatOpacity`, load path ~`RetailUiRuntime.cs:1516-1518`) + generic `RetailWindowLayoutPersistence` | Filters (all 5 windows) + opacity persist to local `settings.json`; window geometry/open-state persist "for free" once registered under `WindowNames.Chat`/`ChatWindow1..4` (`src/AcDream.App/UI/WindowNames.cs:19-23`). |
|
||||
| Dead/legacy surface | `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | An `IPanel` (ImGui-era D.2a stack) chat panel. **Never instantiated in `src/`** — see §6. |
|
||||
|
||||
**Not in this lane** (owned elsewhere per the task boundary): glyph tag
|
||||
colouring/click dispatch, `UiText` rendering internals, SpewBox glyph
|
||||
drawing. Where the input bar or router hands text to the SpewBox
|
||||
(`ChatVM.ShowInterfaceText`, `ChatVM.cs:177-183`) that routing decision is
|
||||
in-lane; the SpewBox's own rendering is not.
|
||||
|
||||
---
|
||||
|
||||
## 2. Window management
|
||||
|
||||
**Multiple windows.** Five windows total: 1 main (always open, `ChatWindowState.MainWindowId=0`, `ChatWindowState.cs:68,169-175`) + 4 floating (independently open/closed, `ChatWindowState.cs:69-70,178-206`). Confirmed working end-to-end: `ChatWindowController.SetIndicatorOpen`/`BindIndicatorClicks` (`ChatWindowController.cs:671-700`) mirror + drive floating-window visibility from the main window's 4 indicator LEDs, and `RetailUiRuntime.ToggleFloatingChatWindow` (`RetailUiRuntime.cs:1142-1143`) is the Alt+1..4 keybind's landing point (`KeyBindings.cs:218-221`).
|
||||
|
||||
**Filters.** Per-window 64-bit `LogTextType` bitmask filter (`ChatWindowState.GetFilter`/`SetFilter`/`ShouldDisplay`, `ChatWindowState.cs:145-167,230-235`), fully live: both controllers read it every transcript rebuild (`ChatWindowController.cs:742,776-777`; `FloatingChatWindowController.cs:261,278-279`), and it's user-editable through the Options→Chat tab (`ChatOptionsPageController.cs:181-193` five `FilterBlockSpec` rows, `:552-598` `BuildFilterBlock`). This is MORE complete than the chat digest implied — the digest's "Main's filter IS user-settable" note (digest line 118-119) is confirmed and the floaty filters are settable through the same UI, not just the main window.
|
||||
|
||||
**Move/resize.** The 8 authored resize grips (`ChatWindowController.cs:38-43` doc) and the drag/move title-strip import generically via `DatWidgetFactory`/`UiResizeGrip` — no per-controller binding code needed (confirmed by the class doc; no resize-specific code exists in either controller beyond `AttachWindow`/`ToggleMaximize`).
|
||||
|
||||
**Maximize/restore (main window only).** `ChatWindowController.ToggleMaximize` (`ChatWindowController.cs:537-580`) is a faithful port of `gmMainChatUI::HandleMaximizeButton @0x004CCE50` (save/restore Y+height, half-parent expansion, up/down growth choice, DAT-constraint clamping) plus `CaptureWindowState`/`RestoreWindowState` (`ChatWindowController.cs:702-715`) for session persistence. **Floating windows have no maximize** — matches retail (no max/min button authored on `0x2100005B`; `FloatingChatWindowController.cs` has no `MaxMinId`/`ToggleMaximize` equivalent, and this is correct, not a gap).
|
||||
|
||||
**Close (floating windows only).** `FloatingChatWindowController.Bind`'s Close-button wiring (`FloatingChatWindowController.cs:211-214`) calls `c.WindowHandle?.Hide()` — a straight port of `gmFloatyChatUI::ListenToElementMessage @0x004CE330`. **Main window has no close button** (matches retail; `ChatWindowState.SetOpen`/`Toggle` are explicit no-ops for window 0, `ChatWindowState.cs:177-188,195-206`).
|
||||
|
||||
**Opacity.** Two linked sliders (Default/Active), ported with retail's `DualHash` drag-the-other-value link (`ChatOptionsPageController.cs:344-372` doc, `:412-451`), scoped to exactly the 5 chat windows per issue #379 (see §5 — DONE). Batched persistence (`RetailUiRuntime.SaveChatOpacity`, `RetailUiRuntime.cs:1201-1210`) flushes once per discrete edit, not per drag tick.
|
||||
|
||||
**Persistence.** Filters for all 5 windows + both opacity values write to local `settings.json` on every live edit (`RetailUiRuntime.cs:1169-1210`) and reload at startup (`RetailUiRuntime.cs:1516-1518` for the main window's filter; the floaty load leg is the analogous call in `MountFloatingChatWindows`, cited by that same comment). Window geometry/open-state ride the generic `RetailWindowLayoutPersistence` path since all 5 windows are registered under distinct `WindowNames` entries (`WindowNames.cs:19-23`). **No gap found here** — window management persistence is comprehensive.
|
||||
|
||||
**Known, already-registered divergences (not new findings, listed for completeness):**
|
||||
- AP-187/AP-189 (`docs/architecture/retail-divergence-register.md`): floaty filters are local-`settings.json`-only, no `0x1000008C` server-side wire sync between installs; and the chat log's shared `500`-entry ring buffer / `200`-entry display tail (`ChatLog.cs:21-22,447-448`; `InteractionRetainedUiComposition.cs:465`) gives every window a shallower **effective per-window** scrollback than retail's own **per-window** 10,000-line log — a low-traffic window's messages can be evicted from the shared tail by unrelated high-traffic windows' spam before that window's own filter ever sees them. Behaviorally the accumulate-while-closed and independent-per-window-scroll-position mechanics are correctly reproduced; only the depth differs.
|
||||
- AP-188/#369 (OPEN): floating windows hardcode Send-channel to Say (`FloatingChatWindowController.cs:157`) because the floaty LayoutDesc authors no talk-focus menu; whether retail's floaties actually share the main window's last-picked channel is UNRESEARCHED (see §5).
|
||||
- AP-190/#379 (#379 DONE, AP-190 partially retired): opacity scope-to-chat-only is fixed; the digest's "we snap, retail eases 5%-of-range per tick" easing-curve residual is unverified as still true today — UNKNOWN, needs re-check against `RetailWindowOpacityController` if picked up.
|
||||
|
||||
---
|
||||
|
||||
## 3. Input bar — exactly what `UiField` supports
|
||||
|
||||
Source: `src/AcDream.App/UI/UiField.cs`, wired per-window at `ChatWindowController.cs:329-372` (main) and `FloatingChatWindowController.cs:151-175` (floaty). Both controllers configure the SAME widget class with near-identical wiring (the floaty path lacks the talk-focus channel, per §2/#369).
|
||||
|
||||
**Supported:**
|
||||
- **Typing / editing:** `InsertChar`, `Backspace`, `DeleteForward`, held-key auto-repeat for Backspace/Delete/Left/Right (`UiField.cs:159-189,683-700`, 0.40s delay / 25/s repeat).
|
||||
- **Caret movement:** Left/Right (`MoveCaret`), Home/End (`MoveCaretTo`), all Shift-extendable when `Selectable` (`UiField.cs:791-811`). No Ctrl+Left/Right word-jump, no Ctrl+Backspace delete-word.
|
||||
- **Selection:** mouse click+drag (`MouseDown`/`MouseMove`, `UiField.cs:749-763`), Shift+arrow, Ctrl+A select-all — **all three gated behind `Selectable`** (`UiField.cs:781,789`), which is DAT-authored property `0x27` on element `0x10000016`. Confirmed live-DAT-true for the chat input specifically: `ChatLayoutConformanceTests.ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace` (`tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs:220-228`) asserts `input.Selectable == true` after `ChatWindowController.Bind`, and neither controller sets it explicitly (`ChatWindowController.cs`/`FloatingChatWindowController.cs` grep clean for `Input.Selectable`) — so this is the real DAT default, not an accidental pass. **Not a gap.**
|
||||
- **Clipboard:** Ctrl+C/Ctrl+X gated behind `Selectable` (same as above); Ctrl+V (paste) is **not** gated and always works when `Editable` (`UiField.cs:265-303,784`). Paste strips control characters and normalizes CR/LF for multi-line fields; the chat input is one-line so paste collapses to a single stripped line.
|
||||
- **History:** 100-entry cap (`UiField.cs:326`, sentinel via `_historyIndex=-1`), Up/Down browse (`HistoryPrev`/`HistoryNext`, `UiField.cs:330-350,809-810`) — a faithful port per the class doc's citation of `ChatInterface::ProcessCommand @0x4f5100`.
|
||||
- **Submit:** Enter/KeypadEnter (`UiField.cs:792-802`) calls `Submit()` → `OnSubmit` → clears (`ClearOnSubmit`, default true) → pushes history (`RecordHistory`, default true) → releases keyboard focus (`FindRoot()?.SetKeyboardFocus(null)`) — "exit write mode after sending," matching retail's read-mode/write-mode chat behavior.
|
||||
- **Max length:** DAT-authored via property `0x1E` → `UiField.MaxCharacters` (`DatWidgetFactory.cs:788-789`); default `0xFFFF` if the DAT doesn't author one. Not hardcoded in the controller — correctly deferred to the imported layout.
|
||||
- **Focus entry (keyboard):** `UiRoot.OnKeyDown` has a special case — when nothing is focused, Tab or Enter/KeypadEnter focuses `DefaultTextInput` (`UiRoot.cs:1059-1071`), which `RetailUiRuntime.cs:1587` sets to the bound chat `Input`. This is the actual, working mechanism for "press Enter/Tab to start typing" — it runs entirely inside `UiRoot`, independent of the `InputDispatcher`/`InputAction` system.
|
||||
|
||||
**Missing / gaps found:**
|
||||
1. **Escape does nothing while the chat input is focused.** `UiField.OnEvent`'s `KeyDown` switch (`UiField.cs:790-811`) has no `Key.Escape` case, so it falls through to `return false;` (implicit end-of-block after the switch, `UiField.cs:812`). Because `KeyboardFocus.IsEditControl` is true for a focused `UiField` (`UiField.cs:149`), `UiRoot.OnKeyDown`'s modal/root fallback branch (`UiRoot.cs:1083-1089`) is skipped entirely, and the event falls to `WorldKeyFallThrough` (`UiRoot.cs:1091`) — **an event nobody subscribes to in production** (grep for `WorldKeyFallThrough +=` across `src/` finds only the class's own declaration and a `README.md` code sample, `src/AcDream.App/UI/UiHost.cs:19`, `src/AcDream.App/UI/UiRoot.cs:408`). Separately, `InputDispatcher`'s own action-routing is gated off entirely whenever a widget holds keyboard focus (`_mouse.WantCaptureKeyboard` → `SilkMouseSource.cs:193` → `Root.WantsKeyboard` → `KeyboardFocus is not null`, `UiRoot.cs:196`), so `GameplayInputCommandController.HandleEscape` (`GameplayInputCommandController.cs:238-248`, cancel target mode / exit fly mode / close window) never fires either. **Net effect: pressing Escape while the chat box has focus is a complete no-op in acdream today** — no clear, no defocus, no fallback to a game hotkey. This is not tracked in `docs/ISSUES.md` under any existing chat issue.
|
||||
2. **No autocomplete / tab-completion** of player names, channel tags, or command verbs. Confirmed by exhaustive grep (`autocomplete|tabcomplete|namecomplet` across `src/`) — zero hits. `ChatCommandRouter.Submit` (`ChatCommandRouter.cs:30-179`) is pure parse-and-dispatch with no partial-match suggestion path. Whether retail AC's chat box ever had tab-completion is UNKNOWN — not established either way in this pass; flagging the absence, not asserting it's a regression.
|
||||
3. **`@title` is a documented no-op** (see §4) — the floating window's title bar (`FloatingChatWindowController.cs:194-207`) is permanently `"Chat {windowId}"`, unaffected by the command that's supposed to set it.
|
||||
4. **No Ctrl+Left/Right word-jump or Ctrl+Backspace delete-word** — minor editing convenience absent from `UiField`'s `KeyDown` switch entirely (not chat-specific, but the chat input is the surface a user would notice it on most).
|
||||
|
||||
---
|
||||
|
||||
## 4. Known no-ops and stubs (grepped, cited)
|
||||
|
||||
| Site | What's disabled |
|
||||
|---|---|
|
||||
| `src/AcDream.Runtime/Chat/ClientCommandId.cs:50-58` (`SetChatTitle`) + `RetailClientCommandCatalog.cs:258-269` (`SetTitle` definition) | `@title <text>` — retail sets the popup chat window's title bar (`ClientCommunicationSystem::DoTitle @0x0057A640`); acdream's binding "is a pure no-op (the value is neither stored nor consumed — no title-bar chrome exists to render it yet, AP-182)". Confirmed live: `FloatingChatWindowController.cs:204` hardcodes `$"Chat {windowId}"` with no seam for an external override. |
|
||||
| `src/AcDream.App/Input/GameplayInputCommandController.cs:208-213` | `InputAction.ToggleChatEntry` (Tab, bound at `KeyBindings.cs:251`) — the switch case's own comment says "IDevToolsGameplayCommands.FocusChatInput() retired... Tab is still consumed here, matching the prior no-op's 'handled' contract." **Harmless**: `UiRoot.OnKeyDown` (§3) independently handles Tab-to-focus-chat before/alongside this path, so functionally nothing is lost — but the `InputAction`/keybind plumbing for it is dead weight that could mislead a future reader into thinking this is the live mechanism. |
|
||||
| `src/AcDream.App/Input/GameplayInputCommandController.cs` (whole file) | `InputAction.EnterChatMode` (Enter, bound at `KeyBindings.cs:252`) has **no case at all** in `Handle`'s switch (`GameplayInputCommandController.cs:172-235`) — falls to `default: return false;`. Same "harmless because `UiRoot` does it independently" caveat as `ToggleChatEntry` above. |
|
||||
| `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs:634-696` (`TryMatchAllegiance`) | 9 of 12 `@allegiance` subcommands (boot/ban/officer/title/motd/name/lock/house/chat/broadcast) unported — issue #360, still OPEN (see §5). |
|
||||
| `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs:319-349` | `@day`/`@log`/`@render` recognized only by `/help <verb>`; execution falls through to server passthrough — issue #361, still OPEN (see §5). |
|
||||
| `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Entire `IPanel`-based class — see §6, dead code, not reachable from any production construction site. |
|
||||
|
||||
No other `deferred`/`TODO`/`stub`/`placeholder` hits inside `ChatWindowController.cs`, `FloatingChatWindowController.cs`, or the `Panels/Chat/` directory resolve to a genuine behavioral gap beyond what's listed above and in §5 — the remaining grep hits in those files are either doc-comment cross-references to *other* code's no-ops (e.g. `ChatWindowController.cs:230` explaining that the main filter is "not an inert no-op" — i.e. describing something that was FIXED) or historical narration.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open issues — current code status
|
||||
|
||||
| Issue | One-line verdict | Evidence |
|
||||
|---|---|---|
|
||||
| **#358** Ctrl+M mute chord never fires | **STALE — DONE.** `KeyBindings.RetailDefaults()` now binds Ctrl+M (`KeyBindings.cs`, per the fix note); root cause (binding added to the dead `AcdreamCurrentDefaults()` table) is fixed. Live-client verification of the actual mute effect was still owed at closure time — UNKNOWN whether that connected check ever ran. |
|
||||
| **#359** `0x019E` PlayerKilled prints to participants | **STILL OPEN.** `ChatLog.OnPlayerKilled` (`ChatLog.cs:188-206`) appends the death message unconditionally for every recipient — no `player_id == victim \|\| player_id == killer` guard exists anywhere in the method or its call site. |
|
||||
| **#360** `@allegiance`/`@house` only port simple subcommands | **STILL OPEN.** `RetailClientCommandCatalog.TryMatchAllegiance` (`RetailClientCommandCatalog.cs:656-696`) only recognizes `hometown`/`ho` and `info`; every other subcommand falls to `AllegianceUnrecognizedSubcommand`'s refusal text (`:689-695`). House subcommands correctly passthrough to ACE per the same file's `TryMatchHouse` comments (`:643`), but neither dispatcher executes the ~22 unported subcommands locally. |
|
||||
| **#361** `@day`/`@log`/`@render` recognized in help only | **STILL OPEN.** `RetailCommandHelpTable.cs:319-349` still carries the "NOT YET IMPLEMENTED in acdream" meta-tail for all three; `RetailClientCommandCatalog` has no `Day`/`Log`/`Render` client-command definitions with real handlers (only chat verbs actually wired execute; these three fall through to server passthrough, which is a silent no-op against ACE). |
|
||||
| **#362** Four CH4 outbound requests had no inbound handler | **STALE — DONE**, closed 2026-08-09 (`ClientCommandResponses.cs` parses `ChannelIndex`/`ChannelList`/`AvailableHouses`/`AllegianceInfoResponse`). |
|
||||
| **#363** Refusal sites typed `0x00` where retail types `0x1A` | **STALE — CLOSED 2026-08-10.** `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (`ChatVM.cs:159-183`) exists and is wired at `InteractionRetainedUiComposition.cs:472-473`; `ChatCommandRouter` routes every named site through it (confirmed at `ChatCommandRouter.cs:85-98,119-121,154,162`). |
|
||||
| **#366** New-unseen-text indicator (`0x1000048C`) imports but unwired | **STILL OPEN** (narrowed 2026-08-16 — the build/import half is fixed, the behavior half — what triggers it, what a click does — remains un-researched). No controller code references `0x1000048C` in `ChatWindowController.cs`. |
|
||||
| **#367** Local-presentation fallbacks land in chat scroll, not SpewBox | **STALE — CLOSED 2026-08-10**, closed as a side effect of #363 (same seam). |
|
||||
| **#369** Unconfirmed whether floaty windows share the main window's talk-focus channel | **STILL OPEN, unresearched.** `FloatingChatWindowController.cs:157` hardcodes Say; whether that's retail-correct is not established either way — filed as a research task, not yet picked up. |
|
||||
| **#372** Options panel Character/Chat/Config tabs render blank | **STALE — DONE** (blank-tabs half fixed at the `UiTemplateListBox` viewport-anchor level, not chat-specific; the tangential "13 Chat-tab filter labels resolve blank" sub-note was fixed by the `FilterStringTableId = 0x2300000D` correction visible at `ChatOptionsPageController.cs:96-105`). |
|
||||
| **#379** Chat opacity applied to all windows, not just chat | **STALE — DONE**, `RetailWindowOpacityController` now scoped to the 5 chat windows only. |
|
||||
| **#380** Chat tab opacity sliders missing row captions | **STALE — DONE**, `ChatOptionsPageController.cs:399-407,473-503` wires `SetOpacityCaption` from `ChatOptionsDatCaptions`. |
|
||||
| **#382** Floating-window indicator buttons invisible until hovered | **STALE — DONE**, `UiButton.TrySetRetailState` fix (unrelated file, general `UiButton` bug that happened to be discovered via the chat indicators). |
|
||||
|
||||
**Net: of the 12 chat-tagged issues checked, 4 remain genuinely open in code (#359, #360, #361, #366) plus one unresearched design question (#369).** The rest closed since the digest's 2026-08-09/10 snapshot but the digest's own "Open" section (still listing #358/#359/#360/#361/#362/#363) is now stale for #358/#362/#363 — worth a digest refresh independent of this audit.
|
||||
|
||||
---
|
||||
|
||||
## 6. Test coverage
|
||||
|
||||
**Well covered** (direct, behavior-level tests exist):
|
||||
- `ChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs` — bind success/failure, talk-focus specials (Squelch/Tell-to-selected), transcript parent/mode, transcript layout caching + out-of-range LogTextType fallback, input submit → `SendChatCmd`, channel-change updates submit channel, input-field resize/reflow (both with and without an imported `LayoutPolicy`), indicator open/closed/cross-window-isolation/out-of-range.
|
||||
- `FloatingChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs` — bind success/failure/invalid-window-id, transcript parent, input parent (floaty row vs main bar), input always-Say, per-window filter subset + filter-change reflection + cache reuse.
|
||||
- `ChatWindowState`: has its own filter/open/`ShouldDisplay` logic covered by construction (not independently verified in this pass, but the class is simple enough that the controller tests above exercise it transitively).
|
||||
- `UiField`: `tests/AcDream.App.Tests/UI/UiFieldTests.cs` — insert/caret, backspace, submit/clear/history-push, empty-submit no-op, history up/down, history 100-cap, two multi-line-after-shrink regression tests (the 2026-07-29 crash class), character filter, select-all-on-focus, read-only field, multi-line Enter-inserts-newline.
|
||||
- Command routing: `ChatCommandRouterTests.cs`, `ChatInputParserTests.cs`, `ChatInputParserAtPrefixTests.cs`, `RetailClientCommandCatalogTests.cs`, `RetailCommandHelpTableTests.cs`, `RetailCommandRegistryConformanceTests.cs` (bidirectional ownership-rule enforcement across the whole 152-verb registry).
|
||||
- Colors: `RetailChatColorTableTests.cs`.
|
||||
|
||||
**Gaps found — user-visible behaviors with zero automated coverage:**
|
||||
1. **`ChatWindowController.ToggleMaximize`** — no test anywhere calls it or exercises `CaptureWindowState`/`RestoreWindowState`. Grep for `ToggleMaximize`/`Maximiz` across `tests/` returns nothing. The growUp/clamp/DAT-constraint logic (`ChatWindowController.cs:537-580`, a direct port of `gmMainChatUI::HandleMaximizeButton`) is entirely unverified by automation — a regression here would only be caught by a human clicking the max/min button.
|
||||
2. **Floating window's Close button** — no test exercises `FloatingChatWindowController.cs:211-214`'s `OnClick` wiring (`WindowHandle?.Hide()`). Grep for `CloseButton` in `FloatingChatWindowControllerTests.cs` returns nothing.
|
||||
3. **`UiField` Escape handling** (or lack thereof — see §3 finding 1) — no test exists for Escape at all in `UiFieldTests.cs`; the absence of behavior is untested, meaning it could silently "start working" or silently regress further with no signal either way.
|
||||
4. **`UiField` clipboard (Ctrl+C/X/V) and Shift-selection** — none of `UiFieldTests.cs`'s 13 tests exercise `CopySelection`/`CutSelection`/`Paste`/Shift+arrow extension. `Selectable`-gating (§3) is only indirectly confirmed via the DAT-fixture conformance test (`ChatLayoutConformanceTests.cs:220-228`), which checks the *property resolves true*, not that copy/cut/select-all *actually work* once it's true.
|
||||
5. **`ChatPanel.cs` and its whole test suite are exercising dead code.** `ChatPanel` (`src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`) implements the old ImGui-era `IPanel` contract from the D.2a stack. `AcDream.UI.ImGui` no longer exists as a project (deleted at Campaign V slice V11 per `CLAUDE.md`), and a repo-wide grep for `new ChatPanel(` finds only the class's own constructor declaration — **no production code anywhere constructs a `ChatPanel`.** Its five test files (`ChatPanelFocusTests.cs`, `ChatPanelInputTests.cs`, `ChatPanelLayoutTests.cs`, plus the shared `ChatVMCombatTests.cs`/`ChatVMLastTellSenderTests.cs`/`ChatVMRetellAndProvidersTests.cs` that exercise `ChatVM` directly and remain legitimately live) still compile and pass, which gives a false impression of "chat input is covered" in a naive test-count read — the REAL live input surface is `UiField` + `ChatWindowController`, covered separately (and less deeply, per findings 1-4 above). This is worth flagging to whoever next touches chat tests: `ChatPanel.cs` and its three panel-specific test files are candidates for deletion (dead code, not a functioning fallback), not maintenance targets.
|
||||
|
||||
---
|
||||
|
||||
## 7. Prioritized gap list (most user-visible first)
|
||||
|
||||
This is the ordering to plan slices from — judgment calls, not a flat dump.
|
||||
|
||||
1. **Escape does nothing in the chat input (§3 finding 1).** Every retail player's muscle memory includes "Escape backs out of whatever I'm doing," and chat is the single most-used text-entry surface in the client. Right now it's a dead key while typing — worse than doing nothing wrong, because it silently swallows an action a user expects to work (defocus/clear), and if `WorldKeyFallThrough` were ever wired for something else, it would ALSO be swallowed by the exact-focus branch that already fails to handle it. This is a real, previously-untracked bug (no ISSUES.md entry), high frequency of exposure, small fix surface (add an Escape case to `UiField.OnEvent`'s `KeyDown` switch, decide clear-vs-defocus-vs-both against retail).
|
||||
2. **#360 — `@allegiance`/`@house` management subcommands (22 of them unported).** Highest-traffic gap by command surface area; already tracked, already scoped ("largest single item; deserves its own slice" per the issue's own text), needs byte-level wire verification before implementation (target-name/guid resolution, confirmation dialogs, multi-field payloads) rather than guessing.
|
||||
3. **#359 — PlayerKilled line double-prints for the victim/killer.** Small, well-scoped, single-method fix (`ChatLog.OnPlayerKilled` needs the local-player-guid participant check) with a clear retail citation already in the issue. Low effort, directly visible to anyone who dies or gets a kill in acdream.
|
||||
4. **#369 — floaty-window channel-sharing research.** Currently a design assumption (Say-always) shipped without verification. Low implementation cost either way once researched, but the research itself (`gmCCommunicationSystem`'s floaty send path) hasn't started. Worth resolving before more chat work builds on the current assumption.
|
||||
5. **`ToggleMaximize`/Close-button test coverage gap (§6.1/6.2).** Not a behavior bug — both features work per the code reading — but zero automated coverage on two interactive, DAT-geometry-dependent code paths (max/min clamping, close-then-reopen) is a latent regression risk given how much chat-adjacent layout churn this codebase has had (8+ chat-parity review rounds in the last two weeks alone).
|
||||
6. **#366 — new-unseen-text indicator inert.** Cosmetic/discoverability only; retail's exact trigger condition is still unresearched, so this can't be fixed correctly without that research first, and its absence doesn't block any other chat behavior.
|
||||
7. **#361 — `@day`/`@log`/`@render`.** Genuinely low-value: `@day` needs a renderer hook that doesn't exist yet (bigger than a chat fix), `@log` was deliberately deferred (file-handle lifecycle risk across reconnects), `@render` has no acdream render-option surface to bind to. Correctly the lowest priority of the open command-registry gaps.
|
||||
8. **`@title` no-op + hardcoded floaty titles (§4).** Cosmetic, single command, no other feature depends on it. Fine to bundle with a future title-bar-chrome pass rather than a standalone fix.
|
||||
9. **`ChatPanel.cs` dead-code cleanup (§6.5).** Not a behavior gap at all — it's hygiene. Flagging here rather than fixing inline per this audit's report-only scope; worth a small follow-up to delete the class and its now-misleading test files so future coverage audits don't need to re-discover this.
|
||||
10. **Missing autocomplete / word-jump editing conveniences (§3 findings 2, 4).** Lowest priority: unconfirmed whether retail even had these, and even if it did, they're minor efficiency features, not correctness or discoverability gaps.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: files read for this audit
|
||||
|
||||
- `src/AcDream.App/UI/Layout/ChatWindowController.cs`
|
||||
- `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs`
|
||||
- `src/AcDream.Core/Chat/ChatWindowState.cs`
|
||||
- `src/AcDream.Core/Chat/ChatLog.cs`
|
||||
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`
|
||||
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`
|
||||
- `src/AcDream.Runtime/Chat/ChatCommandRouter.cs`
|
||||
- `src/AcDream.Runtime/Chat/ClientCommandId.cs`
|
||||
- `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs` (partial)
|
||||
- `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs` (partial, grep-targeted)
|
||||
- `src/AcDream.App/UI/UiField.cs`
|
||||
- `src/AcDream.App/UI/UiMenu.cs` (partial)
|
||||
- `src/AcDream.App/UI/UiRoot.cs` (partial — key dispatch + focus)
|
||||
- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` (partial)
|
||||
- `src/AcDream.App/Input/GameplayInputCommandController.cs` (partial)
|
||||
- `src/AcDream.App/Input/InputCaptureSources.cs` (partial)
|
||||
- `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs`
|
||||
- `src/AcDream.App/UI/RetailUiRuntime.cs` (partial — persistence + mount)
|
||||
- `src/AcDream.App/UI/WindowNames.cs`
|
||||
- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (partial)
|
||||
- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (partial — Type-12 field build)
|
||||
- `tests/AcDream.App.Tests/UI/UiFieldTests.cs`
|
||||
- `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`
|
||||
- `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs`
|
||||
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` (partial)
|
||||
- `docs/ISSUES.md` (targeted sections: #358-#382 chat-tagged range)
|
||||
- `docs/architecture/retail-divergence-register.md` (targeted: AP-185 through AP-191)
|
||||
- `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_chat_digest.md`
|
||||
376
docs/research/2026-08-21-acdream-text-stack-audit.md
Normal file
376
docs/research/2026-08-21-acdream-text-stack-audit.md
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# acdream text-stack audit: seam for retail glyph-level tagged text
|
||||
|
||||
**Scope.** Research only — OUR codebase (`src/AcDream.App/UI/**`,
|
||||
`src/AcDream.UI.Abstractions/Panels/Chat/**`, `src/AcDream.Core/Chat/**`).
|
||||
Goal: determine what it takes to support retail's glyph-level tagged
|
||||
text (a differently-colored, clickable name inside an otherwise
|
||||
uniformly-colored chat line). No code changes made.
|
||||
|
||||
## 1. Current model — what is a rendered text line?
|
||||
|
||||
`UiText` (`src/AcDream.App/UI/UiText.cs`) is the one retained-UI text
|
||||
widget (`RegisterElementClass(0xc)`, class doc at `UiText.cs:10-22`).
|
||||
It has **two** display-line shapes, both single-color:
|
||||
|
||||
- **`Line`** — `UiText.cs:50`:
|
||||
`public readonly record struct Line(string Text, Vector4 Color);`
|
||||
One string, one `Vector4` color for the WHOLE string. This is what
|
||||
`LinesProvider` (`UiText.cs:62`, `Func<IReadOnlyList<Line>>`) returns
|
||||
and what the scrollable multi-line transcript path renders
|
||||
(`DrawClippedText`, `UiText.cs:636-691`). Color is **per-line**, not
|
||||
per-run: `lines[i].Color` (`UiText.cs:673`/`677`) is one value passed
|
||||
whole to `ctx.DrawStringDatPass`/`ctx.DrawString`.
|
||||
- **`TextRun`** — `UiText.cs:55`:
|
||||
`public readonly record struct TextRun(string Text, Vector4 Color);`
|
||||
Multiple colored fragments concatenated onto **one** authored line,
|
||||
fed by `RunsProvider` (`UiText.cs:69`,
|
||||
`Func<IReadOnlyList<TextRun>>?`) and drawn by `DrawSingleLineRuns`
|
||||
(`UiText.cs:693-754`). This is real per-run coloring — each run gets
|
||||
its own `ctx.DrawStringDatPass` call at its own pen X
|
||||
(`UiText.cs:725-743`) — but it is **only reachable when
|
||||
`OneLine == true`** (`UiText.cs:506-510`: `if (OneLine &&
|
||||
RunsProvider is { } runsProvider)`), i.e. the static single-line
|
||||
label path. The chat transcript is NOT `OneLine` (`ChatWindowController.cs:320`:
|
||||
`c.Transcript.OneLine = false;`), so it can never reach
|
||||
`DrawSingleLineRuns` — the scrollable multi-line path only ever
|
||||
reads `LinesProvider`/`Line`.
|
||||
|
||||
**Answer to Q1:** color today is per-`Line` in the transcript
|
||||
(scrollable, multi-line, bottom-pinned/word-wrapped) path, and
|
||||
per-`TextRun` only in the unrelated static single-line label path
|
||||
(currently used by exactly one controller — see §5/§6). Chat uses the
|
||||
former exclusively.
|
||||
|
||||
## 2. Where the flattening happens (the key finding)
|
||||
|
||||
`ChatEntry` (`src/AcDream.Core/Chat/ChatLog.cs:499-536`) is a
|
||||
structured record: `Sender` (string), `SenderGuid` (uint), `Text`,
|
||||
`ChannelId`/`ChannelName`, `Kind`, `LogTextType`. The sender's identity
|
||||
survives as a distinct field all the way through `ChatLog`.
|
||||
|
||||
It is destroyed in **two** steps inside `ChatVM`
|
||||
(`src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`), and the second
|
||||
step is the point of no return:
|
||||
|
||||
**Step A — string composition.** `ChatVM.FormatEntry`
|
||||
(`ChatVM.cs:262-313`) string-interpolates `entry.Sender` directly into
|
||||
the message prose, e.g. for `ChatKind.LocalSpeech`
|
||||
(`ChatVM.cs:269-271`):
|
||||
```
|
||||
ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender)
|
||||
? $"You say, \"{entry.Text}\""
|
||||
: $"{entry.Sender} says, \"{entry.Text}\"",
|
||||
```
|
||||
After this call the sender name is prose inside one `string`; there is
|
||||
no longer a machine-readable boundary marking where "Name" ends and
|
||||
"says, ..." begins.
|
||||
|
||||
**Step B — metadata drop (the actual point of no return).**
|
||||
`ChatVM.RecentLinesDetailed()` (`ChatVM.cs:345-371`) builds the
|
||||
`FormattedLine` record (`ChatVM.cs:384-388`):
|
||||
```
|
||||
public readonly record struct FormattedLine(
|
||||
string Text,
|
||||
ChatKind Kind,
|
||||
CombatLineKind? CombatKind,
|
||||
uint LogTextType);
|
||||
```
|
||||
`FormattedLine` does **not** carry `Sender` or `SenderGuid` at all —
|
||||
only the composed `Text`, `Kind`, `CombatKind`, and the retail color
|
||||
key `LogTextType`. Every downstream consumer
|
||||
(`ChatWindowController.GetTranscriptLines`, `ChatWindowController.cs:736-781`,
|
||||
which calls `vm.RecentLinesDetailed()` at `ChatWindowController.cs:753`,
|
||||
then `ChatTranscriptRenderer.BuildLines`,
|
||||
`src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs:67-95`) only ever
|
||||
sees the flat `Text` string plus one `LogTextType` per entry.
|
||||
`ChatTranscriptRenderer.BuildLines` then assigns exactly **one**
|
||||
`Vector4 currentColor` per entry (resolved once from `LogTextType` at
|
||||
`ChatTranscriptRenderer.cs:89`) and stamps every word-wrapped fragment
|
||||
of that entry with that single color (`ChatTranscriptRenderer.cs:90-93`):
|
||||
```
|
||||
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
|
||||
currentColor = resolved;
|
||||
foreach (string frag in WrapText(d.Text, maxW, measure))
|
||||
result.Add(new UiText.Line(frag, currentColor));
|
||||
```
|
||||
|
||||
**So: the sender's identity (name + guid) is available up through
|
||||
`ChatLog`/`ChatEntry`, is baked into prose by `ChatVM.FormatEntry`, and
|
||||
is then dropped entirely — not merely flattened, but discarded — by
|
||||
`ChatVM.RecentLinesDetailed`'s `FormattedLine` shape.** By the time a
|
||||
`UiText.Line` exists, there is no span boundary, no guid, and (because
|
||||
word-wrap has already run) not even a guarantee that "the sender name"
|
||||
is wholly contained within a single rendered `Line` if it happened to
|
||||
sit at a wrap boundary. Any attempt to recover "where is the name"
|
||||
downstream of this point would have to regex/string-match the composed
|
||||
prose back apart — fragile (a message body containing the speaker's
|
||||
own name, or a name that is a prefix of a common word, breaks it) and
|
||||
still has no guid to attach for the click action.
|
||||
|
||||
## 3. Draw path
|
||||
|
||||
**One call per line/run, never per-glyph-color-batch beyond that.**
|
||||
The scrollable path (`DrawClippedText`, `UiText.cs:636-691`) issues one
|
||||
`ctx.DrawStringDatPass(datFont, text, x, y, color, isOutlinePass)`
|
||||
(`UiText.cs:687-689`) per visible `Line` (whole wrapped fragment, one
|
||||
color). The DAT-font backend (`UiRenderContext.DrawStringDatPass`,
|
||||
`src/AcDream.App/UI/UiRenderContext.cs:320-367`) walks every glyph in
|
||||
that ONE string with ONE `tint` (`UiRenderContext.cs:321`, `tint`
|
||||
passed once, applied per-glyph at `UiRenderContext.cs:359-362`) — there
|
||||
is no per-glyph or per-substring color inside a single
|
||||
`DrawStringDatPass` call.
|
||||
|
||||
**Drawing N differently-colored runs on one visual line requires N
|
||||
separate `DrawStringDatPass` calls, each starting at its own pen X** —
|
||||
this is exactly the mechanism `DrawSingleLineRuns`
|
||||
(`UiText.cs:693-754`) already uses: it measures each run's width
|
||||
(`datFont.MeasureWidth(run.Text)`, `UiText.cs:709`/`736`), accumulates
|
||||
a `penX` (`UiText.cs:731-737`), and issues one
|
||||
`ctx.DrawStringDatPass(datFont, run.Text, run.X, y, run.Color,
|
||||
isOutlinePass: false)` per run (`UiText.cs:742-743`). The outline pass
|
||||
is batched block-wide first (all runs' outlines, then all runs' fills —
|
||||
`UiText.cs:739-743`, same reasoning as the multi-line block-batching
|
||||
documented at `UiRenderContext.cs:306-318`) so a per-run outline
|
||||
doesn't notch an adjacent run's descender.
|
||||
|
||||
**Yes, the DAT font path supports starting a draw at an arbitrary X
|
||||
offset and measuring a substring's width.** `UiDatFont.MeasureWidth(string
|
||||
text)` (`src/AcDream.App/UI/UiDatFont.cs:160-172`) sums per-glyph
|
||||
advances for any string/substring — already used for substring
|
||||
measurement in the selection-highlight code
|
||||
(`UiText.cs:656-657`/`661-662`, `datFont.MeasureWidth(text.Substring(0,
|
||||
c0))`). `DrawStringDatPass`/`DrawStringDat` take an arbitrary `float x`
|
||||
(`UiRenderContext.cs:277-278`, `320-321`) with no assumption it starts
|
||||
at the element's left edge. The bitmap-font fallback (`BitmapFont.cs`,
|
||||
`MeasureWidth` at `BitmapFont.cs:167`) and `UiRenderContext.DrawString`
|
||||
(`UiRenderContext.cs:188-203`, also takes an arbitrary `float x`) mirror
|
||||
the same capability. **Conclusion: the low-level draw primitives
|
||||
already support everything a run-based transcript line needs — no
|
||||
renderer/font work is required, only a widget-level model change to
|
||||
call them N times instead of once.**
|
||||
|
||||
## 4. Hit-testing
|
||||
|
||||
**No sub-line hit-testing exists today; click/hover route to whole
|
||||
elements, never to a text span.** `UiRoot.HitTestTopDown`
|
||||
(`src/AcDream.App/UI/UiRoot.cs:1410-1430`) walks the retained tree via
|
||||
`UiElement.HitTest` (`src/AcDream.App/UI/UiElement.cs:705-731`), which
|
||||
recurses into children and, failing that, calls the virtual
|
||||
`OnHitTest(localX, localY)` (`UiElement.cs:563-564`, default is a
|
||||
rectangle containment check) — the granularity is always "some
|
||||
`UiElement`", never "some substring of a `UiElement`'s text." A
|
||||
resolved hit becomes a `Click` `UiEvent` at `UiRoot.OnMouseUp`
|
||||
(around `UiRoot.cs:994-997`) and bubbles via
|
||||
`UiRoot.BubbleEvent`/`UiElement.OnEvent` (`UiRoot.cs:1516-1525`,
|
||||
`UiElement.cs:571`). `UiText.OnEvent`'s `Click` case
|
||||
(`UiText.cs:809-813`) fires the single `OnClick` delegate for the
|
||||
WHOLE element — there is no notion of "which run was clicked."
|
||||
|
||||
**The pieces needed already exist, just not wired to `Click`.**
|
||||
`UiText.HitChar(float localX, float localY)` (`UiText.cs:1031-1050`)
|
||||
already converts a local point into a `Pos(line, col)` caret position
|
||||
using the cached draw geometry (`_lastLines`/`_lastBaseY`/
|
||||
`_lastLineHeight`, `UiText.cs:268-273`) and a per-character advance
|
||||
lookup (`UiText.cs:1042-1048`, works for both `UiDatFont` and
|
||||
`BitmapFont`) — but it is currently invoked only from the
|
||||
selection-drag path (`MouseDown`/`MouseMove` cases, `UiText.cs:826-847`),
|
||||
gated behind `Selectable` (`UiText.cs:828`, `839`). The chat transcript
|
||||
IS `Selectable = true` (`ChatWindowController.cs:321`), so `HitChar`
|
||||
already runs on every mouse-down inside the transcript — it is simply
|
||||
never asked "which run (if any) covers this `(line, col)`", because
|
||||
`Line` carries no runs to check against.
|
||||
|
||||
**What mapping a click to a run would need:**
|
||||
1. A per-line list of run boundaries (start col, end col, and a
|
||||
payload — e.g. sender guid) reaching `UiText` alongside the text,
|
||||
which does not exist today (`Line` has no such field, see §1/§2).
|
||||
2. `HitChar`'s existing `(line, col)` result checked against that
|
||||
list — this is a small, local addition to `UiText`, not a new
|
||||
hit-test mechanism.
|
||||
3. A dispatch from "run payload resolved" to an actual action (e.g.
|
||||
pre-filling a `/tell <name>` in the chat input) — analogous to the
|
||||
existing `OnClick` delegate, but keyed by run rather than by
|
||||
element.
|
||||
|
||||
No retail-side click semantics (what a name-click does) were
|
||||
researched here — that is a different agent's lane per the task brief.
|
||||
|
||||
## 5. Seam proposal
|
||||
|
||||
**Given Code Structure Rules (CLAUDE.md "Code Structure Rules" §1-3):**
|
||||
- `AcDream.Core` must not depend on window/GL/backend projects (rule 2)
|
||||
— `ChatEntry`/`ChatLog` (Core) can carry the STRUCTURED data a tagged
|
||||
run needs (sender name + guid + explicit text-span boundaries) but
|
||||
must not know about `Vector4`/GL/rendering.
|
||||
- UI panels target `AcDream.UI.Abstractions` only (rule 3) — the
|
||||
composition of "structured entry -> ordered list of colored,
|
||||
optionally-actionable spans" is exactly the kind of pure formatting
|
||||
logic `ChatVM` already owns (`ChatVM.FormatEntry`/
|
||||
`RecentLinesDetailed`, `ChatVM.cs:262-371`) and should keep owning;
|
||||
it must not reach into `AcDream.App` (GL/rendering) types.
|
||||
- The retained-widget rendering (`AcDream.App/UI/UiText.cs`) is where
|
||||
GL-adjacent draw calls (`DrawStringDatPass`) and Silk.NET-adjacent
|
||||
hit-testing (`HitChar`, `UiRoot`) live, and must stay there.
|
||||
|
||||
**Proposed layering (three seams, one per project boundary):**
|
||||
|
||||
1. **`AcDream.UI.Abstractions` (data shape)** — introduce a
|
||||
run-carrying line shape parallel to (not replacing) `FormattedLine`.
|
||||
Sketch: `FormattedLine` gains an optional ordered list of spans, or
|
||||
a new `RichFormattedLine(IReadOnlyList<FormattedRun> Runs, ...)` is
|
||||
added, where `FormattedRun` is something like
|
||||
`(string Text, uint? ActorGuid, bool IsSpeakerName)` — deliberately
|
||||
NOT carrying a color yet (`AcDream.UI.Abstractions` has no
|
||||
`System.Numerics`/GL dependency requirement today, but keeping
|
||||
color resolution in `AcDream.App` mirrors the existing
|
||||
`RetailChatColorTable`/`ChatTranscriptRenderer` split, where
|
||||
`ChatVM` supplies `LogTextType`/structured data and
|
||||
`ChatTranscriptRenderer` in `AcDream.App` resolves it to `Vector4`).
|
||||
`ChatVM.FormatEntry` (`ChatVM.cs:262-313`) would need a sibling that
|
||||
returns spans instead of one interpolated string — e.g. split each
|
||||
`case` into "prefix run" / "sender run" / "suffix run" instead of a
|
||||
single `$"..."` — and `RecentLinesDetailed` would carry
|
||||
`entry.Sender`/`entry.SenderGuid` through instead of discarding them
|
||||
(the §2 fix).
|
||||
2. **`AcDream.App/UI/Layout` (composition)** — `ChatTranscriptRenderer.BuildLines`
|
||||
(`ChatTranscriptRenderer.cs:67-95`) is the existing per-controller-
|
||||
shared seam that already resolves `LogTextType -> Vector4` and
|
||||
word-wraps. It would gain a variant that word-wraps a RUN LIST
|
||||
instead of a flat string per entry, producing a new "rendered line
|
||||
with runs" shape (see below) instead of `UiText.Line`. Both
|
||||
`ChatWindowController` (`ChatWindowController.cs:778`) and
|
||||
`FloatingChatWindowController` (same shared function, per the class
|
||||
doc at `ChatTranscriptRenderer.cs:9-15`) would switch to the new
|
||||
builder — this is the one place both chat surfaces already share,
|
||||
so it is the natural single point of change for chat specifically.
|
||||
3. **`AcDream.App/UI/UiText.cs` (widget)** — this is where the actual
|
||||
gap is. `Line` needs an additive `Runs` concept for the
|
||||
MULTI-LINE (`OneLine == false`) path, not just the existing
|
||||
`OneLine`+`TextRun` path (§1). Minimal shape: extend `Line` (or add
|
||||
a parallel `RichLine`) to carry `IReadOnlyList<TextRun>` alongside
|
||||
or instead of a flat `string Text` + single `Vector4 Color`; change
|
||||
`DrawClippedText`'s multi-line loop (`UiText.cs:636-691`) to, for a
|
||||
line with runs, do what `DrawSingleLineRuns` already does per-run
|
||||
(walk runs, accumulate `penX`, call `DrawStringDatPass` per run,
|
||||
batch all outlines-then-all-fills at the BLOCK level exactly as
|
||||
`UiText.cs:681-690` already batches across LINES today — extending
|
||||
that batching one level deeper, across runs within lines, is
|
||||
mechanical). `HitChar` (`UiText.cs:1031-1050`) needs to additionally
|
||||
resolve which run (if any) contains the hit `col`, and `OnEvent`'s
|
||||
`Click` case (`UiText.cs:809-813`) needs a second dispatch path
|
||||
(run-click, distinct from whole-element `OnClick`) that a controller
|
||||
(e.g. `ChatWindowController`) can bind to "prefill a tell to this
|
||||
guid," mirroring how `OnClick` is bound today.
|
||||
|
||||
**Existing abstraction that already almost does this:**
|
||||
`TextRun`/`RunsProvider`/`DrawSingleLineRuns` (§1, `UiText.cs:55,69,693-754`)
|
||||
is the closest precedent — it proves the draw-side mechanics (measure
|
||||
run, accumulate pen, per-run `DrawStringDatPass`, block-batched
|
||||
outline) already work and are exercised in production by
|
||||
`CharacterStatController.BuildSelectedTitleRuns`
|
||||
(`src/AcDream.App/UI/Layout/CharacterStatController.cs:1425-1454`,
|
||||
wired at `CharacterStatController.cs:1680`) for a skill/attribute title
|
||||
with a colored numeric delta suffix. It is currently scoped to
|
||||
`OneLine` only and carries no click/actor payload — extending it to
|
||||
the multi-line/wrapped path and adding a payload field is smaller than
|
||||
building a new mechanism from scratch. `DatRichText`
|
||||
(`src/AcDream.App/UI/Layout/DatRichText.cs`, `Segment(string? Text,
|
||||
Vector4 Color)` at `DatRichText.cs:40`, `Compose` at
|
||||
`DatRichText.cs:52-88`) is a second, partially-overlapping precedent:
|
||||
it already composes multiple colored segments for a multi-line box,
|
||||
but it word-wraps EACH segment independently and concatenates the
|
||||
results as separate `Line`s (`DatRichText.cs:83-84`) — so two segments
|
||||
that would visually share one wrapped row are NOT joined onto that row
|
||||
today; it solves "multiple colors across a paragraph's several lines,"
|
||||
not "multiple colors sharing one rendered row." A tagged-name-in-chat
|
||||
feature needs the latter (the name and the rest of the sentence share
|
||||
row 0 of a possibly-multi-row wrapped message), so neither existing
|
||||
mechanism is a drop-in — both inform the shape of the fix.
|
||||
|
||||
## 6. Blast radius
|
||||
|
||||
`UiText.Line`/`LinesProvider` is used extremely broadly — 52 files
|
||||
reference `LinesProvider = ` and 53 reference `UiText.Line(`/`new
|
||||
UiText()` (full grep list retained below). **If the change is additive**
|
||||
(new optional `Runs` field/type alongside the existing `Line`, default
|
||||
behavior unchanged for every caller that keeps returning plain
|
||||
`Line`s), the blast radius for BEHAVIOR is limited to whichever
|
||||
controllers opt in (initially: chat only). The blast radius for
|
||||
BUILD/COMPILE risk (anything that touches `UiText.cs`, `UiElement.cs`
|
||||
recompiles the whole `AcDream.App` UI layer) and for REVIEW is the full
|
||||
list below, grouped by category — every one of these should be
|
||||
smoke-tested after a `UiText`-internal change even if it doesn't touch
|
||||
their own code:
|
||||
|
||||
- **Chat (the actual feature target):**
|
||||
`ChatWindowController.cs`, `FloatingChatWindowController.cs`,
|
||||
`ChatTranscriptRenderer.cs`, `SpewBoxController.cs`
|
||||
(`src/AcDream.App/UI/SpewBoxController.cs` — retail's other
|
||||
colored-text-scroll surface, `RetailLogTextType.ClientLocal` per
|
||||
`ChatVM.cs:159-183`; likely wants the SAME run model eventually since
|
||||
it renders `LogTextType`-colored lines too).
|
||||
- **Tooltips:** `RetailTooltipPresenter.cs` — world/UI hover tooltips;
|
||||
currently plain `Line`s.
|
||||
- **Appraisal / item & creature reports:** `AppraisalUiController.cs`,
|
||||
`CreatureAppraisalRows.cs`, `ItemAppraisalReport.cs` — these already
|
||||
render multi-colored informational text (spell names, damage types)
|
||||
as SEPARATE `Line`s per colored fragment (one color per whole line,
|
||||
not per run) — a run model could simplify these, or they could stay
|
||||
as-is if row-granularity coloring already meets retail fidelity
|
||||
there (not assessed here — out of this audit's scope).
|
||||
- **Social panels:** `SocialSquelchPageController.cs`,
|
||||
`SocialAllegiancePageController.cs`,
|
||||
`SocialFellowshipPageController.cs`, `SocialFriendsPageController.cs`
|
||||
— friends/allegiance/fellowship rows; per CLAUDE.md's Campaign FA
|
||||
notes these already do per-state color swaps on `UiText`, a
|
||||
different (not run-based) mechanism.
|
||||
- **Vendor / trade:** `VendorUiController.cs`,
|
||||
`SecureTradeUiController.cs`.
|
||||
- **Combat / spellcasting:** `CombatUiController.cs`,
|
||||
`SpellcastingUiController.cs`, `EffectsUiController.cs`.
|
||||
- **Dialogs:** `RetailWaitDialogView.cs`, `RetailMessageDialogView.cs`,
|
||||
`RetailConfirmationDialogView.cs`,
|
||||
`RetailConfirmationTextInputDialogView.cs`.
|
||||
- **Character sheet / creation:** `CharacterStatController.cs` (the
|
||||
existing `TextRun` consumer, §5), `CharacterCreationSkillsPage.cs`,
|
||||
`CharacterCreationSummaryPage.cs`, `CharacterCreationTownPage.cs`,
|
||||
`CharacterCreationProfessionPage.cs`,
|
||||
`CharacterCreationHeritagePage.cs`,
|
||||
`CharacterManagementUiController.cs`.
|
||||
- **Options / config:** `ConfigOptionsPageController.cs`,
|
||||
`ChatOptionsPageController.cs`, `CharacterOptionsPageController.cs`,
|
||||
`KeyboardConfigController.cs`.
|
||||
- **Misc panels:** `InventoryController.cs`, `RadarController.cs`,
|
||||
`MapPageController.cs`, `HousePageController.cs`,
|
||||
`LinkStatusUiController.cs`, `VitaeUiController.cs`,
|
||||
`VitalsController.cs`, `RetailFpsController.cs`,
|
||||
`SelectedObjectController.cs`, `IndicatorDetailText.cs`,
|
||||
`ComponentBookTemplateFactory.cs`, `EffectRowTemplateFactory.cs`,
|
||||
`CharacterController.cs`, `DatWidgetFactory.cs` (the factory that
|
||||
builds every `UiText` from LayoutDesc — touches all of the above by
|
||||
construction).
|
||||
- **Tests:** `UiTextTests.cs`,
|
||||
`SocialFellowshipPageControllerTests.cs`,
|
||||
`SocialPanelControllerTests.cs`, `RowTemplateResolverTests.cs`,
|
||||
`DatWidgetFactoryTests.cs`, `CharacterStatControllerTests.cs`,
|
||||
`VitalsBindingTests.cs`, `AppraisalUiControllerTests.cs` — any of
|
||||
these that assert on `UiText.Line` shape/count would need review if
|
||||
`Line`'s shape changes (not if a new type is added additively).
|
||||
|
||||
**Net:** an ADDITIVE seam (new run-carrying line type, existing `Line`
|
||||
untouched) keeps the functional blast radius to chat (and optionally
|
||||
SpewBox) while still requiring the whole `AcDream.App/UI` tree to
|
||||
rebuild/retest since it all depends on `UiText.cs`/`UiElement.cs`. A
|
||||
seam that changes `Line`'s existing shape would force a review pass
|
||||
across every file in the list above.
|
||||
|
||||
## What this audit did NOT do
|
||||
|
||||
- Did not research retail's own tagged-glyph-run mechanism (separate
|
||||
agent's lane per the task brief).
|
||||
- Did not propose or write any code change — `Line`/`TextRun`/`Segment`
|
||||
shapes above are illustrative sketches for sizing, not a spec.
|
||||
- Did not assess whether `AppraisalUiController`/`CreatureAppraisalRows`'s
|
||||
existing one-color-per-`Line` approach is already retail-faithful for
|
||||
their own content (out of scope; flagged only as a blast-radius
|
||||
member).
|
||||
543
docs/research/2026-08-21-chat-tag-click-dispatch.md
Normal file
543
docs/research/2026-08-21-chat-tag-click-dispatch.md
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
# Chat log click-to-tag dispatch (retail, Sept 2013 EoR build)
|
||||
|
||||
Research-only. Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
||||
(PDB-named pseudo-C) and `docs/research/named-retail/acclient.h` (verbatim
|
||||
retail struct layouts). Every claim below is cited `symbol @ 0xADDRESS`.
|
||||
Binary Ninja's rendering caveats (misleading compare idioms, ~33-char
|
||||
truncated inline strings) are called out inline wherever they bit this
|
||||
investigation.
|
||||
|
||||
## TL;DR
|
||||
|
||||
The suspected anchor `ChatInterface::SetReplyTextInChatBox @ 0x004F4760` is
|
||||
**not** the click handler. It is a keyboard text-replacement macro
|
||||
(`/t `, `/tell `, `reply ` + space → `@tell <LastTeller>,`) wired through
|
||||
`ChatInterface::HandleTextReplacements @ 0x004F50D0`, itself fired from a
|
||||
"character typed" UI broadcast, not a mouse event. It happens to share the
|
||||
`"@tell %s, "` idea with the real click path but is a separate code path
|
||||
with separate (looser) text.
|
||||
|
||||
The real click path is a generic, polymorphic **tag** system:
|
||||
|
||||
```
|
||||
mouse button up over a UIElement_Text
|
||||
→ UIElement_Text::MouseUp @ 0x004694F0
|
||||
→ UIElement_Text::DeterminePositionFromXY @ 0x004688F0 (screen xy → glyph index)
|
||||
→ GlyphList::InqGlyph @ 0x00473430 (glyph index → Glyph, incl. m_tag)
|
||||
→ TextTag::HandleClick (virtual, vtable+0x14) (dispatch by tag TYPE)
|
||||
TextTag_IIDString::HandleClick @ 0x00478840
|
||||
→ ECM_UI::SendNotice_TextTag_IIDStringClick @ 0x006927C0
|
||||
→ gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10
|
||||
(gate: tag m_type == 0x10000001, chat entry not already focused)
|
||||
→ ChatInterface::StartTell @ 0x004F41F0
|
||||
(writes "@tell <Name>, " into the entry, focuses it)
|
||||
```
|
||||
|
||||
Player/speaker names in chat are wrapped by the server/client text
|
||||
formatters in a `<Tell:IIDString:<iid>:<name>>displayText<\Tell>` markup
|
||||
span (note: closing marker is a **backslash**, `<\Tell>`, not a
|
||||
forward-slash — confirmed from the raw literal at
|
||||
`data_7d0bfc @ 0x007D0BFC` etc.). This markup is used for direct tells,
|
||||
channel "says" lines, `[Fellowship]`, `[Co-Vassals]`, patron/vassal lines,
|
||||
and (per the generic `[%ws] <Tell:IIDString:0:%ws>...` format at
|
||||
`data_7e83e8 @ 0x007E83E8`) ordinary named-channel chat too — i.e. **every**
|
||||
chat line that shows a speaker name embeds the same tag, not just tells.
|
||||
Clicking any of them always opens a **tell**, regardless of which channel
|
||||
the line came from.
|
||||
|
||||
---
|
||||
|
||||
## 1. Click → glyph → tag resolution
|
||||
|
||||
### 1a. Entry point: `UIElement_Text::MouseUp`
|
||||
|
||||
`UIElement_Text::MouseUp @ 0x004694F0` is registered directly in
|
||||
`UIElement_Text`'s vtable slot for `MouseUp` (confirmed at the vtable dump,
|
||||
e.g. `0079C1A4: MouseUp = UIElement_Text::MouseUp`). The relevant tail,
|
||||
reached only when the mouse-up's button id was previously recorded as
|
||||
mouse-down over this same element (`cond:0`, looked up in
|
||||
`this->m_mouseDownTable` keyed by the button id `arg4`):
|
||||
|
||||
```c
|
||||
// UIElement_Text::MouseUp @ 0x004694F0, tail (0x0046959C-0x004695DD)
|
||||
if (eax_4 != 0) // cond:0 — this button's mouse-down WAS on this element
|
||||
{
|
||||
uint32_t eax_6 = UIElement_Text::DeterminePositionFromXY(this, ebp_2, edi_2);
|
||||
Glyph var_24;
|
||||
if (GlyphList::InqGlyph(&this->m_glyphList, eax_6, &var_24) != 0 && var_4 != 0)
|
||||
*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4); // var_4->HandleClick(arg4)
|
||||
Glyph::~Glyph(&var_24);
|
||||
}
|
||||
```
|
||||
|
||||
`ebp_2`/`edi_2` are the mouse position converted to element-local,
|
||||
margin-adjusted coordinates a few lines earlier in the same function:
|
||||
|
||||
```c
|
||||
int32_t ebp_2 = ((arg2 - this->m_margL) - UIRegion::GetScreenX0(this));
|
||||
int32_t edi_2 = ((arg3 - this->m_margU) - UIRegion::GetScreenY0(this));
|
||||
```
|
||||
|
||||
`arg2`/`arg3` are the raw screen-space mouse coordinates passed down from
|
||||
the UI event system; `m_margL`/`m_margU` are the element's left/top text
|
||||
margins (`UIElement_Text` struct, `acclient.h:53412-53415`).
|
||||
|
||||
**BN caveat**: `var_4` (the pointer used for the `var_4 != 0` check and the
|
||||
virtual call) is never shown being assigned in the decompiled output — it
|
||||
is almost certainly `var_24.m_tag` after `GlyphList::InqGlyph` copies the
|
||||
found `Glyph` into `var_24` via `Glyph::operator=`, but the copy-into-field
|
||||
step is not visible in this rendering. This is exactly the kind of
|
||||
"misleading compare idiom" the project's BN caveat warns about — flagged
|
||||
rather than silently assumed. The surrounding evidence (struct layout,
|
||||
vtable offset match below) makes this the only coherent reading, but it is
|
||||
not a directly-visible assignment.
|
||||
|
||||
### 1b. Screen XY → glyph index: `UIElement_Text::DeterminePositionFromXY`
|
||||
|
||||
`UIElement_Text::DeterminePositionFromXY @ 0x004688F0`:
|
||||
|
||||
```c
|
||||
int80_t UIElement_Text::DeterminePositionFromXY(this, arg2 /*local x*/, arg3 /*local y*/)
|
||||
{
|
||||
UIElement_Text::RecalculateGlyphList(this); // ensure wrapped-line layout is current
|
||||
int32_t scrolledY = this->m_iScrollableY + arg3; // undo vertical scroll offset
|
||||
uint32_t line = 0;
|
||||
GlyphList::FindLineFromY(&this->m_glyphList, scrolledY, &line); // which wrapped line
|
||||
uint32_t lineWidthPx = 0;
|
||||
GlyphList::GetGlyphLineWidth(&this->m_glyphList, line, &lineWidthPx); // that line's pixel width
|
||||
int32_t lineLocalX = (this->m_iScrollableX + arg2)
|
||||
- UIElement_Text::CalcJustification(this, lineWidthPx, 1); // undo h-scroll + justification
|
||||
uint32_t glyphIndex = 0;
|
||||
GlyphList::FindPosFromLineAndPixels(&this->m_glyphList, line, lineLocalX, 1, &glyphIndex);
|
||||
// clamp to end-of-text
|
||||
return min(glyphIndex, this->m_glyphList.m_glyphList._num_elements);
|
||||
}
|
||||
```
|
||||
|
||||
Plain-language: convert the click's local (x, y) into a *scrolled* position
|
||||
by adding back however far the text view has been scrolled; use the
|
||||
scrolled Y to find which **wrapped display line** was clicked
|
||||
(`GlyphList::FindLineFromY @ 0x00472770`); measure that line's pixel width
|
||||
(`GlyphList::GetGlyphLineWidth @ 0x00472930`) so the justification offset
|
||||
(left/center/right alignment, `UIElement_Text::CalcJustification @
|
||||
0x00467260`) can be subtracted back out of the X; then walk that line's
|
||||
glyph advances to find which **character index** the X pixel falls on
|
||||
(`GlyphList::FindPosFromLineAndPixels @ 0x004732D0`). The result is a
|
||||
single integer: "the click landed on/before character N of the full
|
||||
(unwrapped) text buffer."
|
||||
|
||||
### 1c. Glyph index → Glyph (and its tag): `GlyphList::InqGlyph`
|
||||
|
||||
`GlyphList::InqGlyph @ 0x00473430`:
|
||||
|
||||
```c
|
||||
uint8_t GlyphList::InqGlyph(this, arg2 /*index*/, arg3 /*out Glyph*/)
|
||||
{
|
||||
ListNode<Glyph>* node = this->m_glyphList._head;
|
||||
if (node == 0 || arg2 >= this->m_glyphList._num_elements) return 0;
|
||||
for (int i = 0; i != arg2; i++) { node = node->next; if (node == 0) return 0; }
|
||||
Glyph::operator=(arg3, node); // copy the whole Glyph struct, incl. m_tag
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
A straight O(n) linked-list walk (the glyph list is a `List<Glyph>`, not an
|
||||
array) to the Nth glyph, then a struct copy. The `Glyph` layout
|
||||
(`acclient.h:45330-45338`):
|
||||
|
||||
```c
|
||||
struct __cppobj Glyph
|
||||
{
|
||||
unsigned __int16 m_data; // the character code
|
||||
int m_width;
|
||||
int m_height;
|
||||
RGBAColor m_color; // per-glyph color, baked in at append time (see §4)
|
||||
Font *m_font;
|
||||
TextTag *m_tag; // non-null only for glyphs inside a <...> tag span
|
||||
};
|
||||
```
|
||||
|
||||
`m_tag` is set by `Glyph::SetTag @ 0x00474920` while the glyph list is
|
||||
built from raw text (see §1d), and cleared in bulk by
|
||||
`GlyphList::RemoveTextTag @ 0x00472BB0` (walks every glyph, clears any
|
||||
whose `m_tag` matches the tag being removed — used when a tagged span is
|
||||
truncated/deleted from the log).
|
||||
|
||||
### 1d. Building tags from markup: `TextTagFactory::MakeTag`
|
||||
|
||||
Text is appended to a `UIElement_Text` glyph list via
|
||||
`UIElement_Text::InqGlyphs @ 0x00468EA0`. Its char-scan loop treats `<`
|
||||
(0x3C) as the start of a tag span: it accumulates characters up to the
|
||||
matching `>` (0x3E) into a string, then calls
|
||||
`TextTagFactory::MakeTag @ 0x00478480` on that whole inner string (e.g.
|
||||
`"Tell:IIDString:1234:PlayerName"`):
|
||||
|
||||
```c
|
||||
TextTag* TextTagFactory::MakeTag(PStringBase<unsigned short> const* tagBody)
|
||||
{
|
||||
// tagBody looks like "TypeName:ShapeName:payload..."
|
||||
if (!FindChar(tagBody, ':')) return 0;
|
||||
typeNameStr = substring-before-first-colon;
|
||||
if (EnumMapper::InqEnum(0x18 /*category*/, typeNameStr, &tagType) == 0) return 0; // e.g. "Tell" -> 0x10000001
|
||||
|
||||
if (!FindChar(rest, ':')) return 0;
|
||||
shapeNameStr = substring-before-second-colon; // e.g. "IIDString"
|
||||
if (EnumMapper::InqEnum(0x18, shapeNameStr, &shapeId) == 0) return 0; // 1..4
|
||||
|
||||
switch (shapeId) {
|
||||
case 1: result = new TextTag_DID(); break;
|
||||
case 2: result = new TextTag_IID(); break;
|
||||
case 3: result = new TextTag_IIDEnum(); break;
|
||||
case 4: result = new TextTag_IIDString(); break;
|
||||
}
|
||||
result->m_type = tagType; // from the FIRST lookup ("Tell" -> 0x10000001)
|
||||
result->m_format = shapeId;
|
||||
result->ParseStartTag(remaining payload); // shape-specific: fills TextTag_IIDString::m_IID/m_string, etc.
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
(Full disassembly at `docs/research/named-retail/acclient_2013_pseudo_c.txt:132871-133065`;
|
||||
the two-colon split and the two `EnumMapper::InqEnum(..., 0x18, ...)` calls
|
||||
are visible at `0x004784E6-0x00478545` and `0x00478577-0x004785DA`.) The
|
||||
resulting `TextTag*` is stashed on every `Glyph` inside the span via
|
||||
`Glyph::SetTag @ 0x00474920` as `InqGlyphs` walks the display characters
|
||||
between the tag's `>` and its closing `<\...>`.
|
||||
|
||||
**UNKNOWN — needs a DAT/cdb dump**: `EnumMapper::InqEnum`'s category `0x18`
|
||||
is a data-driven (DAT-resident, likely `client_portal.dat` StringTable/
|
||||
EnumMapper resource) name↔id table — the code only proves the *mechanism*,
|
||||
not the full roster of type-name strings it accepts. We confirmed "Tell"
|
||||
(→ `m_type == 0x10000001`) and "IIDString" (→ shape id 4, inferred — see
|
||||
§ "Other tag types" below) from literal format strings elsewhere in the
|
||||
binary, but did not independently dump the table itself.
|
||||
|
||||
### 1e. Vtable-offset proof that `var_4` is the tag and `+0x14` is `HandleClick`
|
||||
|
||||
The `TextTag` vtable layout, read straight from the four subclass vtable
|
||||
dumps (`docs/research/named-retail/acclient_2013_pseudo_c.txt:959321-959368`):
|
||||
|
||||
| offset | slot | `TextTag_DID` | `TextTag_IIDString` | `TextTag_IIDEnum` | `TextTag_IID` |
|
||||
|---|---|---|---|---|---|
|
||||
| 0x00 | `__vecDelDtor` | ✓ | ✓ | ✓ | ✓ |
|
||||
| 0x04 | `ParseEndTag` | `TextTag::ParseEndTag` (shared) | shared | shared | shared |
|
||||
| 0x08 | `ParseStartTag` | own | own | own | own |
|
||||
| 0x0C | `BuildEndTag` | `TextTag::BuildEndTag` (shared) | shared | shared | shared |
|
||||
| 0x10 | `BuildStartTag` | `TextTag::BuildStartTag` (shared) | shared | shared | shared |
|
||||
| **0x14** | **`HandleClick`** | **own** | **own** | **own** | **own** |
|
||||
| 0x18 | `BuildStartTagData` | own | own | own | own |
|
||||
|
||||
`+0x14` is exactly `HandleClick`, confirming `MouseUp`'s
|
||||
`*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4)` is `var_4->HandleClick(arg4)`
|
||||
— a virtual call, i.e. this is dispatched **per concrete tag subclass**,
|
||||
not a single hardcoded action.
|
||||
|
||||
---
|
||||
|
||||
## 2. What fires on click: generic dispatch, not hardcoded to tells
|
||||
|
||||
All four subclasses' `HandleClick` do the same shape of thing — forward to
|
||||
a global "notice" (AC's internal pub/sub event system) carrying the tag's
|
||||
type + payload, nothing else:
|
||||
|
||||
```c
|
||||
// TextTag_DID::HandleClick @ 0x00478740
|
||||
ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id); // @ 0x006926D0
|
||||
|
||||
// TextTag_IID::HandleClick @ 0x00478E80
|
||||
ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID); // @ 0x00692720
|
||||
|
||||
// TextTag_IIDEnum::HandleClick @ 0x00478B40
|
||||
ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum); // @ 0x00692770
|
||||
|
||||
// TextTag_IIDString::HandleClick @ 0x00478840
|
||||
ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string); // @ 0x006927C0
|
||||
```
|
||||
|
||||
So the dispatch **is** generic — any listener can register for any of the
|
||||
four notices and react to any `m_type`. In this build, though, only ONE of
|
||||
the four notices has an actual (non-stub) listener anywhere in the client:
|
||||
|
||||
- `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` — real,
|
||||
wired to chat's click-to-tell (see §3).
|
||||
- `NoticeHandler::RecvNotice_TextTag_IIDEnumClick @ 0x006A0240` is declared
|
||||
`__pure` — a pure-virtual stub, no base behavior, and no override for it
|
||||
was found anywhere in this pass.
|
||||
- No function definition for a real `RecvNotice_TextTag_DIDClick` or
|
||||
`RecvNotice_TextTag_IIDClick` override exists anywhere in the pseudo-C
|
||||
file either — every other hit for those names is vtable-slot noise (the
|
||||
decompiler filling unresolved thunk slots with neighboring symbol names;
|
||||
cross-checked, none are real function bodies with those signatures).
|
||||
|
||||
**Conclusion**: the mechanism is generic (4 tag shapes × arbitrary
|
||||
`m_type` values × arbitrary listeners), but in this Sept 2013 build only
|
||||
the chat-log "clickable speaker name → start a tell" feature is actually
|
||||
wired up end-to-end. `TextTag_DID`/`TextTag_IID`/`TextTag_IIDEnum` exist,
|
||||
parse, and would dispatch correctly if clicked, but nothing in the client
|
||||
reacts to their click notices — **UNKNOWN whether item links / URLs /
|
||||
coordinates use these shapes in later builds or via server-composed text
|
||||
we didn't grep for**; no evidence of them was found in this pass.
|
||||
|
||||
### Other tag types found (full roster)
|
||||
|
||||
| Class | Shape id (inferred) | Ctor | `HandleClick` | Real listener found? |
|
||||
|---|---|---|---|---|
|
||||
| `TextTag_DID` | 1 | `0x00478760` | `0x00478740` | No |
|
||||
| `TextTag_IID` | 2 | `0x00478E60` | `0x00478E80` | No |
|
||||
| `TextTag_IIDEnum` | 3 | `0x00478B20` | `0x00478B40` | No |
|
||||
| `TextTag_IIDString` | 4 | `0x00478860` | `0x00478840` | **Yes** — `gmMainChatUI` |
|
||||
|
||||
Shape-id-to-class mapping is inferred from the `switch(shapeId){case
|
||||
1..4}` construction order in `MakeTag` (`0x00478632`/`0x004785E1`/
|
||||
`0x004785FC`/`0x00478617`) plus the fact that the only shape name we can
|
||||
directly read from format strings ("IIDString") is used everywhere the
|
||||
"Tell" markup appears, which always constructs `TextTag_IIDString`. This
|
||||
is strong circumstantial evidence, not a direct read of the DAT enum
|
||||
table — flagged per the "no guessing" rule.
|
||||
|
||||
---
|
||||
|
||||
## 3. The prefill itself: `ChatInterface::StartTell`
|
||||
|
||||
`gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` is the
|
||||
registered listener for `SendNotice_TextTag_IIDStringClick`:
|
||||
|
||||
```c
|
||||
void gmMainChatUI::RecvNotice_TextTag_IIDStringClick(this, uint32_t type, uint32_t iid, PStringBase<unsigned short> const* name)
|
||||
{
|
||||
if (type == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0)
|
||||
ChatInterface::StartTell(this, name);
|
||||
}
|
||||
```
|
||||
|
||||
Two gates: (a) the tag's semantic type must be `0x10000001` — i.e. only
|
||||
"Tell"-markup spans do anything on click, other `m_type` values on an
|
||||
`IIDString` tag (if any exist) are silently ignored here; (b) the chat
|
||||
**entry box must not already have keyboard focus** — if the player is
|
||||
mid-sentence typing something else, clicking a name in the log does
|
||||
nothing (`ChatInterface::IsTextEntryFocused @ 0x004F30A0`, which checks
|
||||
`UIElementManager`'s active/focused element against `this->m_chatEntry`).
|
||||
Note the tag's own `iid` payload (`arg3`/`m_IID`) is read into the
|
||||
parameter list but **never used** by this handler — only the embedded name
|
||||
string matters.
|
||||
|
||||
`ChatInterface::StartTell @ 0x004F41F0`:
|
||||
|
||||
```c
|
||||
void ChatInterface::StartTell(this, PStringBase<unsigned short> const* name)
|
||||
{
|
||||
PStringBase<unsigned short> text = Formatted(u"@tell %s, ", name); // note: trailing space after comma
|
||||
this->m_chatEntry->vtable->Activate();
|
||||
this->m_chatEntry->vtable->TakeFocus(); // <-- keyboard focus moves to the chat entry
|
||||
CM_UI::SendNotice_ToggleChatEntry(1); // <-- ensures the chat entry bar is shown
|
||||
UIElement_Text::SetText(this->m_chatEntry, &text);
|
||||
UIElement_Text::MoveCursorToPosition(this->m_chatEntry, /* length of `text` */);
|
||||
UIElement_Text::ClearSelection(this->m_chatEntry);
|
||||
}
|
||||
```
|
||||
|
||||
So, precisely:
|
||||
|
||||
- **Text placed**: `"@tell <PlayerName>, "` — literal `@tell`, a space,
|
||||
the name, a comma, and a **trailing space** (ready to type the message
|
||||
body immediately).
|
||||
- **Focus**: YES, explicitly changed. `Activate()` + `TakeFocus()` move
|
||||
keyboard focus into the chat entry field, and
|
||||
`CM_UI::SendNotice_ToggleChatEntry(1) @ 0x0047A200` broadcasts a notice
|
||||
whose real handler, `ClientUISystem::RecvNotice_ToggleChatEntry @
|
||||
0x00564200`, is what shows/expands the chat entry bar if it was
|
||||
currently hidden (confirmed as the one non-stub override among many
|
||||
vtable-slot look-alikes for that notice name).
|
||||
- **Cursor**: placed at the end of the inserted text (right after the
|
||||
trailing space), any prior selection cleared.
|
||||
- Whatever the player had already typed into the box (if it wasn't
|
||||
focused — see the focus gate above) is **replaced outright**, not
|
||||
merged or prepended to.
|
||||
|
||||
---
|
||||
|
||||
## 4. Hover behavior: color is static per-tag-type, not a hover effect
|
||||
|
||||
`UIElement_Text::MouseMove @ 0x004695F0` was checked directly — it does
|
||||
**no** glyph/tag lookup at all. It only handles active text-selection
|
||||
dragging (`this->m_bitField & 0x40`) or falls back to the base
|
||||
`UIElement::MouseMove`. `UIElement_Text::GetShouldBeMouseVisible @
|
||||
0x00467460` likewise only inspects `this->m_bitField & 5` (an
|
||||
editable/selectable flag), not glyph tags. **No hover-triggered highlight,
|
||||
brightening, or cursor-icon change tied to `Glyph::m_tag` was found
|
||||
anywhere in this pass.**
|
||||
|
||||
What *is* real, and is presumably what reads as "the name is green," is a
|
||||
**static per-glyph color chosen at glyph-construction time**, based on
|
||||
whether the glyph belongs to an active `0x10000001`-typed tag span. Inside
|
||||
`UIElement_Text::InqGlyphs @ 0x00468EA0`, right after a tag span is
|
||||
opened/continues:
|
||||
|
||||
```c
|
||||
// 0x00469084-0x0046908A, ebx_1 = the currently-active TextTag* for this glyph (0 if none)
|
||||
if (ebx_1 == 0 || ebx_1->m_type != 0x10000001)
|
||||
color = &this->m_curFontColor; // offset 0x6A4 on UIElement_Text
|
||||
else
|
||||
color = &this->m_curTagFontColor; // offset 0x6B8 on UIElement_Text
|
||||
// ... color is then baked into the new Glyph's m_color field
|
||||
```
|
||||
|
||||
`UIElement_Text`'s struct (`acclient.h:53392-53420`) confirms two distinct
|
||||
color fields exist: `RGBAColor m_curFontColor;` and
|
||||
`RGBAColor m_curTagFontColor;`, set via
|
||||
`UIElement_Text::SetFontColorHelper(this, attrId, &field, colorIndex)`
|
||||
with **different authored attribute ids** — `0x1B` for `m_curFontColor`,
|
||||
`0x1D` for `m_curTagFontColor` (seen consistently at
|
||||
`UIElement_Text::AppendTextWithFont @ 0x00469D70` and
|
||||
`AppendStringInfoWithFont @ 0x00469DE0`). `SetFontColorHelper @
|
||||
0x00466AC0` treats its 4th argument as an **index into an authored color
|
||||
array** (an `InqProperty`-backed attribute, not a raw RGBA value) — so
|
||||
`FontColor` and `TagFontColor` are two independently-authored per-window
|
||||
color tables (almost certainly LayoutDesc-driven, consistent with this
|
||||
project's existing DAT-driven-UI findings), and can hold different colors
|
||||
at the same index. That is the entire "green name" mechanism: **it's
|
||||
baked into the glyph once, from data, when the text is appended — not
|
||||
computed or changed on mouse hover.**
|
||||
|
||||
`m_curTagFontColor` defaults to `RGBAColor_White` (`0x00468641`) unless a
|
||||
window's layout overrides attribute `0x1D` — chat windows presumably do.
|
||||
|
||||
**UNKNOWN — needs DAT/runtime inspection**: the actual authored RGBA
|
||||
values (attribute `0x1D`'s color table) live in a LayoutDesc DAT resource,
|
||||
not in code; not dumped in this pass. Cross-reference
|
||||
`claude-memory/reference_retail_chat_colors.md` for retail chat-color
|
||||
ground truth already captured via cdb.
|
||||
|
||||
---
|
||||
|
||||
## 5. Is `SetReplyTextInChatBox` the click handler? No — keyboard macro only
|
||||
|
||||
`ChatInterface::SetReplyTextInChatBox @ 0x004F4760` has exactly one
|
||||
caller in the whole binary: `ChatInterface::HandleTextReplacements @
|
||||
0x004F50D0`, which tries three "quick reply" expanders in order:
|
||||
|
||||
```c
|
||||
void ChatInterface::HandleTextReplacements(this)
|
||||
{
|
||||
if (this->m_chatEntry == 0) return;
|
||||
if (!ChatInterface::SetReplyTextInChatBox(this)) // @ 0x004F4760 — replies to LAST TELLER
|
||||
if (!ChatInterface::SetMonarchReplyTextInChatBox(this)) // @ 0x004F4B70 — replies to monarch
|
||||
ChatInterface::SetPatronReplyTextInChatBox(this); // @ 0x004F4EA0 — replies to patron
|
||||
}
|
||||
```
|
||||
|
||||
`HandleTextReplacements` is itself called from exactly one place:
|
||||
`ChatInterface::ListenToElementMessage @ 0x004F51C0`, `case 0x11` (i.e.
|
||||
`idMessage == 0x12`), gated on `arg2->dwParam1 == 0x20` (ASCII space) and
|
||||
the message originating from the chat entry element:
|
||||
|
||||
```c
|
||||
case 0x11: // idMessage == 0x12
|
||||
if (arg2->pElement == this->m_chatEntry /* decompiled as m_fCurrentOpacity, mis-attributed field */
|
||||
&& arg2->dwParam1 == 0x20)
|
||||
ChatInterface::HandleTextReplacements(this);
|
||||
break;
|
||||
```
|
||||
|
||||
Message id `0x12` is confirmed elsewhere as the "character typed"
|
||||
broadcast: `UIElement_Text::CharacterHandler @ 0x00469B90` ends its
|
||||
non-control-character path with
|
||||
`UIElement::BroadcastElementMessage(this, 0x12, typedChar, 0) @
|
||||
0x00469CAF` — `dwParam1` carries the raw character code. So this whole
|
||||
path only fires **while the player is typing into the chat entry box and
|
||||
presses the SPACE bar**, and only after typing one of a small set of
|
||||
recognized prefixes.
|
||||
|
||||
`SetReplyTextInChatBox` itself: reads the entry's current (trimmed) text,
|
||||
checks whether it starts with `/` or `@`, and if so, matches the
|
||||
first-word substring against known shortcut prefixes (`"t"`/`"te"`-style
|
||||
1–2 char abbreviations at `data_7c4c70`/`data_7c4c68`, and the literal
|
||||
`u"reply "` at `0x004F4A04`). If matched, it replaces that recognized
|
||||
prefix (leaving anything typed after it in place) with:
|
||||
|
||||
```c
|
||||
gmCCommunicationSystem::GetLastTellerName(...) @ 0x00589550 // whoever LAST sent YOU a tell
|
||||
Formatted(u"@tell %hs,", lastTellerName) // note: NO trailing space, %hs = narrow string
|
||||
UIElement_Text::SetText / MoveCursorToPosition / ClearSelection // @ 0x004F4ADC-0x004F4AFA
|
||||
```
|
||||
|
||||
This is a **different string** from the click path's `"@tell %s, "` (no
|
||||
trailing space here; `%hs` explicitly narrow-string-formats
|
||||
`GetLastTellerName`'s `PStringBase<char>*` return, vs. the click path's
|
||||
already-wide `PStringBase<unsigned short>` name) — a small but real
|
||||
divergence between the two "start a tell" paths worth preserving if both
|
||||
get ported. `SetMonarchReplyTextInChatBox @ 0x004F4B70` and
|
||||
`SetPatronReplyTextInChatBox @ 0x004F4EA0` are structurally identical,
|
||||
sourcing the name from `GetLastAtMonarchUserName`/`GetLastAtPatronUserName`
|
||||
instead.
|
||||
|
||||
**Verdict**: `SetReplyTextInChatBox` is a **keyboard-shortcut/text-macro
|
||||
handler only** — triggered by typing a recognized prefix then a space in
|
||||
the chat entry. It shares the "write `@tell Name,` into the entry" idea
|
||||
with the click path but is a wholly separate call chain, keyed off "last
|
||||
person who told me something" global state
|
||||
(`gmCCommunicationSystem::SetLastTeller @ 0x005891A0`,
|
||||
`SetLastTellerName @ 0x00589500`) rather than the specific name embedded
|
||||
in the clicked chat line's tag. It is **not** invoked by, and does not
|
||||
invoke, any part of the click-to-tag chain in §1–§3.
|
||||
|
||||
A consequence worth flagging for the port: because the click path reads
|
||||
the name baked into that *specific* chat line's tag, clicking an **old**
|
||||
"X tells you" line further up the scrollback still starts a tell to X,
|
||||
even if X is no longer whoever last told you something — whereas the
|
||||
`/t `+space keyboard shortcut always resolves to the single global
|
||||
"last teller," which could be a different person by then.
|
||||
|
||||
---
|
||||
|
||||
## 6. Where the clickable markup comes from (bonus — answers "why does this reproduce on so many message types")
|
||||
|
||||
The `<Tell:IIDString:<iid>:<name>>displayText<\Tell>` span is built by
|
||||
`sprintf`-style formatting at multiple sites, not just for direct tells.
|
||||
Representative literals (some inline strings are BN-truncated at ~33
|
||||
chars, marked `…`):
|
||||
|
||||
| Format string (verbatim where fully visible) | Address | Used for |
|
||||
|---|---|---|
|
||||
| `"<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n"` | `data_7d0ec0 @ 0x007D0EC0` | direct tell received |
|
||||
| `"<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n"` | `data_7d0e60 @ 0x007D0E60` | local/say-range speech |
|
||||
| `"[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0cdc @ 0x007D0CDC` | fellowship chat |
|
||||
| `"[Co-Vassals] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0bfc @ 0x007D0BFC` | co-vassal chat |
|
||||
| `"[Allegiance Broadcast] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0c30 @ 0x007D0C30` | allegiance broadcast |
|
||||
| `"Your patron <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d10 @ 0x007D0D10` | patron chat |
|
||||
| `"Your vassal <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d4c @ 0x007D0D4C` | vassal chat |
|
||||
| `"Your follower <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0ca0 @ 0x007D0CA0` | follower chat |
|
||||
| `"[%ws] <Tell:IIDString:0:%ws>%ws<\Tell> says, \"%ws\""` | `data_7e83e8 @ 0x007E83E8` | generic named channel say (channel name in `[%ws]`) |
|
||||
|
||||
Confirms the closing marker is a literal backslash `<\Tell>` in the raw
|
||||
string data (not the HTML-style `</Tell>` one might assume), and that the
|
||||
non-direct-tell variants hardcode the `iid` field to `0` (the click
|
||||
handler ignores `iid` anyway, so this has no functional effect — but it
|
||||
means the tag's `m_IID` is meaningless/decorative for anything except
|
||||
direct tells). The gating logic for direct tells
|
||||
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:382537-382553`,
|
||||
`gmCCommunicationSystem`-adjacent code around `0x00571880`) only emits the
|
||||
tagged, clickable form when the sender's id falls in the player-character
|
||||
GUID range (`0x50000001`-`0x6FFFFFFF`); tells attributed to ids outside
|
||||
that range fall back to the plain, non-clickable
|
||||
`"%s tells you, \"%s\"\n"` format (`0x00571880` false branch) — so
|
||||
non-player "tells" (system/GM broadcast-as-tell, etc.) are never
|
||||
clickable.
|
||||
|
||||
---
|
||||
|
||||
## Open gaps (explicitly not resolved here)
|
||||
|
||||
- **UNKNOWN**: the full roster of `EnumMapper` category `0x18` type-name
|
||||
strings (only "Tell" and, by strong inference, "IIDString" are
|
||||
confirmed). A DAT dump or a cdb breakpoint on `EnumMapper::GetString`
|
||||
with category `0x18` would enumerate the rest and settle whether item
|
||||
links/URLs/coordinates exist as other `m_type` values on the same
|
||||
`IIDString` shape, or as `DID`/`IID`/`IIDEnum` shapes instead.
|
||||
- **UNKNOWN**: authored RGBA values behind `m_curFontColor`/
|
||||
`m_curTagFontColor` attribute ids `0x1B`/`0x1D` for the chat log window
|
||||
specifically — lives in a LayoutDesc DAT resource, not code.
|
||||
- **Confirmed absence, not a gap**: no hover-only visual/cursor change was
|
||||
found for tagged glyphs in this build (`MouseMove`,
|
||||
`GetShouldBeMouseVisible` both checked directly and neither look at
|
||||
`Glyph::m_tag`).
|
||||
469
docs/research/2026-08-21-chat-tagged-name-composition.md
Normal file
469
docs/research/2026-08-21-chat-tagged-name-composition.md
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
# Retail chat: how a tagged, coloured player-name run gets composed
|
||||
|
||||
Research-only. No source files modified. All claims cite `symbol @ 0xADDRESS`
|
||||
in `docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) or
|
||||
`docs/research/named-retail/acclient.h` (verbatim retail struct defs) unless
|
||||
otherwise noted. Addresses without an explicit file are in the pseudo-C dump.
|
||||
|
||||
## TL;DR — the mechanism in one paragraph
|
||||
|
||||
Retail does **not** use `StringInfo`'s two-colour-argument mechanism
|
||||
(`RecvNotice_DisplayFinalStringInfo`'s `arg3`/`arg4`) to colour the player
|
||||
name differently from the rest of the sentence. `StringInfo` is only a
|
||||
**localization/variable-substitution template** (string-table id + named
|
||||
variables, or a literal override) — it has no colour or tag fields at all.
|
||||
Instead, the sender's name is delivered as a **literal inline markup tag**,
|
||||
`<Tell:IIDString:<GUID>:<Name>>Name<\Tell>`, baked directly into the plain
|
||||
wide-char sentence *before* it is ever handed to the UI. When that sentence
|
||||
is appended to the chat log, `UIElement_Text`'s glyph-list builder
|
||||
(`UIElement_Text::InqGlyphs @ 0x00468ea0`) recognizes the `<...>` markup,
|
||||
asks `TextTagFactory::MakeTag @ 0x00478480` to parse it into a
|
||||
`TextTag_IIDString` object (GUID + name), and colours **every individual
|
||||
`Glyph`** inside the tagged span from `m_curTagFontColor` (font-color
|
||||
property `0x1D`) instead of the line's `m_curFontColor` (property `0x1B`) —
|
||||
but **only if the tag's type is the "Tell" enum value `0x10000001`**. Each
|
||||
`Glyph` also carries a `TextTag*` pointer, which is what makes the run
|
||||
clickable and lets a click resolve back to the right player.
|
||||
|
||||
## 1. Where the sender name becomes a tagged run
|
||||
|
||||
### 1a. The literal tag text is baked in at message-composition time
|
||||
|
||||
Two adjacent handlers on `ClientCommunicationSystem` build the chat line
|
||||
for incoming speech, and both embed the markup directly via `sprintf`,
|
||||
*before* any StringInfo/UI code runs:
|
||||
|
||||
- **Local/overheard speech** — `ClientCommunicationSystem::Handle_Communication__HearSpeech @ 0x005712a0`:
|
||||
|
||||
```
|
||||
005714f5 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
|
||||
005714f5 PStringBase<char>::sprintf(&s_NullBuffer_2, "%s says, \"%s\"\n"); // no tag
|
||||
005714f5 else
|
||||
00571511 PStringBase<char>::sprintf(&s_NullBuffer_2, "<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n");
|
||||
```
|
||||
(full literal recovered from the constant pool: `data_7d0e60 @ 0x007d0e60`
|
||||
= `"<Tell:IIDString:%d:%s>%s<\\Tell> says, \"%s\"\n"`, since Binary
|
||||
Ninja's inline preview truncates at ~33 chars).
|
||||
|
||||
- **Direct tell** — `ClientCommunicationSystem::Handle_Communication__HearDirectSpeech @ 0x005715a0`, same shape:
|
||||
|
||||
```
|
||||
00571880 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
|
||||
00571880 PStringBase<char>::sprintf(&arg5, "%s tells you, \"%s\"\n"); // no tag
|
||||
00571880 else
|
||||
0057189c PStringBase<char>::sprintf(&arg5, "<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n");
|
||||
```
|
||||
(full literal: `data_7d0ec0 @ 0x007d0ec0` =
|
||||
`"<Tell:IIDString:%d:%s>%s<\\Tell> tells you, \"%s\"\n"`).
|
||||
|
||||
`%d` = `arg4`, the speaker's actual object GUID from the wire message.
|
||||
`%s` (first) = the speaker's display name (repeated once inside the tag
|
||||
payload, once again as the visible glyph text after the `>`).
|
||||
|
||||
**The `0x50000001..0x6FFFFFFF` GUID-range gate is load-bearing**: only
|
||||
senders whose object id falls in that range get the clickable/coloured
|
||||
treatment at all. This is AC1's dynamic-object id range (players and other
|
||||
non-static weenies); ids outside it (system/NPC broadcast cases handled
|
||||
elsewhere) fall through to the plain, untagged `"%s says/tells...` format
|
||||
with no markup and no special colour.
|
||||
|
||||
Group/channel broadcasts go through a **separate** builder,
|
||||
`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (called from
|
||||
`gmCCommunicationSystem::uiChatInterfaceProvider::OnSendToRoom @ 0x0058a590`,
|
||||
the TurbineChat room-message handler), which always uses `IID:0` in the tag
|
||||
(no real object id is available/needed there) and prepends the channel
|
||||
name, e.g. for General chat:
|
||||
|
||||
```
|
||||
005cd93a ebx = 0x1b; // LogTextType = General
|
||||
005cd93f var_1c_14 = &ChannelSystem::General_GlobalChannelName;
|
||||
005cd85c sprintf(&s_NullBuffer_2, "[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\"");
|
||||
```
|
||||
(full literal: `data_7e83e8 @ 0x007e83e8` =
|
||||
`"[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\""`). The function
|
||||
returns a `ChatDisplayInfo{ m_ltt (LogTextType), m_display (the whole
|
||||
sprintf'd string), m_doDisplayText }` and the caller passes `m_display` and
|
||||
`m_ltt` straight into `ClientSystem::AddTextToScroll`. Similar hard-coded
|
||||
`IID:0` tag formats exist for Fellowship broadcast (`"[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \""`,
|
||||
`data_7d0cdc @ 0x007d0cdc`), Co-Vassals (`data_7d0bfc @ 0x007d0bfc`),
|
||||
Allegiance Broadcast (`data_7d0c30 @ 0x007d0c30`), patron/vassal/follower
|
||||
tells (`data_7d0d10`, `data_7d0d4c`, `data_7d0ca0`).
|
||||
|
||||
**BN-truncation note**: every one of the `sprintf(..., "<Tell:IIDString:...")`
|
||||
call sites above showed only a `…`-truncated ~33-char preview in the raw
|
||||
decompiler output. The full strings quoted here were recovered from the
|
||||
named constant-pool entries (`data_7d0bfc`, `data_7d0c30`, `data_7d0c88`,
|
||||
`data_7d0ca0`, `data_7d0cdc`, `data_7d0d10`, `data_7d0d4c`, `data_7d0e60`,
|
||||
`data_7d0ec0`, `data_7e83e8`), which the pseudo-C dump prints verbatim
|
||||
elsewhere in the file as global `char const data_XXXXXXXX[N] = "...", 0`
|
||||
declarations — not guessed.
|
||||
|
||||
### 1b. The plain sprintf'd string reaches the chat window unmodified
|
||||
|
||||
`ClientSystem::AddTextToScroll @ 0x00487fc0` (char* overload) widens to
|
||||
UTF-16 and tail-calls the wide overload `@ 0x00563c50`, which wraps the
|
||||
**already-composed** text (markup and all) in a **literal-override**
|
||||
`StringInfo` and forwards it to the notification bus:
|
||||
|
||||
```
|
||||
00563ee9 StringInfo::SetLiteralValue(&var_890, &m_charbuffer_3 /* full "<Tell:...>...\"" text */, 1);
|
||||
00563f05 StringInfo::SetLiteralValue(&var_920, &s_NullBuffer_4 /* timestamp string, or empty */, 1);
|
||||
00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3 /* LogTextType/colour index */, &var_890, &var_920, arg5 /* window id */);
|
||||
```
|
||||
|
||||
This confirms `StringInfo` here is used purely as a **transport wrapper**
|
||||
around an already-fully-formed literal string — `StringInfo::SetLiteralValue`
|
||||
sets `m_Override = 1` (literal) so `StringInfo::GetString` later just
|
||||
returns the wrapped text verbatim; no template/variable substitution
|
||||
happens for chat lines built this way. (See §4 for why `StringInfo` cannot
|
||||
itself be the tag carrier.)
|
||||
|
||||
`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (the
|
||||
override registered on `ChatInterface`) receives this notice:
|
||||
|
||||
```
|
||||
004f46dc if (StringInfo::IsValid(arg4, 1) != 0)
|
||||
004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4, 0, 0xc); // arg4 = timestamp StringInfo, colour index 0x0C
|
||||
004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3, 0, arg2); // arg3 = the message StringInfo, colour index arg2 (LogTextType)
|
||||
```
|
||||
|
||||
`arg4` (the *second* StringInfo, appended first, at the fixed colour index
|
||||
`0x0C`) is the **timestamp prefix** (`"HH:MM:SS "`, built a few lines
|
||||
earlier in `AddTextToScroll` from `PlayerModule::DisplayTimeStamps` +
|
||||
`wcsftime`), not a channel-name prefix. `arg3` (colour index `arg2` = the
|
||||
caller-supplied LogTextType) is the **entire rest of the line**, channel
|
||||
prefix and all — see §3 for why that resolves the "0x0C is grey" question.
|
||||
|
||||
### 1c. `AppendStringInfoWithFont` resolves the literal text and hands it to the glyph parser
|
||||
|
||||
`UIElement_Text::AppendStringInfoWithFont @ 0x00469de0`:
|
||||
|
||||
```
|
||||
00469df4 UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3);
|
||||
00469e09 UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4); // line colour, keyed by LogTextType index arg4
|
||||
00469e1a UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4); // tag colour, same index
|
||||
00469e2a eax_1 = StringInfo::GetString(arg2, &arg3, 0); // resolves literal-override text verbatim
|
||||
00469e44 UIElement_Text::AddText_Internal(this, m_charbuffer, 3);
|
||||
```
|
||||
|
||||
`m_curFontColor` and `m_curTagFontColor` are named fields on
|
||||
`UIElement_Text` (`docs/research/named-retail/acclient.h:53408,53410`):
|
||||
|
||||
```
|
||||
struct __cppobj __declspec(align(8)) UIElement_Text : UIElement_Scrollable, CInputHandler
|
||||
{
|
||||
...
|
||||
RGBAColor m_curFontColor;
|
||||
Font *m_curFontObj;
|
||||
RGBAColor m_curTagFontColor;
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
So **before any markup parsing happens**, the widget primes two colours
|
||||
for the whole append call — the base line colour and a *separate* tag
|
||||
colour — both looked up via the exact same LogTextType-indexed mechanism
|
||||
(`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does
|
||||
`InqProperty(propId) → indexed-array element at [arg4]`).
|
||||
|
||||
### 1d. The glyph-list builder recognizes `<...>` and creates the `TextTag`
|
||||
|
||||
`UIElement_Text::InqGlyphs @ 0x00468ea0` (the routine `AddText_Internal`
|
||||
uses to turn the resolved wide string into `Glyph` records) scans
|
||||
char-by-char; on finding `<` it captures through the matching `>` and
|
||||
calls the tag factory:
|
||||
|
||||
```
|
||||
00469021 int32_t eax_16 = TextTagFactory::MakeTag(); // parses the whole "<Tell:IIDString:GUID:Name>" span
|
||||
00469084 if (ebx_1 == 0 || tag->m_type != 0x10000001)
|
||||
00469084 edx_15 = <UIElement_Text + 0x6a4>; // this->m_curFontColor (RGBAColor field order matches struct above)
|
||||
00469084 else
|
||||
0046908a edx_15 = <UIElement_Text + 0x6b8>; // this->m_curTagFontColor
|
||||
004690c5 Glyph::Glyph(&esp_1[9]); // constructs the glyph with the chosen colour + tag pointer
|
||||
```
|
||||
|
||||
(Note: this function is heavily register/stack-mangled in Binary Ninja's
|
||||
output — the raw operand forms above are paraphrased from the literal
|
||||
`esp`/`ecx` chains in the dump, not verbatim BN text, because the BN
|
||||
pseudo-C here reads as raw stack-slot arithmetic rather than named field
|
||||
accesses. The two struct-offset destinations (`+0x6a4`, `+0x6b8`) are
|
||||
`0x14` bytes apart, matching an `RGBAColor` (16 bytes) + `Font*` (4 bytes)
|
||||
gap between `m_curFontColor` and `m_curTagFontColor` in the struct dump
|
||||
above — consistent with, but not a byte-for-byte confirmed alias of, those
|
||||
two named fields.)
|
||||
|
||||
`Glyph` itself carries **per-character** colour and tag
|
||||
(`docs/research/named-retail/acclient.h:45330`):
|
||||
|
||||
```
|
||||
struct __cppobj Glyph
|
||||
{
|
||||
unsigned __int16 m_data; // character code
|
||||
int m_width;
|
||||
int m_height;
|
||||
RGBAColor m_color; // per-glyph colour — this is what makes the name a different colour from the rest of the line
|
||||
Font *m_font;
|
||||
TextTag *m_tag; // non-null only for glyphs inside a <Tag:...>...<\Tag> span — this is what makes it clickable
|
||||
};
|
||||
```
|
||||
|
||||
**This is the answer to "who creates the tagged run": `TextTagFactory::MakeTag @ 0x00478480`**,
|
||||
called from `UIElement_Text::InqGlyphs @ 0x00468ea0` while it walks the
|
||||
resolved plain-text string looking for `<...>` markers. It is a **markup
|
||||
parser operating on plain text**, not a StringInfo/variable mechanism.
|
||||
|
||||
`TextTagFactory::MakeTag @ 0x00478480` itself:
|
||||
1. Confirms the captured span starts with `<` and ends with `>`.
|
||||
2. Splits on the first `:` — the text before it (e.g. `"Tell"`) is looked
|
||||
up via `EnumMapper::InqEnum(name, 0x18, &m_type)` (a DAT-driven
|
||||
string→enum table, category `0x18`) to get the numeric tag **type**
|
||||
(`this->m_type`, e.g. `0x10000001` for `"Tell"`).
|
||||
3. Splits again on the next `:` — the text between them (e.g.
|
||||
`"IIDString"`) is looked up the same way to get a small **class**
|
||||
selector (`var_18`, 1–4), which a `switch` uses to instantiate the
|
||||
right `TextTag` subclass:
|
||||
- `1` → `TextTag_DID`
|
||||
- `2` → `TextTag_IID`
|
||||
- `3` → `TextTag_IIDEnum`
|
||||
- `4` → `TextTag_IIDString` (jump table `jump_table_478728 @ 0x00478728`, case `4 @ 0x00478617`)
|
||||
4. Calls the new tag's virtual `ParseStartTag` on the remaining payload
|
||||
text (everything after the second `:`, i.e. `"<GUID>:<Name>"`).
|
||||
|
||||
## 2. Tag payload — what a click needs to address the right player
|
||||
|
||||
`struct TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase<unsigned short> m_string; }`
|
||||
(`docs/research/named-retail/acclient.h:53960`), with the base class
|
||||
`struct TextTag : ReferenceCountTemplate<1048576,0> { unsigned int m_type; unsigned int m_format; }`
|
||||
(`docs/research/named-retail/acclient.h:45358`).
|
||||
|
||||
`TextTag_IIDString::ParseStartTag @ 0x00478910` fills it in:
|
||||
|
||||
```
|
||||
00478946 if (PStringBaseIter_Common<unsigned short>::FindChar(&iter, ':', 0) != 0) // find the FIRST ':' in "GUID:Name"
|
||||
00478a02 if (PSUtils::is_uint32(leftPart) != 0) // left of ':' must parse as a uint32
|
||||
004788ad this->m_IID = PStringBase<unsigned short>::to_uint32(&leftPart); // -> numeric object GUID
|
||||
00478ae2 PStringBase<unsigned short>::operator=(&this->m_string, &rightPart); // -> display name text
|
||||
```
|
||||
|
||||
So the payload is **both** the numeric object id **and** the display name
|
||||
string, not just one or the other. `TextTag_IIDString::BuildStartTagData @ 0x004788e0`
|
||||
is the inverse (serializes back to `"0x%08X:%ls"`), confirming the same
|
||||
two-field shape round-trips.
|
||||
|
||||
**Click resolution** (`TextTag_IIDString::HandleClick @ 0x00478840`):
|
||||
|
||||
```
|
||||
0047884c ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string);
|
||||
```
|
||||
|
||||
which is picked up by `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004cce10`:
|
||||
|
||||
```
|
||||
004cce1b if (arg2 == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0) // arg2 = tag->m_type ("Tell")
|
||||
004cce2d ChatInterface::StartTell(this, arg4); // arg4 = tag->m_string — starts a /tell using the NAME, not the GUID
|
||||
```
|
||||
|
||||
So in the one client-side consumer we traced, the actual action
|
||||
(`StartTell`) only uses the **name string**, even though the tag also
|
||||
carries the numeric GUID. The GUID is transmitted through the notice
|
||||
(`arg3`) but this handler doesn't consume it — it may be used by other,
|
||||
untraced `RecvNotice_TextTag_IIDStringClick` overrides (several other UI
|
||||
classes register the same override — see the vtable-slot list in the
|
||||
pseudo-C dump around `0x0079e580` onward — only `gmMainChatUI`'s and the
|
||||
base `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` fallback were read in
|
||||
this pass). **UNKNOWN — needs a scan of every other `RecvNotice_TextTag_IIDStringClick`
|
||||
override** if a consumer that actually resolves by GUID matters for
|
||||
acdream's design (e.g. distinguishing two players who changed names, or a
|
||||
"select in world" action).
|
||||
|
||||
## 3. The `[General]` channel prefix — colour, and resolving the 0x0C puzzle
|
||||
|
||||
**Resolved: the apparent "0x0C is grey" contradiction was a mis-identification
|
||||
on my part before tracing the code, not a real contradiction.** Index `0x0C`
|
||||
is not the channel-prefix colour — it's hard-coded in
|
||||
`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (§1b) as the
|
||||
colour for the **timestamp** StringInfo (`arg4`), which is entirely
|
||||
separate from the channel-prefixed message text (`arg3`). Confirmed against
|
||||
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` (see below): index
|
||||
`0x0C`'s colour **is** `colorGrey` — exactly matching the existing project
|
||||
note. It's grey because it's the timestamp, not because it's "[General]".
|
||||
|
||||
`"[General]"` and the rest of the line (`says, "..."`, including the
|
||||
embedded name tag's line-colour-before-override) share **one** LogTextType
|
||||
value for the whole assembled string — set in
|
||||
`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (§1a):
|
||||
|
||||
```
|
||||
005cd93a ebx = 0x1b; // General
|
||||
005cd949 ebx = 0x1c; // Trade
|
||||
005cd95c ebx = 0x1d; // LFG
|
||||
005cd96d ebx = 0x1e; // Roleplay
|
||||
005cd97e ebx = 0x12; // Olthoi
|
||||
005cd9b8 ebx = 0x20; // Society (all variants)
|
||||
```
|
||||
|
||||
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` builds **one**
|
||||
LogTextType-indexed colour array (`BaseProperty` at property id `0x1B`,
|
||||
34 entries, indices `1..0x22`) applied to `this->m_chatLog`. It defaults
|
||||
every index to `colorGreen @ 0x81c578` and then overrides ~27 of them.
|
||||
Reading the override sequence address-by-address against the named
|
||||
`RGBAColor` globals in the constant pool (`0x81c4a8`..`0x81c598`, each
|
||||
printed with a name, e.g. `class RGBAColor colorGrey = { r=0.824 g=0.824
|
||||
b=0.784 a=1 }`) gives this full index→colour table:
|
||||
|
||||
| LogTextType index | Colour name | RGBA |
|
||||
|---|---|---|
|
||||
| 2 | colorWhite | (1, 1, 1, 1) |
|
||||
| 0x0C | colorGrey | (0.824, 0.824, 0.784, 1) — **timestamp**, not General |
|
||||
| 3, 0xA, 0x13, 0x1F | colorYellow | (1, 1, 0.247, 1) |
|
||||
| 4, 0xB | colorTan | (0.824, 0.824, 0.392, 1) |
|
||||
| 5 | colorBrightPurple | (1, 0.498, 1, 1) |
|
||||
| 6, 0xF, 0x15 | colorDarkRed | (1, 0.247, 0.247, 1) |
|
||||
| 7, 0x11 | colorLightBlue | (0.247, 0.749, 1, 1) |
|
||||
| 8, 9 | colorPink | (1, 0.588, 0.588, 1) |
|
||||
| 0xD | colorCyan | (0.247, 0.863, 0.863, 1) |
|
||||
| **0xE, 0x1B (General), 0x1C (Trade), 0x1D (LFG), 0x1E (Roleplay), 0x20 (Society)** | **colorBlueGrey** | **(0.706, 0.863, 0.941, 1)** |
|
||||
| 0x16 | colorLightRed | (0.96, 0.459, 0.447, 1) |
|
||||
| 0x12 (Olthoi) , 0x21 | colorOrange | (0.933, 0.573, 0.118, 1) |
|
||||
| 0x1A | colorBrightRed | (1, 0, 0, 1) |
|
||||
| everything else (1, 0x10, 0x14, 0x17, 0x18, 0x19, 0x22) | colorGreen (default, unoverridden) | (0.5, 1, 0.498, 1) |
|
||||
|
||||
So **General/Trade/LFG/Roleplay/Society chat all render in the same pale
|
||||
blue-grey** (`colorBlueGrey`) as their base line colour — `"[General]"`
|
||||
and `says, "..."` are the same colour. This cross-checks cleanly against
|
||||
the project's existing `claude-memory/reference_retail_chat_colors.md`
|
||||
(same named constants, same addresses, independently dumped live via cdb
|
||||
on 2026-06-16): its `colorWhite`→LocalSpeech, `colorBrightPurple`(index
|
||||
5)→Tell, `colorLightRed`(index 0x16)→Combat, and `colorGrey`(index
|
||||
0x0C)→"Emote/SoulEmote/fallback" mappings all match the indices found here
|
||||
exactly. That memory doc's "Channel"→`colorLightBlue` guess (its own text
|
||||
flags this mapping as an *unverified* nearest-match, "the rare kinds map
|
||||
to the nearest named color... wasn't traced") is superseded by the exact
|
||||
trace above: the built-in text channels are `colorBlueGrey`, not
|
||||
`colorLightBlue` (`colorLightBlue` is indices 7 and 0x11, whose LogTextType
|
||||
names weren't identified in this pass — **UNKNOWN**, would need the
|
||||
DAT-driven `LogTextTypeEnumMapper` string table to name every index; see §5).
|
||||
|
||||
**Net effect for the screenshot in the prompt**: `"[General] <name> says, ..."` is
|
||||
**two** colours, not three — the whole line (brackets, "says,", the
|
||||
quoted message) in `colorBlueGrey`, and the name span in whatever
|
||||
`m_curTagFontColor` resolves to (see §5) wherever the tag's type is
|
||||
`"Tell"` (`0x10000001`). If the user's read genuinely showed three
|
||||
distinguishable hues, the third one is not explained by anything traced in
|
||||
this pass — flag as **UNKNOWN, possibly a rendering/outline-colour effect
|
||||
(`m_curOutlineColor`, also a field on `UIElement_Text`, untraced here) or
|
||||
a visual misread of anti-aliasing against the grey timestamp prefix.**
|
||||
|
||||
## 4. Is `StringInfo` the tag carrier?
|
||||
|
||||
**No.** Traced its full field set from the constructor/accessor bodies
|
||||
(`StringInfo::StringInfo @ 0x0042da60`, `::Reset @ 0x0042daf0`,
|
||||
`::IsValid @ 0x0042cbe0`, `::AddVariable_Int/UInt/Float/String/StringInfo
|
||||
@ 0x0042dde0-0x0042e7d0`): `m_Override` (0=table-driven / 1=literal /
|
||||
2=?), `m_stringID`, `m_tableID`, `m_strToken`, `m_LiteralValue`,
|
||||
`m_strEnglish`, `m_strComment`, and `m_variables` (an
|
||||
`IntrusiveHashTable<name, StringInfoData*>` for named-variable
|
||||
substitution into a localized template). None of these are colour, tag,
|
||||
or link fields — `StringInfo` is purely a **localization envelope**
|
||||
(string-table id + substitution variables, or a raw literal override via
|
||||
`SetLiteralValue`). Tagging is applied **after** the envelope is unwrapped:
|
||||
`StringInfo::GetString` (called inside `AppendStringInfoWithFont @
|
||||
0x00469de0`, §1c) resolves the final plain wide string, and *that* plain
|
||||
string is what `UIElement_Text::AddText_Internal`/`InqGlyphs` scans for
|
||||
`<...>` markup. So the two systems are cleanly separated: StringInfo
|
||||
answers "what text, and in what language", the glyph-list builder answers
|
||||
"does any of this text contain clickable/differently-coloured spans".
|
||||
|
||||
## 5. Which colour does the tagged name actually use?
|
||||
|
||||
**Structurally proven, exact value UNKNOWN.** §1c/1d prove the mechanism:
|
||||
`UIElement_Text::SetFontColorHelper(this, 0x1D, &m_curTagFontColor, arg4) @
|
||||
0x00469e1a` looks up property `0x1D` through the *identical*
|
||||
indexed-array-by-LogTextType path as property `0x1B` (line colour) — see
|
||||
`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does
|
||||
`InqProperty(propId) → array bounds check (arg4 < count) → indexed element
|
||||
copy`. And `UIElement_Text::InqGlyphs @ 0x00468ea0` proves the *use*: a
|
||||
glyph gets `m_curTagFontColor` instead of `m_curFontColor` specifically
|
||||
when its enclosing tag's `m_type == 0x10000001` (the "Tell" tag-name enum
|
||||
value, resolved via the DAT-driven `EnumMapper` category `0x18` — see §1d
|
||||
step 2). Tags of any *other* type (e.g. `IIDEnum`-based links used
|
||||
elsewhere in the client) are still clickable (non-null `Glyph.m_tag`) but
|
||||
render in the ordinary line colour — the green/special-colour behaviour is
|
||||
specific to Tell-type name links, not "any markup tag."
|
||||
|
||||
What I could **not** find: a second `BuildXxxColorLookupTable`-style
|
||||
function that populates property `0x1D`'s array the way
|
||||
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` populates `0x1B`
|
||||
(that function only ever calls `SetPropertyName(&var_18, 0x1b)` once, and
|
||||
the whole function body — read start to end — only ever writes to that one
|
||||
array before the final `this->m_chatLog->vtable->SetProperty(&var_18)`).
|
||||
Two explanations are consistent with what's traced and neither is
|
||||
confirmed:
|
||||
- Property `0x1D` is authored directly on the chat-log `UIElement_Text`
|
||||
widget via its `LayoutDesc` (a per-widget default, not something
|
||||
`ChatInterface` code builds at runtime) — plausible since
|
||||
`SetFontColorHelper`'s `InqProperty` call would find *any* property the
|
||||
widget inherits, not just ones `BuildChatColorLookupTable` wrote.
|
||||
- A second, unlocated runtime builder populates it elsewhere.
|
||||
|
||||
**UNKNOWN — needs either**: (a) a `LayoutDesc`/DAT dump of the chat-log
|
||||
window's property `0x1D` (or its default RGBAColor), or (b) a live cdb
|
||||
breakpoint on `UIElement_Text::SetFontColorHelper` with `arg2==0x1D` while
|
||||
a real Tell-tagged line renders, reading `this->m_curTagFontColor` after
|
||||
the call returns (same toolchain as `claude-memory/reference_retail_chat_colors.md`'s
|
||||
`x acclient!color*` / `dd` recipe). The user's screenshot reads it as
|
||||
green, and AC's clickable-name convention is widely remembered as green,
|
||||
but that is **not** something this pass proved from decomp — flagging it
|
||||
as inferred-from-screenshot/prior-knowledge, not decomp-verified.
|
||||
|
||||
## Open items / follow-ups
|
||||
|
||||
- §2: only one `RecvNotice_TextTag_IIDStringClick` override
|
||||
(`gmMainChatUI`) was traced for click behaviour; others exist (vtable
|
||||
slots reference `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` and
|
||||
`UIElement::MouseHover` as generic fallbacks — worth a second pass if
|
||||
acdream needs to replicate hover/tooltip behaviour, not just click).
|
||||
- §3: LogTextType names for indices 7, 0x11 (colorLightBlue), 0xE
|
||||
(shares colorBlueGrey with the built-in channels), and the seven
|
||||
unoverridden default-green indices (1, 0x10, 0x14, 0x17, 0x18, 0x19,
|
||||
0x22) are unidentified — the DAT-driven `LogTextTypeEnumMapper` string
|
||||
table (`struct __cppobj LogTextTypeEnumMapper`, `acclient.h:57333`)
|
||||
would name them; not pulled in this pass.
|
||||
- §5: the exact `m_curTagFontColor` RGBA value is unproven from static
|
||||
decomp alone — see the two follow-up options listed there.
|
||||
- The `m_format` field on `TextTag` (set from the tag's second colon-split
|
||||
segment, e.g. `"IIDString"` → the class-selector `1..4`) was read as a
|
||||
class-shape selector, consistent with the `TextTagFactory::MakeTag`
|
||||
switch, but its retail name/purpose beyond "which TextTag subclass" was
|
||||
not otherwise probed.
|
||||
|
||||
## RESOLVED: the tag colour is authored, and it is green
|
||||
|
||||
The research pass above could only prove the *mechanism* for the tag colour
|
||||
(property `0x1D`, applied per-glyph when a tag is open and its `m_type` is
|
||||
`0x10000001`), not its value — `ChatInterface::BuildChatColorLookupTable
|
||||
@0x004F31C0` builds only the ordinary `0x1B` array, so it correctly flagged the
|
||||
RGBA as UNKNOWN rather than assuming the green seen in a screenshot.
|
||||
|
||||
It is authored in the LayoutDesc, and it measures out of the installed DATs as:
|
||||
|
||||
chat window 0x2100006F, transcript element 0x10000011
|
||||
P0x1B (line colour) [0x00] R=204 G=204 B=204 A=255
|
||||
P0x1D (tag colour) [0x00] R= 0 G=178 B= 0 A=255 <- the green
|
||||
|
||||
Reproduce with:
|
||||
|
||||
dotnet run --project tools/LayoutDump -c Release -- 0x2100006F --colors
|
||||
|
||||
Two things worth carrying into the port:
|
||||
|
||||
- **The tag colour is per-ELEMENT, not per-LogTextType.** `0x1B` here is a
|
||||
one-entry array too, so on this element the ordinary colour comes from the
|
||||
runtime-built chat table while the tag colour comes from the authored
|
||||
property. A port that files "tag green" into the LogTextType colour table
|
||||
would be putting it in the wrong place.
|
||||
- The same `0x1D` green appears on more than one element in this layout, so it
|
||||
is not unique to the transcript.
|
||||
|
||||
`tools/LayoutDump --colors` was added for this measurement and prints the
|
||||
`0x1B`/`0x1D` arrays of every element in a layout.
|
||||
849
docs/research/2026-08-21-chat-texttag-model.md
Normal file
849
docs/research/2026-08-21-chat-texttag-model.md
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
# Retail chat TextTag / glyph-tag model — data model, lifetime, colour rule
|
||||
|
||||
Research-only. No source was modified for this document. All addresses are
|
||||
from the Sept 2013 EoR build (`refs/acclient.pdb` / `acclient.exe` v11.4186,
|
||||
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), as decompiled in
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) and
|
||||
`docs/research/named-retail/acclient.h` (verbatim retail struct headers).
|
||||
Every claim below cites `symbol @ 0xADDRESS`; anything not directly
|
||||
observed in the decompile is marked **UNKNOWN**.
|
||||
|
||||
This document explains the mechanism behind the retail behaviour: a
|
||||
speaker's name inside a chat line renders in a different colour and is
|
||||
clickable (click → prefill a tell to that person). The mechanism is a
|
||||
**glyph-level tag model**, not per-line colouring. `UIElement_Text`
|
||||
(the class backing chat log / most retail text widgets) keeps a list of
|
||||
`Glyph` structs, one per character, and each `Glyph` optionally points at
|
||||
a shared, reference-counted `TextTag` object. A **contiguous run of
|
||||
glyphs sharing the same `TextTag*` pointer** is what gets the special
|
||||
colour and the click behaviour — there is no separate "run" or "span"
|
||||
object; identity is pointer equality on `Glyph::m_tag`, discovered by
|
||||
linear walk every time it matters.
|
||||
|
||||
---
|
||||
|
||||
## 1. The `TextTag` type family
|
||||
|
||||
### 1.1 Struct layout
|
||||
|
||||
```
|
||||
acclient.h:45358
|
||||
struct __cppobj TextTag : ReferenceCountTemplate<1048576,0>
|
||||
{
|
||||
unsigned int m_type;
|
||||
unsigned int m_format;
|
||||
};
|
||||
```
|
||||
|
||||
`ReferenceCountTemplate<1048576,0>` (`acclient.h:7974`) is:
|
||||
|
||||
```
|
||||
struct __cppobj ReferenceCountTemplate<1048576,0>
|
||||
{
|
||||
ReferenceCountTemplate<1048576,0>Vtbl *vfptr; // +0x0
|
||||
unsigned int m_cRef; // +0x4
|
||||
};
|
||||
```
|
||||
|
||||
So on a live `TextTag`, `vfptr` is at `+0x0`, `m_cRef` at `+0x4`,
|
||||
`m_type` at `+0x8`, `m_format` at `+0xc`. Those exact offsets are used
|
||||
directly by the pseudo-C at several sites cited below (e.g.
|
||||
`*(uint32_t*)((char*)result + 8) = var_14` for `m_type`,
|
||||
`*(uint32_t*)((char*)ebx_1 + 8) != 0x10000001` for a runtime `m_type`
|
||||
comparison), which cross-checks the struct layout against the header.
|
||||
|
||||
`TextTagType` (the type of `m_type`/`m_format`) is only ever typedef'd:
|
||||
|
||||
```
|
||||
acclient.h:62585
|
||||
typedef unsigned int TextTagType;
|
||||
```
|
||||
|
||||
No named enum for it survived in the PDB (`grep`'d `TextTagType|TAG_TYPE|
|
||||
eTextTag` across `acclient.h` returns only that one typedef line). See
|
||||
§7 for what this means for the `0x10000001` sentinel.
|
||||
|
||||
### 1.2 Four concrete subclasses — what identifies/distinguishes a tag
|
||||
|
||||
```
|
||||
acclient.h:53947 struct __cppobj TextTag_IID : TextTag { unsigned int m_IID; };
|
||||
acclient.h:53953 struct __cppobj TextTag_IIDEnum : TextTag { unsigned int m_IID; unsigned int m_enum; };
|
||||
acclient.h:53960 struct __cppobj TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase<unsigned short> m_string; };
|
||||
acclient.h:53967 struct __cppobj TextTag_DID : TextTag { IDClass<_tagDataID,32,0> m_DID; };
|
||||
```
|
||||
|
||||
So a `TextTag` is a small, polymorphic, ref-counted "click payload"
|
||||
object. Its identity as far as glyphs/runs are concerned is just its
|
||||
**pointer value** (see §2). Its semantic identity — what clicking it
|
||||
actually means — is carried by the concrete subclass's extra field(s):
|
||||
|
||||
- `TextTag_DID` — wraps a `DataID` (a DAT-file object reference).
|
||||
- `TextTag_IID` — wraps an `IID` (an in-world Instance ID, i.e. a live
|
||||
object/creature/player's server-assigned id).
|
||||
- `TextTag_IIDEnum` — an `IID` plus an `enum` (a secondary
|
||||
small-integer qualifier).
|
||||
- `TextTag_IIDString` — an `IID` plus a `PStringBase<unsigned short>`
|
||||
(a wide string) — **this is the shape used for a clickable player
|
||||
name**: `m_IID` is the speaker's object id, `m_string` most plausibly
|
||||
carries their display name for building the tell command (see §6).
|
||||
|
||||
Each subclass overrides a fixed 7-slot vtable (`__vecDelDtor`,
|
||||
`ParseEndTag`, `ParseStartTag`, `BuildEndTag`, `BuildStartTag`,
|
||||
`HandleClick`, `BuildStartTagData` — confirmed layout dumped verbatim at
|
||||
`acclient_2013_pseudo_c.txt:959321-959375`, e.g. `TextTag_DID::`vftable''
|
||||
@ `0x0079e09c`).
|
||||
|
||||
**Decompiler artifact to flag**: in the vtable dump, `TextTag_IID`'s
|
||||
`ParseStartTag`/`BuildStartTagData` slots point at
|
||||
`TextTag_DID::ParseStartTag` / `TextTag_DID::BuildStartTagData`
|
||||
(`acclient_2013_pseudo_c.txt:959363,959367`), not at distinct
|
||||
`TextTag_IID::*` functions. This is almost certainly MSVC identical-code
|
||||
folding (COMDAT folding) — `TextTag_IID`'s parse/build logic for a bare
|
||||
32-bit `m_IID` is byte-identical to `TextTag_DID`'s for a bare 32-bit
|
||||
`m_DID.id`, so the linker merged them and the PDB can only attribute the
|
||||
merged function to one of the two symbols. Treat this as "same code,
|
||||
shared by both classes," not "IID delegates to DID."
|
||||
|
||||
### 1.3 Factory / parsing — `TextTagFactory::MakeTag`
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:132871
|
||||
00478480 class TextTag* TextTagFactory::MakeTag(class PStringBase<unsigned short> const* arg1)
|
||||
```
|
||||
|
||||
Given the text between `<` and `>` (the delimiters are stripped by the
|
||||
caller — see §3), `MakeTag`:
|
||||
|
||||
1. Finds the first `:` in the substring (`FindChar(':')`,
|
||||
`acclient_2013_pseudo_c.txt:132904`). If none is found, parsing fails
|
||||
and `MakeTag` returns `0` (`return 0;` @ `0x478700`,
|
||||
`acclient_2013_pseudo_c.txt:133064`). **This is the mechanism an "end
|
||||
tag" uses to close a run — see §3.2.**
|
||||
2. Resolves the substring before the first `:` to an enum value via
|
||||
`EnumMapper::InqEnum` (`acclient_2013_pseudo_c.txt:132921`).
|
||||
3. Finds the *second* `:` and resolves that substring to a second enum
|
||||
value, also via `EnumMapper::InqEnum`
|
||||
(`acclient_2013_pseudo_c.txt:132954`), then `switch`es on it
|
||||
(`acclient_2013_pseudo_c.txt:132955`) to allocate one of the four
|
||||
concrete subclasses:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:132955-133051 (paraphrased switch table)
|
||||
case 1: result = TextTag_DID::TextTag_DID(...)
|
||||
case 2: result = TextTag_IID::TextTag_IID(...)
|
||||
case 3: result = TextTag_IIDEnum::TextTag_IIDEnum(...)
|
||||
case 4: result = TextTag_IIDString::TextTag_IIDString(...)
|
||||
```
|
||||
|
||||
4. Stores the two resolved enum values into the new object:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:132981-132982
|
||||
*(int32_t*)((char*)result + 8) = var_14; // m_type = first EnumMapper::InqEnum result
|
||||
*(int32_t*)((char*)result + 0xc) = var_18; // m_format = second EnumMapper::InqEnum result (== the switch discriminant, 1-4)
|
||||
```
|
||||
|
||||
5. Delegates the remainder of the string (after the second `:`) to the
|
||||
new object's own `ParseStartTag` virtual (via the vtable, at
|
||||
`acclient_2013_pseudo_c.txt:133008`,
|
||||
`*(int32_t*)((char*)vtable + 8)(__return)` — vtable slot `+0x8` =
|
||||
`ParseStartTag` per the layout in §1.1) to consume the
|
||||
type-specific payload (the `IID`/`DID`/`enum`/`string` fields).
|
||||
If that fails, the freshly-allocated tag is released
|
||||
(`ReferenceCountTemplate<1048576,0>::Release(result)` @
|
||||
`acclient_2013_pseudo_c.txt:133016`) and no tag is produced for this
|
||||
span.
|
||||
|
||||
So the overall wire format `MakeTag` parses is
|
||||
**`TYPE_NAME:FORMAT_NAME:PAYLOAD`**, where `TYPE_NAME` selects `m_type`
|
||||
(a semantic category — see §7) and `FORMAT_NAME` selects which concrete
|
||||
subclass parses `PAYLOAD` (`m_format` doubles as "which of the four
|
||||
built-in payload shapes this is").
|
||||
|
||||
Round-trip confirmation comes from `TextTag::BuildStartTag` (the
|
||||
inverse operation, used when a tagged region is serialized back to
|
||||
text — see §2.3):
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:133619-133669 (TextTag::BuildStartTag @ 0x478fe0)
|
||||
eax_1 = EnumMapper::InqString(0x18, this->m_type, &...); // name for m_type
|
||||
eax_6 = EnumMapper::InqString(0x18, this->m_format, &...); // name for m_format
|
||||
this->vtable->BuildStartTagData(&...); // subclass-specific payload text
|
||||
PStringBase<unsigned short>::sprintf(arg2, u"<%ls:%ls%:%ls>");
|
||||
```
|
||||
|
||||
(`u"<%ls:%ls%:%ls>"` — the stray `%` immediately after the second `%ls`
|
||||
is very likely a Binary Ninja string-literal rendering artifact, not a
|
||||
real extra `%` in the format string; the surrounding logic only ever
|
||||
supplies three substitutions. **Flagged as uncertain** — resolving it
|
||||
precisely would need a raw byte dump of the `.rdata` string at its
|
||||
address rather than BN's decompiled string preview.)
|
||||
|
||||
`EnumMapper::InqString`/`InqEnum` both route through a *table id*
|
||||
argument of `0x18` (`acclient_2013_pseudo_c.txt:133627,133636,133725,
|
||||
133364`, and `EnumMapper::InqEnum`'s call site in `MakeTag` at
|
||||
`acclient_2013_pseudo_c.txt:132918-132921`). This table id is what
|
||||
selects which named-enum table (`m_type`'s table vs. individual
|
||||
subclass fields' tables) to search — see §7 for why we can't yet name
|
||||
what string maps to `m_type == 0x10000001`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Attachment model — per-glyph, not per-run, not per-line
|
||||
|
||||
### 2.1 `Glyph` struct
|
||||
|
||||
```
|
||||
acclient.h:45330
|
||||
struct __cppobj Glyph
|
||||
{
|
||||
unsigned __int16 m_data; // the character
|
||||
int m_width;
|
||||
int m_height;
|
||||
RGBAColor m_color; // acclient.h:8100 — 4 floats (r,g,b,a), resolved per-glyph at append time
|
||||
Font *m_font;
|
||||
TextTag *m_tag; // nullable, shared, ref-counted
|
||||
};
|
||||
```
|
||||
|
||||
`GlyphList` (`acclient.h:45305`) is a doubly-linked `List<Glyph>` plus a
|
||||
cached `SmartArray<GlyphLine,1>` (line-break layout cache) and some
|
||||
bookkeeping (`m_nMaxCharacters`, `m_nFirstInvalidPosition`, etc.).
|
||||
`UIElement_Text` (`acclient.h:53392`) owns exactly one `GlyphList
|
||||
m_glyphList` (the live/editable text) and a second `GlyphList
|
||||
m_glTruncate` (used by the truncation machinery — not investigated
|
||||
further here).
|
||||
|
||||
**There is no `TextTag*` field, run/span object, or index range
|
||||
anywhere on `GlyphList`, `GlyphLine`, or `UIElement_Text`.** The *only*
|
||||
place a tag pointer lives is `Glyph::m_tag`, one per character. A
|
||||
"tagged run" is purely an emergent property: a maximal sequence of
|
||||
adjacent glyph list nodes whose `m_tag` fields are pointer-equal.
|
||||
|
||||
### 2.2 How code discovers a run boundary
|
||||
|
||||
Every place in the decompile that needs to know "does this edit split a
|
||||
tagged run" or "did the tag change here" does the same thing: walk
|
||||
adjacent glyphs and compare `data.m_tag` by pointer. Three load-bearing
|
||||
examples:
|
||||
|
||||
**On insert**, if the two glyphs immediately either side of the
|
||||
insertion point shared a tag, the whole tag is stripped (see §3.1 for
|
||||
why it's the *whole* tag, not just the boundary):
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:127079-127090 (GlyphList::Insert @ 0x472e70)
|
||||
class ListNode<Glyph>* prev = _current->prev;
|
||||
if (prev != 0)
|
||||
{
|
||||
class TextTag* m_tag = prev->data.m_tag;
|
||||
if (m_tag == _current->data.m_tag)
|
||||
GlyphList::RemoveTextTag(this, m_tag);
|
||||
}
|
||||
```
|
||||
|
||||
**On delete**, the boundary glyph of the doomed range is checked the
|
||||
same way:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:127294-127302 (inside GlyphList::Delete @ 0x4730a0)
|
||||
class TextTag* m_tag = edi->data.m_tag;
|
||||
if (m_tag != 0)
|
||||
{
|
||||
for (class ListNode<Glyph>* i = this_1->m_glyphList._head; i != 0; i = i->next)
|
||||
{
|
||||
if (i->data.m_tag == m_tag)
|
||||
Glyph::SetTag(i, nullptr);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**On bulk append** (`GlyphList::AddText`), the glyph immediately before
|
||||
the insertion point is compared to the first glyph being spliced in:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:127354-127362 (inside GlyphList::AddText @ 0x473190)
|
||||
int32_t ebx = *(int32_t*)((char*)prev + 0x20); // prev->data.m_tag (offset +0x20 into Glyph)
|
||||
if ((ebx == var_8->data.m_tag && ebx != 0))
|
||||
{
|
||||
for (class ListNode<Glyph>* i = this_2->m_glyphList._head; i != 0; i = i->next)
|
||||
if (i->data.m_tag == ebx)
|
||||
Glyph::SetTag(i, nullptr);
|
||||
}
|
||||
```
|
||||
|
||||
**On text serialization** (`GlyphList::InqText`, used to reconstruct
|
||||
markup text — e.g. what `ChatInterface::TruncateChatLog` reads before
|
||||
truncating, see §3.3), the same pointer-compare drives when to close
|
||||
the previous tag's markup and open the new one:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:127671-127700 (inside GlyphList::InqText @ 0x473560)
|
||||
class TextTag* m_tag = _head->data.m_tag;
|
||||
if ((eax_4 != 0 && m_tag != m_tag_1)) // tag changed since previous glyph
|
||||
{
|
||||
if (m_tag_1 != 0)
|
||||
m_tag_1->vtable->BuildEndTag(&arg5); // close previous run's markup
|
||||
if (m_tag != 0)
|
||||
m_tag->vtable->BuildStartTag(&arg5); // open new run's markup
|
||||
}
|
||||
...
|
||||
m_tag_1 = m_tag; // carried into next iteration
|
||||
```
|
||||
|
||||
So: **a contiguous run is identified only by walking neighbours and
|
||||
comparing `Glyph::m_tag` pointers; there is no cached run table.**
|
||||
Every operation that could break a run's contiguity re-derives the
|
||||
answer by walking.
|
||||
|
||||
### 2.3 How a tag attaches during append — `UIElement_Text::InqGlyphs`
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:115983
|
||||
00468ea0 uint8_t __stdcall UIElement_Text::InqGlyphs(class UIElement_Text* this @ ecx, class PStringBase<unsigned short> const* arg2, class SmartArray<Glyph,1>* arg3)
|
||||
```
|
||||
|
||||
This converts a raw wide string (which may contain embedded
|
||||
`<TYPE:FORMAT:DATA>` markup) into a flat array of `Glyph`s, one
|
||||
character at a time. It is called from `UIElement_Text::AddText_Internal`
|
||||
(`acclient_2013_pseudo_c.txt:116791`), which is itself the single choke
|
||||
point every text-append path funnels through (`AppendText`,
|
||||
`AppendStringInfo`, `AppendStringInfoWithFont`, `CharacterHandler`, the
|
||||
paste handler, etc. — confirmed by grepping every
|
||||
`AddText_Internal(` call site, `acclient_2013_pseudo_c.txt:116886,116916,
|
||||
116994,117008,117026,117043,117106`).
|
||||
|
||||
The character loop keeps one local, `ebx_1` (`class TextTag*`), which is
|
||||
the **currently-open tag while walking characters** — this is NOT a
|
||||
field on `UIElement_Text`; it's a local in this one function's loop:
|
||||
|
||||
- On seeing `<` (`0x3c`), it scans to the matching `>` (`0x3e`),
|
||||
extracts the substring, and calls
|
||||
`TextTagFactory::MakeTag(...)` (`acclient_2013_pseudo_c.txt:116117`).
|
||||
The `<...>` delimiter text itself is **not emitted as glyphs** — the
|
||||
character cursor (`edi_1`) is advanced past the closing `>` before
|
||||
glyph emission resumes (`acclient_2013_pseudo_c.txt:116124-116131`).
|
||||
The result — a new tag pointer, or `0` if `MakeTag` failed to parse —
|
||||
replaces `ebx_1` for subsequent characters.
|
||||
- For every ordinary character, the glyph's colour is chosen from `ebx_1`
|
||||
(see §5 for the exact rule) and a `Glyph` is constructed carrying
|
||||
`ebx_1` as its `m_tag` (§4 covers the ref-count mechanics of that
|
||||
construction).
|
||||
|
||||
**Because `ebx_1` is never explicitly reset to `0` on a "closing
|
||||
bracket," the only way a tag run ends is for a later `<...>` span to
|
||||
fail to parse into a valid tag** (no `:` found → `MakeTag` returns `0`,
|
||||
§1.3 step 1). That is exactly what `TextTag::BuildEndTag` emits:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:133714-133748 (TextTag::BuildEndTag @ 0x479190)
|
||||
if (this->m_type != 0)
|
||||
{
|
||||
EnumMapper::InqString(0x18, this->m_type, &var_4); // just the type's name, no ':'
|
||||
PStringBase<unsigned short>::sprintf(arg2, u"<\%ls>");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
```
|
||||
|
||||
i.e. the end-tag markup is a bracketed **type name with no colon**
|
||||
(`u"<\%ls>"` — the `\` immediately before `%` is almost certainly a
|
||||
Binary Ninja rendering artifact for a literal `/`, i.e. the real string
|
||||
is most plausibly `"</%ls>"`; **flagged as uncertain**, same caveat as
|
||||
§1.3 — BN's string preview/escaping for embedded control characters is
|
||||
not reliably faithful and this should be confirmed with a raw
|
||||
`.rdata` byte dump before being relied on verbatim). Since that
|
||||
substring has no `:`, `TextTagFactory::MakeTag`'s `FindChar(':')` check
|
||||
fails and it returns `0` → `ebx_1` becomes `0` → every glyph after that
|
||||
point is untagged, until the next successfully-parsed `<TYPE:FORMAT:
|
||||
DATA>` start tag. **Start/end tags are symmetric in markup shape but
|
||||
asymmetric in mechanism**: a start tag is a successful `MakeTag` parse;
|
||||
an end tag is nothing more than *any* bracketed text that fails to
|
||||
parse as one.
|
||||
|
||||
---
|
||||
|
||||
## 3. Lifetime
|
||||
|
||||
### 3.1 Creation, retention, destruction — reference counting
|
||||
|
||||
`TextTag` inherits `ReferenceCountTemplate<1048576,0>` (`m_cRef` at
|
||||
`+0x4`, `vfptr` at `+0x0`). `TextTagFactory::MakeTag` hands back an
|
||||
object with `m_cRef == 1` (set in the base ctor,
|
||||
`acclient_2013_pseudo_c.txt:133587-133594`,
|
||||
`TextTag::TextTag @ 0x478f80`: `this->m_cRef = 1;`). From there,
|
||||
ownership is **fully distributed across every `Glyph` that points at
|
||||
it** — there is no separate owning list or registry.
|
||||
|
||||
**Adopting a tag reference increments the count.** The parameterized
|
||||
`Glyph` constructor used by `InqGlyphs` when building a new glyph does
|
||||
this explicitly:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:129088-129106 (Glyph::Glyph(this, char, color*, font*, tag*) @ 0x474a90)
|
||||
*(uint32_t*)((char*)this_1 + 0x20) = arg5; // this->m_tag = tag
|
||||
Glyph::SetFont(this_1, arg4);
|
||||
int32_t eax_5 = *(uint32_t*)((char*)this_1 + 0x20);
|
||||
if (eax_5 != 0)
|
||||
InterlockedIncrement((eax_5 + 4)); // tag->m_cRef++
|
||||
```
|
||||
|
||||
The copy assignment operator (used whenever a glyph is copied — e.g.
|
||||
splicing the freshly-built `SmartArray<Glyph,1>` from `InqGlyphs` into
|
||||
the live `List<Glyph>`, or `List<Glyph>::flush`'s per-node teardown)
|
||||
does the matching release-then-acquire:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:128949-128969 (Glyph::operator= @ 0x474870)
|
||||
class TextTag* m_tag_1 = this->m_tag;
|
||||
if (m_tag_1 != 0)
|
||||
{
|
||||
if (InterlockedDecrement(&m_tag_1->m_cRef) == 0 && m_tag_1 != 0)
|
||||
m_tag_1->vtable->__vecDelDtor(1); // release old tag, free at zero
|
||||
this->m_tag = nullptr;
|
||||
}
|
||||
class Font* m_font = arg2->m_font;
|
||||
this->m_font = m_font;
|
||||
this->m_tag = arg2->m_tag;
|
||||
if (this->m_tag != 0)
|
||||
InterlockedIncrement(&this->m_tag->m_cRef); // acquire new tag
|
||||
```
|
||||
|
||||
**Releasing decrements the count and self-deletes at zero, via the
|
||||
destructor**:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:128905-128925 (Glyph::~Glyph @ 0x474820)
|
||||
class TextTag* m_tag = this->m_tag;
|
||||
if (m_tag != 0)
|
||||
{
|
||||
if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0)
|
||||
m_tag->vtable->__vecDelDtor(1);
|
||||
this->m_tag = nullptr;
|
||||
}
|
||||
```
|
||||
|
||||
**`Glyph::SetTag` is the exception to note carefully** — it releases the
|
||||
*old* tag (decrement, free at zero) but does **not** increment the
|
||||
refcount of the incoming tag:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:128977-128993 (Glyph::SetTag @ 0x474920)
|
||||
void Glyph::SetTag(class Glyph* this, class TextTag* arg2)
|
||||
{
|
||||
class TextTag* m_tag = this->m_tag;
|
||||
if (m_tag == 0)
|
||||
{
|
||||
this->m_tag = arg2;
|
||||
return;
|
||||
}
|
||||
if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0)
|
||||
m_tag->vtable->__vecDelDtor(1);
|
||||
this->m_tag = nullptr;
|
||||
this->m_tag = arg2;
|
||||
}
|
||||
```
|
||||
|
||||
Every call site of `Glyph::SetTag` actually observed in this decompile
|
||||
passes `nullptr` for `arg2` (§2.2's three excerpts, plus the identical
|
||||
pattern at `acclient_2013_pseudo_c.txt:126830`,
|
||||
`GlyphList::RemoveTextTag`). In practice `SetTag` is only ever used as
|
||||
"sever this glyph's reference to whatever tag it has" — a porting
|
||||
engineer must not assume the general two-argument form is
|
||||
refcount-safe for a non-null argument; if a call site with a non-null
|
||||
tag is ever found, it must AddRef beforehand, mirroring the
|
||||
constructor/`operator=` pattern above.
|
||||
|
||||
### 3.2 Whole-tag invalidation on any edit that could split a run
|
||||
|
||||
This is the single most important porting gotcha in this document.
|
||||
None of the three "does this edit touch a tag boundary" checks in §2.2
|
||||
try to *split* a run in two. All of them, on detecting that an edit
|
||||
would break contiguity, call `Glyph::SetTag(i, nullptr)` on **every
|
||||
glyph in the entire `GlyphList` that shares that tag pointer** — not
|
||||
just the glyphs adjacent to the edit. See `GlyphList::RemoveTextTag`:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:126822-126833
|
||||
void __thiscall GlyphList::RemoveTextTag(class GlyphList* this, class TextTag* arg2)
|
||||
{
|
||||
if (arg2 != 0)
|
||||
{
|
||||
for (class ListNode<Glyph>* i = this->m_glyphList._head; i != 0; i = i->next)
|
||||
{
|
||||
if (i->data.m_tag == arg2)
|
||||
Glyph::SetTag(i, nullptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`GlyphList::Insert`'s boundary check (§2.2) calls exactly this function
|
||||
when it detects a would-be-split. `GlyphList::Delete` and
|
||||
`GlyphList::AddText` inline the identical "walk the whole list, clear
|
||||
every glyph sharing this tag" loop rather than calling
|
||||
`RemoveTextTag` directly, but the effect is the same. **The retail
|
||||
behaviour is: any edit that would leave a discontiguous run under one
|
||||
tag pointer instead destroys the tag for the ENTIRE list, not just the
|
||||
disturbed portion.** A tagged player name that gets partially edited or
|
||||
partially deleted loses its colour/clickability everywhere it appears
|
||||
in that `GlyphList`, not just at the edit site.
|
||||
|
||||
### 3.3 Scroll-off / truncation — `ChatInterface::TruncateChatLog`
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:247098
|
||||
004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2)
|
||||
```
|
||||
|
||||
This reads the chat log's current text length (via
|
||||
`UIElement_Text::GetText`, which is backed by `GlyphList::InqText`, §2.2)
|
||||
and, if it exceeds the cap (`arg2`), calls:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:247148,247178
|
||||
UIElement_Text::BeheadText(this->m_chatLog, N, 1);
|
||||
```
|
||||
|
||||
`BeheadText` is a thin wrapper:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:116727-116731 (UIElement_Text::BeheadText @ 0x469970)
|
||||
void __thiscall UIElement_Text::BeheadText(class UIElement_Text* this, uint32_t arg2, uint8_t arg3)
|
||||
{
|
||||
UIElement_Text::DeleteSection(this, 0, arg2, arg3);
|
||||
}
|
||||
```
|
||||
|
||||
which in turn calls `GlyphList::Delete` (`acclient_2013_pseudo_c.txt:
|
||||
116680`, inside `UIElement_Text::DeleteSection @ 0x469800`) — **the
|
||||
exact same generic deletion path used for any other text edit** (typed
|
||||
backspace, cut, selection delete). There is no special-cased "truncate
|
||||
the chat log" tag handling. Consequences, following directly from §3.1
|
||||
and §3.2:
|
||||
|
||||
- A `TextTag` whose glyphs are entirely scrolled off is destroyed the
|
||||
ordinary way: `GlyphList::Delete` walks the doomed range, finds the
|
||||
shared tag, `Glyph::SetTag(..., nullptr)`s every glyph that shares it
|
||||
(§3.2), decrementing to zero and freeing it.
|
||||
- A `TextTag` whose glyphs are only **partially** scrolled off (the
|
||||
truncation boundary falls inside a tagged name) has the tag stripped
|
||||
from **all** its glyphs, including the ones that remain visible — per
|
||||
§3.2's whole-list behaviour. The surviving remnant of a truncated
|
||||
tagged name renders and behaves as plain untagged text. This is a
|
||||
concrete, verified retail behaviour, not a hypothesis — it falls
|
||||
directly out of `GlyphList::Delete`'s implementation, which
|
||||
`BeheadText`/`TruncateChatLog` invoke with no special-casing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Colour rule — property `0x1b` (font colour) vs. `0x1d` (tag font colour)
|
||||
|
||||
### 4.1 `UIElement_Text`'s "current" state fields
|
||||
|
||||
```
|
||||
acclient.h:53392-53420 (struct UIElement_Text, relevant fields with confirmed field order)
|
||||
RGBAColor m_curFontColor; // used for the property "0x1b" value
|
||||
Font *m_curFontObj; // used for the property "0x1a" value
|
||||
RGBAColor m_curTagFontColor; // used for the property "0x1d" value
|
||||
unsigned int m_curOutlineColor;
|
||||
```
|
||||
|
||||
The field ORDER in the header (`m_curFontColor`, `m_curFontObj`,
|
||||
`m_curTagFontColor` back to back) matches the raw offset arithmetic seen
|
||||
at the glyph-construction site in `InqGlyphs`
|
||||
(`acclient_2013_pseudo_c.txt:116153-116162`, this-relative offsets
|
||||
`0x6a4` for `m_curFontColor` and `0x6b8` for `m_curTagFontColor`, a
|
||||
`0x14`-byte gap = 16 bytes of `RGBAColor` + 4 bytes of the `Font*`
|
||||
pointer in between) — this cross-check confirms the struct layout
|
||||
against the pseudo-C's raw pointer math.
|
||||
|
||||
### 4.2 Setting them per append — `UIElement_Text::AppendStringInfoWithFont`
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:117031-117048
|
||||
00469de0 void __thiscall UIElement_Text::AppendStringInfoWithFont(class UIElement_Text* this, class StringInfo const* arg2, int32_t arg3, int32_t arg4)
|
||||
{
|
||||
UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3);
|
||||
UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4);
|
||||
UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4);
|
||||
...
|
||||
UIElement_Text::AddText_Internal(this, m_charbuffer, 3);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Both colour properties are refreshed from the **same caller-supplied
|
||||
index**, `arg4`, immediately before the string is appended
|
||||
(`AddText_Internal` → `InqGlyphs`, §2.3). The same three-call pattern
|
||||
(`0x1a`/`0x1b`/`0x1d`, same index argument) recurs at every other append
|
||||
entry point that carries a colour index:
|
||||
`UIElement_Text::AppendText`/`AppendStringInfo`'s shared helper at
|
||||
`acclient_2013_pseudo_c.txt:115213-115222`, the string-download
|
||||
completion handler at `acclient_2013_pseudo_c.txt:117098-117104` (index
|
||||
taken from the queued download's own stored index,
|
||||
`ebx_2[0x25]`/`ebx_2[0x26]`), and the reset-to-index-0 call at
|
||||
`acclient_2013_pseudo_c.txt:117546-117548`.
|
||||
|
||||
### 4.3 What `SetFontColorHelper` actually does — an indexed property array
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:113699 (UIElement_Text::SetFontColorHelper @ 0x466ac0)
|
||||
void __thiscall UIElement_Text::SetFontColorHelper(class UIElement_Text* this, uint32_t arg2 /*propId*/, class RGBAColor* arg3 /*out*/, uint32_t arg4 /*index*/)
|
||||
```
|
||||
|
||||
Simplified control flow (BN's exact vtable-offset dispatch on `0xf0`,
|
||||
`0xf4`, `0x98` is not named by the PDB — see caveat below):
|
||||
|
||||
1. `this->vtable->InqProperty(propId, &var_10)` — looks up the
|
||||
UIElement's own authored property (`0x1b` or `0x1d`) via its normal
|
||||
`UIElement` property mechanism, i.e. this is a
|
||||
**LayoutDesc/DAT-authored per-element property**, not global engine
|
||||
state.
|
||||
2. If found, the returned property object is treated as an *indexed
|
||||
collection*: a virtual call through vtable offset `+0xf0`
|
||||
(`acclient_2013_pseudo_c.txt:113743`) that plausibly returns the
|
||||
collection's element count into `arg2`; a bounds check
|
||||
`if (arg4 < arg2)`; then a virtual call through `+0xf4`
|
||||
(`acclient_2013_pseudo_c.txt:113758`) that plausibly fetches the
|
||||
sub-property at index `arg4`; then a virtual call through `+0x98`
|
||||
(`acclient_2013_pseudo_c.txt:113761`) that plausibly extracts an
|
||||
`RGBAColor` from that sub-property into the caller's `arg3` output.
|
||||
3. If any step fails (property absent, index out of range), `arg3` (the
|
||||
caller's `m_curFontColor`/`m_curTagFontColor`) is left untouched —
|
||||
i.e. it retains whatever colour it already held from a previous
|
||||
append.
|
||||
|
||||
**Caveat**: BN does not resolve names for the `+0xf0`/`+0xf4`/`+0x98`
|
||||
virtual calls (they're dispatched through a generic `BaseProperty`-family
|
||||
vtable, and the pseudo-C prints them as raw `(*(uint32_t*)(vtable +
|
||||
offset))(...)` calls). The functional interpretation above
|
||||
("count / get-at-index / get-color") is inferred from the argument
|
||||
shapes and control flow (an `arg4 < count` bounds check immediately
|
||||
followed by an index-parameterized fetch), not from a symbol. Treat the
|
||||
step-by-step mechanics as **probable, not certain** — the porting-load-
|
||||
bearing fact that IS certain is the *outcome*: property `0x1b` and
|
||||
property `0x1d` are each an array of colours on the `UIElement_Text`,
|
||||
indexed by the same `arg4` the caller supplies, and `SetFontColorHelper`
|
||||
resolves one colour from each array into `m_curFontColor` /
|
||||
`m_curTagFontColor` respectively before the string is walked into
|
||||
glyphs. This lines up with `claude-memory/project_chat_digest.md`'s
|
||||
`LogTextType colors` note — `arg4` is almost certainly the `LogTextType`
|
||||
of the message being appended (a per-message-category colour index),
|
||||
and property `0x1d` is a **parallel, per-category array of "tag"
|
||||
colours** — i.e. retail authors one link/tag colour per chat category,
|
||||
not one global link colour.
|
||||
|
||||
### 4.4 Which colour a glyph actually gets — the `m_type == 0x10000001` gate
|
||||
|
||||
Back in `UIElement_Text::InqGlyphs`, per character, the code picks
|
||||
between the two "current" colours based on whether a tag is open **and**
|
||||
that tag's `m_type` equals a specific sentinel:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:116150-116162
|
||||
void* edx_15;
|
||||
void* esi_6;
|
||||
if ((ebx_1 == 0 || *(uint32_t*)((char*)ebx_1 + 8) != 0x10000001))
|
||||
{
|
||||
esi_6 = esp_1[6]; // this (UIElement_Text*)
|
||||
edx_15 = ((char*)esi_6 + 0x6a4); // &this->m_curFontColor
|
||||
}
|
||||
else
|
||||
{
|
||||
esi_6 = esp_1[6];
|
||||
edx_15 = ((char*)esi_6 + 0x6b8); // &this->m_curTagFontColor
|
||||
}
|
||||
```
|
||||
|
||||
(`ebx_1 + 8` is `TextTag::m_type`, per the struct layout in §1.1.)
|
||||
`edx_15` is then passed straight into the parameterized `Glyph`
|
||||
constructor as the colour source (`acclient_2013_pseudo_c.txt:116173-
|
||||
116180`). So, precisely:
|
||||
|
||||
- No open tag (`ebx_1 == 0`) → glyph gets `m_curFontColor` (property
|
||||
`0x1b`'s indexed value).
|
||||
- Open tag, but its `m_type != 0x10000001` → **still**
|
||||
`m_curFontColor`. Not every tag type gets the special colour.
|
||||
- Open tag with `m_type == 0x10000001` → glyph gets `m_curTagFontColor`
|
||||
(property `0x1d`'s indexed value).
|
||||
|
||||
**This is the exact rule the task asked for**: property `0x1b` is the
|
||||
default/base colour used for all untagged text and for any tag whose
|
||||
type isn't the specially-recognized one; property `0x1d` is used only
|
||||
for glyphs inside a tag of that one recognized type, and both are
|
||||
selected from the same caller-supplied colour-category index. See §7
|
||||
for what is and is not known about what `0x10000001` names.
|
||||
|
||||
### 4.5 "Currently open tag" state
|
||||
|
||||
There is **no persistent "currently open tag" field on `UIElement_Text`**
|
||||
— `m_curFontColor`/`m_curFontObj`/`m_curTagFontColor` are the *colour
|
||||
palette currently in effect for this append call* (refreshed once per
|
||||
`AppendStringInfoWithFont`/`AppendText` call from the indexed DAT
|
||||
properties), not per-tag state. The actual "is a tag open right now,
|
||||
and which one" state during the character walk is the **local variable
|
||||
`ebx_1` inside `UIElement_Text::InqGlyphs`'s loop** (§2.3) — it does not
|
||||
outlive one call to `InqGlyphs`/`AddText_Internal`. Each append call
|
||||
starts fresh with no tag open, and the markup embedded in that call's
|
||||
own string is what opens/closes tags within it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Click dispatch
|
||||
|
||||
Each concrete subclass's `HandleClick` (vtable slot `+0x14`, per §1.2)
|
||||
forwards to a `ECM_UI::SendNotice_TextTag_*Click` free function, which —
|
||||
per the `NoticeHandler` vtable declared in `acclient.h:30237-30240` — is
|
||||
a broadcast notice any registered `NoticeHandler` (e.g. the chat/social
|
||||
UI) can receive via a matching `RecvNotice_TextTag_*Click` virtual:
|
||||
|
||||
```
|
||||
acclient_2013_pseudo_c.txt:133078-133085 (TextTag_DID::HandleClick @ 0x478740)
|
||||
ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id);
|
||||
|
||||
acclient_2013_pseudo_c.txt:133521-133528 (TextTag_IID::HandleClick @ 0x478e80)
|
||||
ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID);
|
||||
|
||||
acclient_2013_pseudo_c.txt:133328-133335 (TextTag_IIDEnum::HandleClick @ 0x478b40)
|
||||
ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum);
|
||||
|
||||
acclient_2013_pseudo_c.txt:133150-133157 (TextTag_IIDString::HandleClick @ 0x478840)
|
||||
ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string);
|
||||
```
|
||||
|
||||
matching the `NoticeHandler` vtable slots:
|
||||
|
||||
```
|
||||
acclient.h:30237-30240
|
||||
void (__thiscall *RecvNotice_TextTag_DIDClick)(NoticeHandler *this, unsigned int, IDClass<_tagDataID,32,0>);
|
||||
void (__thiscall *RecvNotice_TextTag_IIDClick)(NoticeHandler *this, unsigned int, unsigned int);
|
||||
void (__thiscall *RecvNotice_TextTag_IIDEnumClick)(NoticeHandler *this, unsigned int, unsigned int, unsigned int);
|
||||
void (__thiscall *RecvNotice_TextTag_IIDStringClick)(NoticeHandler *this, unsigned int, unsigned int, PStringBase<unsigned short> *);
|
||||
```
|
||||
|
||||
This strongly supports the observed behaviour ("clicking a speaker's
|
||||
name in chat prefills a tell to them"): a chat name is plausibly tagged
|
||||
`TextTag_IIDString`, carrying the speaker's `IID` (their in-world
|
||||
object id — the correct addressee for a `/t` tell) and `m_string`
|
||||
(plausibly the speaker's display name — needed because the retail tell
|
||||
command syntax is name-based, not id-based). Clicking dispatches
|
||||
`ECM_UI::SendNotice_TextTag_IIDStringClick(type, IID, &name)`, and
|
||||
whichever `NoticeHandler` owns the chat input box (this document did
|
||||
not trace that far — **UNKNOWN, needs a search of `RecvNotice_TextTag_
|
||||
IIDStringClick` overrides across the UI classes to find which panel
|
||||
consumes it and confirm it prefills `/t "name" `**) reacts by loading
|
||||
that into the input field.
|
||||
|
||||
**Not traced from HandleClick backward to the mouse-hit-test that finds
|
||||
"which glyph, hence which tag, is under the cursor."** No literal
|
||||
`->vtable->HandleClick(...)` call site was found via text grep (it's an
|
||||
indirect vtable call, invisible to a literal-string search); locating
|
||||
the exact hit-test function that resolves a click's screen position to
|
||||
a glyph index and reads that glyph's `m_tag` was out of scope for this
|
||||
pass. **UNKNOWN — needs a targeted search for the `UIElement_Text`
|
||||
mouse-down handler** (candidates: something built on
|
||||
`GlyphList::FindPosFromLineAndPixels` @
|
||||
`acclient_2013_pseudo_c.txt:127424`, which is already known to resolve
|
||||
screen pixels to a glyph index and is a very likely component of that
|
||||
path, but the actual click-to-`HandleClick` wiring was not confirmed).
|
||||
|
||||
---
|
||||
|
||||
## 6. Summary — the model to port
|
||||
|
||||
1. **Data model**: `TextTag` is a small polymorphic ref-counted object
|
||||
(`m_type`, `m_format`, plus subclass payload — `IID`/`DID`/`enum`/
|
||||
`string` in various combinations). `Glyph` carries an OPTIONAL
|
||||
`TextTag*`. `GlyphList` is a flat list of `Glyph`; nothing above the
|
||||
glyph level stores tag/run information.
|
||||
2. **Attachment**: identity of a "run" is pointer equality on
|
||||
consecutive glyphs' `m_tag`. No cached run table exists; every
|
||||
consumer (insert-boundary check, delete-boundary check, append-
|
||||
boundary check, text-serialization) re-derives it by walking
|
||||
neighbours.
|
||||
3. **Markup**: `<TYPE:FORMAT:DATA>` opens a tag (parsed by
|
||||
`TextTagFactory::MakeTag`, dispatching on the `FORMAT` value to one
|
||||
of 4 concrete classes); any bracketed text that fails to parse (in
|
||||
particular the literal `<TYPE>`-shaped close marker emitted by
|
||||
`TextTag::BuildEndTag`) closes the currently-open tag. The delimiter
|
||||
text itself is never rendered as glyphs.
|
||||
4. **Lifetime**: pure intrusive refcounting. Adopting a tag reference
|
||||
(construction, copy-assignment) increments; releasing (destruction,
|
||||
explicit clear, reassignment) decrements and self-deletes at zero.
|
||||
**Any edit that would split a tagged run instead strips the tag from
|
||||
every glyph in the WHOLE `GlyphList` that shares it** — there is no
|
||||
run-splitting. Chat-log truncation (`TruncateChatLog` →
|
||||
`BeheadText` → `DeleteSection` → `GlyphList::Delete`) is not
|
||||
special-cased; it goes through this exact same path, so a tagged
|
||||
name straddling the truncation boundary loses its tag entirely, even
|
||||
on the surviving portion.
|
||||
5. **Colour**: two parallel, per-`UIElement_Text`, DAT-authored,
|
||||
index-selected colour arrays — property `0x1b` (base/default) and
|
||||
property `0x1d` (tag colour) — refreshed from the same caller
|
||||
colour-category index at the top of every append call. A glyph gets
|
||||
the `0x1d` colour only if a tag is open AND that tag's `m_type`
|
||||
equals the sentinel `0x10000001`; otherwise it gets the `0x1b`
|
||||
colour regardless of whether some *other* kind of tag is open.
|
||||
6. **Click**: each concrete `TextTag` subclass's `HandleClick`
|
||||
broadcasts a `NoticeHandler`-family notice
|
||||
(`ECM_UI::SendNotice_TextTag_*Click`) carrying its payload; some
|
||||
listener elsewhere (not traced in this pass) reacts to populate chat
|
||||
input, open a character sheet, etc., depending on subclass/`m_type`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions / explicitly unresolved
|
||||
|
||||
- **What symbolic name does `TextTag::m_type == 0x10000001` correspond
|
||||
to?** `TextTagType` has no recovered named enum (`acclient.h:62585` is
|
||||
a bare typedef). The literal `0x10000001` recurs pervasively
|
||||
elsewhere in the pseudo-C for apparently unrelated purposes (dialog
|
||||
IDs, `StringInfo::SetStringIDandTableEnum` table-enum arguments,
|
||||
keymap IDs — see the broad grep hits at
|
||||
`acclient_2013_pseudo_c.txt:2334,135182,149121,150950,154810,...`),
|
||||
which suggests it's a low, sequential "category 1" id reused across
|
||||
several small per-subsystem enums rather than one global constant
|
||||
with a single meaning — i.e. seeing the same literal elsewhere is
|
||||
**not** evidence about what it means for `TextTag`. Both
|
||||
`EnumMapper::InqEnum`/`InqString` route through a table id of `0x18`
|
||||
(§1.3), which is very likely a DAT-resident enum/string table (the
|
||||
lookup falls through `MasterDBMap::DivineType`-style DBObj resolution
|
||||
seen in `EnumMapper::GetEnumByDID`,
|
||||
`acclient_2013_pseudo_c.txt:29890-29937`, for other DID categories),
|
||||
meaning the actual keyword strings (e.g. whatever text maps to
|
||||
`m_type == 1`) live in a DAT string/enum table, not as a compiled
|
||||
string literal — grepping for literal tag keywords like `"IID"`,
|
||||
`"DID"` in the pseudo-C found nothing. **Needs**: pulling DAT category
|
||||
`0x18`'s EnumMapper table contents (likely in
|
||||
`client_local_English.dat` or `client_portal.dat`) to find the actual
|
||||
keyword-to-`m_type` mapping, the same way
|
||||
`claude-memory/project_settings_options_digest.md`'s `GetNameFromKey`
|
||||
work pulled DAT tables `0x2300000A`/`0x2300000B`/`0x23000007`.
|
||||
- **Exact wording of the two `sprintf` format strings** at
|
||||
`acclient_2013_pseudo_c.txt:133651` (`u"<%ls:%ls%:%ls>"`) and
|
||||
`acclient_2013_pseudo_c.txt:133728` (`u"<\%ls>"`). Binary Ninja's
|
||||
string-literal rendering is known to mis-escape embedded
|
||||
slashes/percents in this codebase; both are flagged inline in §1.3/§2.3
|
||||
as probable artifacts (a stray `%` in the first, a `\` that's likely a
|
||||
literal `/` in the second). **Needs**: a raw byte dump of the two
|
||||
`.rdata` string constants at their addresses (not BN's decompiled
|
||||
preview) to confirm exact bytes before porting the exact markup
|
||||
syntax.
|
||||
- **The exact semantics of `SetFontColorHelper`'s three virtual calls**
|
||||
(vtable offsets `+0xf0`, `+0xf4`, `+0x98`, §4.3) are inferred from
|
||||
control flow, not named by the PDB. The functional summary (indexed
|
||||
colour array) is believed correct, but the precise interface
|
||||
(`BaseProperty`'s exact virtual table) was not independently
|
||||
confirmed against `acclient.h`'s `BaseProperty`-family struct
|
||||
definitions in this pass.
|
||||
- **The mouse-hit-test → `HandleClick` wiring** (§5) — which function
|
||||
resolves a click position to a glyph, reads its `m_tag`, and invokes
|
||||
`HandleClick` through the vtable — was not located in this pass (no
|
||||
literal-text call site exists to grep for an indirect vtable call).
|
||||
- **Which `NoticeHandler` override actually consumes
|
||||
`RecvNotice_TextTag_IIDStringClick` and prefills the chat input** —
|
||||
not traced. This is the last link needed to fully confirm "clicking a
|
||||
chat name populates a `/t` tell," though the `IID` + name payload
|
||||
shape on `TextTag_IIDString` makes it the overwhelmingly likely
|
||||
candidate tag type for that UI behaviour.
|
||||
650
docs/research/2026-08-21-retail-chat-window-ui.md
Normal file
650
docs/research/2026-08-21-retail-chat-window-ui.md
Normal file
|
|
@ -0,0 +1,650 @@
|
|||
# Retail chat WINDOW shell — window model, filters, scrollback, chrome
|
||||
|
||||
**Date:** 2026-08-21
|
||||
**Status:** RESEARCH ONLY. No source files touched.
|
||||
**Scope:** retail's chat window SHELL and DISPLAY behavior — window
|
||||
management, filtering, scrollback, chrome/interaction, multi-window,
|
||||
line-composition structure, and other user-visible window mechanics.
|
||||
**Explicitly out of scope** (covered by sibling research this session):
|
||||
glyph text-tag coloring, clickable/colored names, tag click dispatch, and
|
||||
acdream's own current UI code. This document does not re-derive anything
|
||||
already answered there.
|
||||
|
||||
**Primary sources**
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
|
||||
build, Binary Ninja pseudo-C, PDB-named)
|
||||
- `docs/research/named-retail/acclient.h` (verbatim retail struct/enum defs)
|
||||
- `docs/research/named-retail/symbols.json`
|
||||
|
||||
**Notes read first so this extends rather than repeats:**
|
||||
- `docs/research/2026-08-09-chat-retail-window-shell.md` (CH6 shell research
|
||||
— window lifecycle/identity, LayoutDesc geometry, resize model, opacity,
|
||||
persistence, multi-window/floaty mechanics). **This document is the
|
||||
authority for §1 window-lifecycle mechanics, §4 chrome/resize/opacity, and
|
||||
§5 tabs/multi-window — I only summarize its findings below with pointers,
|
||||
and add what it doesn't cover:** scrollback/truncation, the exact
|
||||
window-ID routing predicate as a single decompiled function, structural
|
||||
line-composition order, and the unseen-text/auto-scroll interaction.
|
||||
- `docs/research/2026-08-09-chat-retail-color-table.md` §4 (filter storage,
|
||||
`m_llTextTypeFilter`, `PostInit` seeded defaults) — I summarize and do not
|
||||
re-derive; I use its findings to cross-check the routing function decoded
|
||||
fresh below.
|
||||
- `docs/plans/2026-08-09-chat-parity-campaign.md` — Campaign CH plan/ledger.
|
||||
|
||||
**Binary-Ninja caveats (apply throughout, per
|
||||
`claude-memory/feedback_bn_decomp_field_names.md`):** BN's struct-field
|
||||
attribution in `ChatInterface::PostInit`/`gmMainChatUI::PostInit` is shifted
|
||||
by one slot relative to the true member order — the window-shell doc already
|
||||
documented this for the main window's border elements. I hit the same
|
||||
artifact in `ChatInterface::PostInit`'s `GetChildRecursive` binding sequence
|
||||
(§1) and resolve it the same way: against the verbatim struct order in
|
||||
`acclient.h:54898-54912`, which is authoritative and does not shift.
|
||||
|
||||
---
|
||||
|
||||
## 1. Window model
|
||||
|
||||
### 1.1 How many windows, and how they're identified
|
||||
|
||||
Confirmed against `acclient.h:54898-54912` (verbatim `ChatInterface`
|
||||
struct):
|
||||
|
||||
```cpp
|
||||
/* 6041 */
|
||||
struct __cppobj ChatInterface : gmNoticeHandler, UIElement_Field
|
||||
{
|
||||
unsigned int m_eWindowID;
|
||||
float m_fDefaultOpacity;
|
||||
float m_fActiveOpacity;
|
||||
float m_fCurrentOpacity;
|
||||
UIElement_Text *m_chatEntry;
|
||||
UIElement_Text *m_chatLog;
|
||||
UIElement *m_chatNewNonVisibleTextIndicator;
|
||||
unsigned __int64 m_llTextTypeFilter;
|
||||
UIElement_Text *m_pChatTargetButtonText;
|
||||
PStringBaseArray<unsigned short> m_InputHistory;
|
||||
unsigned int m_LastInputHistoryPos;
|
||||
ClientCommunicationSystem *m_pCCS;
|
||||
};
|
||||
```
|
||||
|
||||
Per the window-shell doc §1.2/§4.1 (not re-derived here): **five** live
|
||||
chat windows exist — the main window (`m_eWindowID == 8`) and four floating
|
||||
windows (`m_eWindowID == 2..5`). `m_eWindowID == 0` is the **UNAUTHORED
|
||||
constructor default** (`ChatInterface::ChatInterface @0x004F4550` sets
|
||||
`this->m_eWindowID = 0;` before `PostInit` reads the real value off the
|
||||
LayoutDesc attribute `0x1000007E`). All five windows are authored,
|
||||
always-resident children of the gameplay-UI root — there is no
|
||||
runtime-allocated window registry (window-shell doc §1.1).
|
||||
|
||||
The SpewBox (`gmSpewBoxUI`) is a **separate, unrelated class** — not a
|
||||
`ChatInterface` subclass, not part of this window-id space (per
|
||||
`claude-memory/project_chat_digest.md`).
|
||||
|
||||
### 1.2 The wire-to-window routing predicate — one function, load-bearing
|
||||
|
||||
`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640` is the single
|
||||
function every displayed chat line passes through. Its **head** (the routing
|
||||
decision, before any text is appended) is:
|
||||
|
||||
```
|
||||
004f4640 void __thiscall ChatInterface::RecvNotice_DisplayFinalStringInfo(
|
||||
class ChatInterface* this, uint32_t arg2 /*type*/,
|
||||
class StringInfo const* arg3 /*body*/,
|
||||
class StringInfo const* arg4 /*prefix*/, uint32_t arg5 /*windowId*/)
|
||||
004f4640 {
|
||||
004f4640 uint32_t eax_7 = arg5;
|
||||
004f4652 if (eax_7 == this->m_eWindowID)
|
||||
004f4652 {
|
||||
004f467c label_4f467c:
|
||||
… (appends — see §3/§6) …
|
||||
004f4652 }
|
||||
004f4652 else if ((eax_7 == 0 && ChatInterface::TypeIsActive(this, arg2) != 0))
|
||||
004f4666 goto label_4f467c;
|
||||
004f4640 }
|
||||
```
|
||||
|
||||
**The predicate is exactly: `windowId == m_eWindowID` OR (`windowId == 0`
|
||||
AND `TypeIsActive(type)`).** This is an ADDRESS-vs-BROADCAST model, not a
|
||||
"windows subscribe to a channel" model:
|
||||
|
||||
- A line sent with a **specific windowId** (matching an already-open target
|
||||
window, e.g. a command whose output is explicitly directed at the window
|
||||
that issued it — `m_idCurrentCommandSource` per the color-table doc §4)
|
||||
is shown **only** in that one window, unconditionally — the destination
|
||||
window's own filter is never consulted for an address-targeted line.
|
||||
- A line sent with **windowId == 0** ("broadcast") is shown in **every**
|
||||
window whose own `TypeIsActive(type)` (i.e. its 64-bit
|
||||
`m_llTextTypeFilter`, decoded in the color-table doc §4) says yes. This is
|
||||
how the same "Sio says, ..." line can land in the main window and in a
|
||||
floating window simultaneously if both have `Speech` enabled.
|
||||
- **Window id 0 is therefore never itself a *window* — it is the broadcast
|
||||
sentinel value on the wire/call parameter, exactly as the window-shell
|
||||
doc's goal-window addendum states.** No live `ChatInterface` instance ever
|
||||
keeps `m_eWindowID == 0` after `PostInit` runs.
|
||||
|
||||
`ChatInterface::TypeIsActive @0x004F2F10` (cited, not re-derived, per the
|
||||
color-table doc §4) is `(1ULL << type) & m_llTextTypeFilter`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Filters
|
||||
|
||||
**Fully decoded already in `2026-08-09-chat-retail-color-table.md` §4 — not
|
||||
re-derived here.** Summary for completeness of this document's structure:
|
||||
|
||||
- Storage: 64-bit `ChatInterface::m_llTextTypeFilter` (`acclient.h:54907`),
|
||||
read from `PlayerModule::InqChatWindowOption(windowId, 0x1000007F, …)`
|
||||
(`ChatInterface::UpdateFromPlayerModule @0x004F3920`) and live-updated via
|
||||
`RecvNotice_GameplayOptionChanged @0x004F30E0`.
|
||||
- Test: `ChatInterface::TypeIsActive @0x004F2F10` — `(1ULL << type) &
|
||||
m_llTextTypeFilter`, used both for the broadcast-routing predicate (§1.2)
|
||||
and, per the color-table doc, nowhere else.
|
||||
- **`PostInit`'s per-window seeded default** switches on `m_oldState`
|
||||
(`ChatInterface::PostInit @0x004F3DD0`, `0x004f3df9`): 1 and 8 (the main
|
||||
window) get `0xFBFFFFFF` low-dword (everything except client-local `0x1A`);
|
||||
2 (floaty 1) gets Speech/Tell/Speech_Direct_Send/Emote; 3 (floaty 2) gets
|
||||
Social/Social_Send/Allegiance; 4 (floaty 3) gets Fellowship; 5 (floaty 4)
|
||||
gets the four Turbine rooms General/Trade/LFG/Roleplay. Every default's
|
||||
HIGH dword is 0 — Society (`0x20`) and the reserved `0x21` slot start
|
||||
disabled in **every** window and must be opted into by the user.
|
||||
- User edit path: `gmChatOptionsUI::InitOptions @0x0049FC60` /
|
||||
`AddCheckboxBitfield64Option @0x0049EDA0` build one checkbox-grid `SetUserData`
|
||||
block per window id (main = id 8, with its own dedicated Society checkbox
|
||||
child at `0x0049FEFB`).
|
||||
- Squelching is a **separate axis** from filtering:
|
||||
`LogTextTypeEnumMapper::IsLegalChannel @0x006AFF40` whitelists a 14-value
|
||||
subset of `LogTextType` as squelchable at all; it has no interaction with
|
||||
`m_llTextTypeFilter`.
|
||||
|
||||
Nothing new to add here beyond what the color-table doc already covers —
|
||||
the routing predicate decoded fresh in §1.2 above is a second, independent
|
||||
confirmation of the same "`windowId==0` → filter-gated broadcast" model that
|
||||
doc's §4 described from `RecvNotice_DisplayFinalStringInfo`'s citation
|
||||
alone; this document supplies the full decompiled function body.
|
||||
|
||||
---
|
||||
|
||||
## 3. Scrollback
|
||||
|
||||
### 3.1 The cap, the trigger, and the trim target
|
||||
|
||||
Still inside `RecvNotice_DisplayFinalStringInfo @0x004F4640`, immediately
|
||||
after the body append (full excerpt with the append order in §6):
|
||||
|
||||
```
|
||||
004f4701 int32_t m_chatLog_1 = this->m_chatLog;
|
||||
004f4711 if (*(uint32_t*)(m_chatLog_1 + 0x61c) > 0x2710)
|
||||
004f4711 {
|
||||
004f4713 int32_t var_14_4 = 0x1d4c;
|
||||
004f471a m_chatLog_1 = ChatInterface::TruncateChatLog(this, m_chatLog_1);
|
||||
004f4711 }
|
||||
```
|
||||
|
||||
`0x2710` = **10,000**, `0x1d4c` = **7,500**. The field read at transcript
|
||||
offset `+0x61C` tracks the transcript's total **character** count (not a
|
||||
line count) — retail's scrollback limit is a character budget, not a
|
||||
fixed number of retained lines. **Trigger: transcript exceeds 10,000
|
||||
characters. Target: trim back down toward ~7,500.** This runs on every
|
||||
appended line once the log is over budget — it is not a periodic/timed
|
||||
sweep, it is inline in the same call that just displayed the line.
|
||||
|
||||
### 3.2 The truncation rule — trims at a newline boundary, not mid-line
|
||||
|
||||
`ChatInterface::TruncateChatLog @0x004F4290` (arg2 = target length, 7500 at
|
||||
the only call site found):
|
||||
|
||||
```
|
||||
004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2)
|
||||
004f4290 {
|
||||
text = GetText(m_chatLog); // live PStringBase
|
||||
currentLen = text.length; // *(len_ptr - 4)
|
||||
004f42c2 if (currentLen <= arg2)
|
||||
return; // under budget — no-op
|
||||
004f42c2 else
|
||||
{
|
||||
004f42c8 excess = currentLen - arg2; // chars over target
|
||||
… PStringBaseIter_Common<unsigned short>::FindChar(iter, "\n", 1) …
|
||||
// search FORWARD from the excess offset for the next '\n'
|
||||
004f4350 if (found && (excess - foundPos) < (currentLen / 10))
|
||||
004f4360 BeheadText(m_chatLog, foundPos + 1, 1); // cut at that newline
|
||||
else {
|
||||
… FindChar(iter, "\n", 0) … // search again, other direction arg
|
||||
004f43f0 if (found2 && (foundPos2 - excess) < (currentLen / 10))
|
||||
goto (the same BeheadText-at-newline path)
|
||||
004f4403 else
|
||||
BeheadText(m_chatLog, excess, 1); // fallback: cut at the raw excess offset
|
||||
}
|
||||
004f4290 }
|
||||
```
|
||||
|
||||
Reading this at the BN pseudo-C level is genuinely uncertain past the
|
||||
overall shape — **flagging per the assignment's constraint rather than
|
||||
guessing**: the `0xCCCCCCCD` multiply + `HIGHD(...) >> 3` pair is the
|
||||
standard MSVC constant-division-by-10 idiom (`length / 10`), and the two
|
||||
`FindChar` calls with a `PStringBase(&data_79c288)` needle (confirmed below,
|
||||
§3.3, to be a single `\n` character) plus `UIElement_Text::BeheadText` are
|
||||
unambiguous. **UNKNOWN — needs a live cdb capture with real transcript
|
||||
content to nail down exactly:** whether the two `FindChar` calls search in
|
||||
opposite directions from the excess offset (my reading above) or whether
|
||||
one is a fallback re-search after the first's 10%-tolerance check fails for
|
||||
a different reason; the two `arg3` values passed to `FindChar` (`1` then
|
||||
`0`) are almost certainly a direction or "case-sensitive/whole-word" flag,
|
||||
but the pseudo-C never names the parameter. **What is certain and
|
||||
sufficient to port:** truncation removes text from the FRONT of the
|
||||
transcript (`BeheadText`), it PREFERS a boundary within the char that
|
||||
begins the next `\n`-terminated line rather than a raw char-offset cut
|
||||
(there's a ~10%-of-current-length tolerance band around the target for
|
||||
preferring the newline-aligned cut), and it falls back to an exact
|
||||
char-offset behead only if no acceptable newline is found nearby.
|
||||
|
||||
### 3.3 The separator character — confirms `\n`, not `\r\n`
|
||||
|
||||
```
|
||||
0079c280 data_79c280: 0d 00 0a 00 00 00 00 00 // L"\r\n" — used elsewhere, NOT here
|
||||
0079c288 data_79c288: 0a 00 00 00 00 00 00 00 // L"\n" — the separator + the TruncateChatLog needle
|
||||
```
|
||||
|
||||
`data_79c288` is passed both as the inter-line separator string appended in
|
||||
`RecvNotice_DisplayFinalStringInfo` (§6) and as the `FindChar` needle in
|
||||
`TruncateChatLog` above — confirming truncation genuinely searches for line
|
||||
breaks, i.e. it is line-boundary-aware even though the budget itself is
|
||||
counted in characters.
|
||||
|
||||
### 3.4 Auto-scroll / "stick to bottom" — `IsAtVerticalEnd` + `ScrollToPosition`
|
||||
|
||||
`UIElement_Text::IsAtVerticalEnd @0x00469350`:
|
||||
|
||||
```
|
||||
00469350 uint8_t __fastcall UIElement_Text::IsAtVerticalEnd(class UIElement_Text* this)
|
||||
00469350 {
|
||||
lineCount = this->m_glyphList.m_glyphList._num_elements;
|
||||
00469359 if (lineCount == 0)
|
||||
return 1; // empty log counts as "at end"
|
||||
00469360 lastLineIndex = lineCount - 1;
|
||||
00469369 return UIElement_Text::IsPositionInView(this, &lastLineIndex);
|
||||
00469350 }
|
||||
```
|
||||
|
||||
**This is not a scroll-offset comparison — it is "is the last line
|
||||
currently visible inside the viewport right now."** `IsPositionInView` is
|
||||
the same hit-test the widget uses for click-to-position, applied to the
|
||||
transcript's own final line.
|
||||
|
||||
`RecvNotice_DisplayFinalStringInfo` captures this **before** appending the
|
||||
new line, then decides what to do with it **after** appending and
|
||||
truncating:
|
||||
|
||||
```
|
||||
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // BEFORE the new line lands
|
||||
… append prefix, append body, truncate if over budget (§6, §3.1) …
|
||||
004f4723 if (ebx != 0)
|
||||
004f4723 {
|
||||
004f4732 UIElement_Text::ScrollToPosition(m_chatLog, currentLineCount); // re-stick to the new bottom
|
||||
004f4739 return;
|
||||
004f4723 }
|
||||
004f4723 else
|
||||
004f473c this->m_chatNewNonVisibleTextIndicator->vtable->SetState(1); // flag "unseen text" instead
|
||||
```
|
||||
|
||||
**So: if the user was already looking at the bottom of the log, retail
|
||||
scrolls the new line into view (sticky-bottom). If the user had scrolled up
|
||||
into history, retail does NOT move their scroll position at all — it
|
||||
instead lights the "new unseen text" indicator.** There is no separate
|
||||
manual "scroll lock" toggle; this automatic per-line check IS retail's
|
||||
scroll-lock mechanism. `m_chatNewNonVisibleTextIndicator` is a real
|
||||
`UIElement*` field (`acclient.h:54906`), bound in `PostInit` from element
|
||||
id `0x1000048C` — the 16×16 button the window-shell doc's layout dump
|
||||
already placed at (21,62) in the main window and (5,169) in the floaties,
|
||||
labeled there "new-unseen-text indicator (Button)" from the authored rect
|
||||
alone; this document supplies the code that drives it.
|
||||
|
||||
### 3.5 Clearing the unseen-text flag
|
||||
|
||||
`ChatInterface::ListenToElementMessage @0x004F51C0`, click-message case,
|
||||
`idElement == 0x1000048c`:
|
||||
|
||||
```
|
||||
004f51f1 if (idElement == 0x1000048c)
|
||||
004f51f1 {
|
||||
if (m_chatEntry_or_chatLog != 0) // see field-shift caveat below
|
||||
004f5208 UIElement_Text::ScrollToPosition(transcript, transcript->lineCount);
|
||||
004f520d indicator->vtable->SetState(0xd);
|
||||
}
|
||||
```
|
||||
|
||||
**Field-attribution caveat:** this function's local variable is BN-named
|
||||
`m_chatEntry` at the point it calls `ScrollToPosition`, but the object it
|
||||
scrolls is described by `_num_elements` of its own `m_glyphList` — the
|
||||
transcript's own line count, not the chat-entry input field's. Combined
|
||||
with the ctor/struct order (§1.1) and the same-class shift already
|
||||
documented in the window-shell doc for `PostInit`, the operation this
|
||||
really performs is: **clicking the unseen-text indicator scrolls the
|
||||
transcript to its own bottom and resets the indicator's own visual state**
|
||||
(`SetState(0xd)`, a different state than the "flagged" `SetState(1)` set
|
||||
when new text arrives while scrolled up) — i.e. clicking it is the user's
|
||||
manual "catch up" action, and it un-flags itself. **Not independently
|
||||
re-verified via cdb; treat the exact numeric visual STATE values (1 vs
|
||||
0xd) as confirmed, but the specific field bound to "which object gets
|
||||
scrolled to bottom" as inferred from the semantics of `IsAtVerticalEnd`
|
||||
elsewhere, not a literal read of this function's own variable names.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Window chrome & interaction
|
||||
|
||||
**Fully covered by the window-shell doc §1 and §2–§4 — not re-derived
|
||||
here.** Summary pointers:
|
||||
|
||||
- **Move/resize:** eight authored `UIElement_Resizebar` (type 9) grips per
|
||||
window with per-grip bool properties `0x2A`/`0x2B`/`0x2C`/`0x2D`
|
||||
(bottom/left/right/top); the main window's plain top edge strip is a
|
||||
`UIElement_Dragbar` (type 2, move-only) rather than a ninth resize grip —
|
||||
window-shell doc §2.1/§2.3, `UIElement_Resizebar::StartMouseResizing
|
||||
@0x0046B7E0`.
|
||||
- **Docking/anchoring:** none found — windows are free-floating, clamped
|
||||
to stay on-screen only at restore time (`gmFloatyMainChatUI::MoveTo
|
||||
@0x004D2D10:004d2d53-004d2dbb`).
|
||||
- **Opacity:** two GLOBAL floats (`Option_DefaultOpacity_Property
|
||||
0x10000080` unfocused, `Option_ActiveOpacity_Property 0x10000081`
|
||||
focused), applied to the WHOLE composited window surface including text
|
||||
via one `SetOpacity` call — window-shell doc §3, `ChatInterface::SetOpacity
|
||||
@0x004F3120`. Per-class constructed starting values differ (main window
|
||||
1.0/1.0 always-opaque, floaties 0.5/1.0) until a saved option overrides
|
||||
them. Retail eases toward the target at 5%-of-delta per tick
|
||||
(`ChatInterface::ListenToGlobalMessage @0x004F3840`); acdream currently
|
||||
snaps (AP-190, window-shell doc §3.1).
|
||||
- **Show/hide:** authored elements toggled via `SetVisible`, driven by
|
||||
either a keybind (`Alt+1..4` for the floaties) or a generic
|
||||
registered-action click dispatch — window-shell doc §1.3/§1.4.
|
||||
- **Persistence:** two independent paths — the per-window `GameplayOptions`
|
||||
blob (position/size/visible/title, gated on `m_eWindowID != 0`, i.e. the
|
||||
main window's geometry is NEVER saved this way) and a separate local
|
||||
screen-layout text file that IS the only path persisting the main
|
||||
window's geometry — window-shell doc §4.
|
||||
|
||||
**One piece of chrome not covered by the window-shell doc — the talk-focus
|
||||
menu (main window only):**
|
||||
|
||||
`gmMainChatUI::InitTalkFocusMenu @0x004CDC50` builds a dropdown menu (from
|
||||
the button/group pair at elements `0x10000014`/`0x10000015`, window-shell
|
||||
doc §2.1) with a squelch-toggle entry plus 13 target items, each carrying
|
||||
an `Enum` attribute `0x1000000B` set to a distinct small integer (`1`
|
||||
through `0xD`) that records which "talk focus" (broadcast target category)
|
||||
that menu row represents:
|
||||
|
||||
```
|
||||
004cdcd3 this->m_pSquelchToggleButton = UIElement_Menu::AddTextItem(eax_1, &var_90);
|
||||
… (13x) …
|
||||
004cdcfb UIElement::SetAttribute_Enum(eax_3, 0x1000000b, 5);
|
||||
004cdd07 SmartArray<UIElement_Text *,1>::push_back(&this->m_aTalkFocusButtons, &var_94);
|
||||
```
|
||||
|
||||
`gmMainChatUI::EnableSelection @0x004CE0A0` toggles individual rows'
|
||||
enabled/greyed state (`SetState(0xd)` when Olthoi-locked); a companion
|
||||
`RecvNotice_SelectionChanged @0x004CE050` re-syncs the menu's currently
|
||||
highlighted target whenever the player's WORLD selection changes (via
|
||||
`ACCWeenieObject::selectedID` and `PublicWeenieDesc::IsTalkable`) — this is
|
||||
a **world-object selection** feed (F1-click on an NPC), not a transcript
|
||||
text-tag click, and is out of this document's lane beyond noting that the
|
||||
main window's talk-focus button exists and is driven from it. Only the
|
||||
main window has this menu; floaty windows (window-shell doc §2.2) have
|
||||
neither a talk-focus menu nor a max/min button, only a title bar and close
|
||||
button.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tabs / multiple windows
|
||||
|
||||
**Fully covered by the window-shell doc §1.1–§1.4, §2, §4.1 — not
|
||||
re-derived here.** Summary:
|
||||
|
||||
- There is no "tab" widget. The five windows (§1.1) are five separate,
|
||||
independently positioned/sized/opaque floating panels, not tabs of one
|
||||
container.
|
||||
- **Creation:** none — all five exist from gameplay-UI construction; users
|
||||
cannot create additional windows. **Naming:** each floaty window has an
|
||||
editable title (`gmFloatyChatUI::SetWindowTitle @0x004CEAA0`, persisted
|
||||
option `0x1000008D`) but the SET of windows is fixed at five; there is no
|
||||
"new chat tab" affordance analogous to modern MMO UIs. **Closing:**
|
||||
floaty windows close via their own title-bar close button
|
||||
(`gmFloatyChatUI::ListenToElementMessage @0x004CE330`, element
|
||||
`0x1000052A`) or the `Alt+N` toggle; the main window cannot be closed at
|
||||
all (no close button is authored on it — window-shell doc §2.1's element
|
||||
table has none). **Switching:** there is no focus-cycling shortcut found;
|
||||
each window is an independent, simultaneously-visible panel, and
|
||||
"switching" only means moving keyboard focus into a different window's
|
||||
entry field by clicking it (which is what drives the opacity fade,
|
||||
§4/window-shell doc §3).
|
||||
- **Per-window state:** `m_eWindowID`, `m_llTextTypeFilter` (§2),
|
||||
`DefaultOpacity`/`ActiveOpacity` (global, not per-window — window-shell
|
||||
doc §3 correction), position/size/visible/title (§4), and the transcript
|
||||
itself (`m_chatLog`, independently truncated per §3 — each window keeps
|
||||
its own scrollback, so a floaty showing only Tells has its own 10k/7.5k
|
||||
character budget separate from the main window's).
|
||||
- The main window's four indicator buttons (`0x10000522`-`0x10000525`)
|
||||
mirror the four floaties' visibility as one-directional state indicators,
|
||||
not a tab strip — window-shell doc §1.4.
|
||||
|
||||
---
|
||||
|
||||
## 6. Timestamps, prefixes, and line composition order
|
||||
|
||||
### 6.1 The two-part composition model — confirmed structurally
|
||||
|
||||
`ClientSystem::AddTextToScroll @0x00563C50` is where a body string
|
||||
(`arg2`), a `LogTextType` (`arg3`), a plugin-hook flag (`arg4`) and a
|
||||
windowId (`arg5`) become the two `StringInfo` arguments
|
||||
`RecvNotice_DisplayFinalStringInfo` receives. Its structurally relevant
|
||||
branch (client-local `0x1A` short-circuits both the timestamp AND the local
|
||||
log file):
|
||||
|
||||
```
|
||||
00563de6 if (arg3 == 0x1a)
|
||||
00563de6 {
|
||||
// build body-only StringInfo, EMPTY prefix StringInfo
|
||||
00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3, &bodyOnly, &emptyPrefix, windowId);
|
||||
00563de6 }
|
||||
00563de6 else
|
||||
00563de6 {
|
||||
00563dfb if (PlayerModule::DisplayTimeStamps(&playerModule) != 0)
|
||||
00563dfb {
|
||||
00563e24 wcsftime(&buf, 0x400, u"%#H:%M:%S ", localtime(&now)); // "H:MM:SS " — no date, trailing space
|
||||
00563e39 PStringBase<unsigned short>::set(&prefixBuffer, &buf);
|
||||
00563dfb }
|
||||
… if (s_pLogFile) fprintf(s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer); // §7.4
|
||||
}
|
||||
```
|
||||
|
||||
**There is exactly ONE structural prefix element: the timestamp, and it is
|
||||
entirely optional (gated on `PlayerModule::DisplayTimeStamps()`, a
|
||||
character option toggle backed by `PlayerModule::options2_` bit 6 —
|
||||
`PlayerModule::DisplayTimeStamps @0x005D39B0`: `return (options2_ >> 6) &
|
||||
1`).** There is **no separate structural "channel name" prefix element**
|
||||
(`[Fellowship]`, `[<name>]`, etc.) anywhere in this function or in
|
||||
`RecvNotice_DisplayFinalStringInfo`. Channel-name brackets that DO appear
|
||||
in retail's transcript (documented already, by content not structure, in
|
||||
the color-table doc §3.3's channel-bit table) are baked directly into the
|
||||
`arg2` body string by the SENDING handler (e.g.
|
||||
`Handle_Communication__ChannelBroadcast`) before it ever reaches
|
||||
`AddTextToScroll` — from this function's point of view there are only ever
|
||||
two composed parts: prefix (timestamp-or-empty) and body.
|
||||
|
||||
### 6.2 The append order — separator, then prefix, then body
|
||||
|
||||
Back in `RecvNotice_DisplayFinalStringInfo @0x004F4640` (full body, per the
|
||||
excerpts in §1.2/§3.1/§3.4 stitched together in call order):
|
||||
|
||||
```
|
||||
004f467c if (this->m_chatLog->m_glyphList.m_glyphList._num_elements > 0)
|
||||
004f4687 {
|
||||
004f469a UIElement_Text::AppendTextWithFont(this->m_chatLog, L"\n", 0, arg2 /*type*/);
|
||||
004f467c } // 1. separator (skipped on the very first line)
|
||||
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // captured BEFORE any of the below
|
||||
004f46dc if (StringInfo::IsValid(arg4, 1) != 0)
|
||||
004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4 /*prefix*/, 0, 0xc);
|
||||
// 2. timestamp prefix — ALWAYS color idx 0x0C (grey), only if valid/non-empty
|
||||
004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3 /*body*/, 0, arg2 /*type*/);
|
||||
// 3. body — colored by the wire LogTextType
|
||||
```
|
||||
|
||||
**Fixed structural order: `[\n if not first line] → [timestamp, if
|
||||
enabled] → [body]`.** The leading separator is a property of the LOG
|
||||
(inserted once per new entry, before the entry, so the transcript never
|
||||
starts with a blank line), not a property of the entry itself — a port that
|
||||
appends `body + "\n"` per-line instead of `"\n" + body` will still LOOK
|
||||
identical on screen but will behave differently under `TruncateChatLog`'s
|
||||
newline-boundary search (§3.2) and under `IsAtVerticalEnd` line-counting
|
||||
(§3.4) if the two approaches disagree at the very first/last line. The
|
||||
color assignment itself is the color-table doc's territory (not re-derived
|
||||
here) — the load-bearing NEW fact this document adds is the *order* and
|
||||
that the timestamp is unconditionally color index `0x0C` regardless of the
|
||||
body's own type, which the color-table doc §3.2 already states from the
|
||||
same address; this document supplies the surrounding append sequence and
|
||||
confirms the timestamp's StringInfo is `arg4`, always appended strictly
|
||||
BEFORE the body `arg3`, never interleaved or after.
|
||||
|
||||
### 6.3 Timestamp format, verbatim
|
||||
|
||||
`u"%#H:%M:%S "` fed to `wcsftime` — hour without a leading zero, minute,
|
||||
second, **no date**, one trailing space baked into the format string
|
||||
(explaining why no separate space-insertion code exists between prefix and
|
||||
body — the prefix string itself carries its own trailing separator).
|
||||
|
||||
---
|
||||
|
||||
## 7. Other user-visible window behaviors
|
||||
|
||||
### 7.1 Local session log file — a port would miss this
|
||||
|
||||
`ClientSystem::s_pLogFile` — a plain-text file retail writes chat lines to
|
||||
during the session, independent of the on-screen transcript's 10k/7.5k
|
||||
character budget (§3.1) or any window's filter (§2). Written from the same
|
||||
`AddTextToScroll` branch that builds the on-screen timestamp (§6.1):
|
||||
|
||||
```
|
||||
00563e5b fprintf(ClientSystem::s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer);
|
||||
```
|
||||
|
||||
Client-local type `0x1A` text (§6.1's short-circuit branch) explicitly
|
||||
bypasses this — client-local errors/refusals never reach the log file,
|
||||
only the on-screen transcript. **UNKNOWN — needs further grep:** the log
|
||||
file's path/naming convention and whether it rotates per-session or
|
||||
per-character; not chased further as it's a filesystem-artifact question
|
||||
more than a window-UI one, but flagged because "retail also writes a
|
||||
plain-text chat log to disk" is exactly the kind of behavior a UI-only port
|
||||
would miss entirely.
|
||||
|
||||
### 7.2 Unread/unseen marker — confirmed, see §3.4/§3.5
|
||||
|
||||
The `0x1000048C` "new unseen text" indicator button IS retail's unread
|
||||
marker. It is per-window (each `ChatInterface` owns its own
|
||||
`m_chatNewNonVisibleTextIndicator`), lights when a broadcast/addressed line
|
||||
arrives while the user has scrolled away from the bottom, and clears when
|
||||
the user clicks it (which also snaps the transcript back to its bottom).
|
||||
There is no separate "flash the window" or "flash the taskbar/app icon" —
|
||||
`FlashWindow`/`FlashWindowEx` do not appear anywhere in the pseudo-C dump
|
||||
(checked via a whole-file grep; zero hits).
|
||||
|
||||
### 7.3 Sound cues on incoming chat — UNKNOWN, likely none dedicated
|
||||
|
||||
A targeted grep for `PlaySound`/`SoundManager::Play*` near the
|
||||
`Handle_Communication__HearDirectSpeech @0x005715A0` (incoming tell) handler
|
||||
body found no sound-manager call inside it, and no `Sound_*`-named constant
|
||||
resembling "tell received" or "chat" turned up in the identifiers swept.
|
||||
The one chat-adjacent audio-related symbol found is a **global** preference
|
||||
— `Sound_PlaySoundOnlyWhenActive` / `ID_Sound_NoFocusNoSound`
|
||||
(`UIPreferences::AttachPreference @0x004037E4`,
|
||||
`SoundManager::PlaySoundInternal @0x0054FEC0` checks
|
||||
`SoundManager::s_bPlaySoundOnlyWhenActive` against `Device::m_bIsActiveApp`)
|
||||
— which mutes ALL UI sounds (not specifically chat) when the game window
|
||||
isn't the active app. **UNKNOWN — needs a deeper sweep or a live cdb
|
||||
capture on an incoming tell**: this document did not find a chat-specific
|
||||
sound cue, but a negative grep result over a 66 MB pseudo-C dump is weak
|
||||
evidence of absence given how many code paths route through indirect
|
||||
vtable calls the text search can't follow. Flagging rather than asserting
|
||||
"retail has no tell sound."
|
||||
|
||||
### 7.4 Copy/paste and text selection — a base `UIElement_Text` capability
|
||||
|
||||
`UIElement_Text::GetSelection @0x00466F20` and `UIElement_Text::SelectAll
|
||||
@0x004678D0` exist as capabilities of the general text-widget class that
|
||||
BOTH the chat entry field (`m_chatEntry`) and the read-only transcript
|
||||
(`m_chatLog`) are instances of (`acclient.h:54904-54905`, both typed
|
||||
`UIElement_Text*`). `SelectAll`'s call sites found are mostly OTHER
|
||||
text-entry fields (a character-name box, a stack-size entry box) triggered
|
||||
by a "select-all-on-first-click" attribute (`UIElement::GetAttribute_Bool(this,
|
||||
0xd1, ...)` inside `UIElement_Text::MouseDown @0x00469370`), not anything
|
||||
chat-specific. **UNKNOWN — not independently confirmed for the read-only
|
||||
transcript specifically:** whether the transcript panel exposes the SAME
|
||||
click-drag-select-then-copy affordance as the entry field, or whether it is
|
||||
flagged read-only in a way that suppresses selection; the class-level
|
||||
capability clearly exists on the type, but no chat-transcript-specific
|
||||
selection code path was located distinct from the generic `UIElement_Text`
|
||||
mouse-down handler already cited. Worth a live-client check (select text in
|
||||
the retail transcript, see if a selection highlight appears) rather than
|
||||
further static digging.
|
||||
|
||||
### 7.5 What's genuinely absent
|
||||
|
||||
- No `FlashWindow` anywhere in the binary (§7.2).
|
||||
- No docking/snapping between chat windows or to screen edges — the
|
||||
window-shell doc's resize/move research found only free-floating
|
||||
clamped-on-restore positioning (§4).
|
||||
- No tab strip / tabbed-window container (§5) — five independent panels,
|
||||
not a tab model.
|
||||
- No manual "scroll lock" toggle — the auto-scroll behavior in §3.4 IS the
|
||||
scroll-lock mechanism, driven automatically by `IsAtVerticalEnd`, with no
|
||||
user-facing on/off switch found.
|
||||
|
||||
---
|
||||
|
||||
## Behaviours acdream is most likely missing
|
||||
|
||||
Ordered by how load-bearing each gap looks against `RuntimeCommunicationState`
|
||||
(`docs/research/2026-07-26-slice-j4-1-communication-state.md`) and
|
||||
`ChatWindowController` as of this session:
|
||||
|
||||
1. **Scrollback truncation entirely.** No 10,000-char trigger / ~7,500-char
|
||||
target / newline-boundary-preferring trim (§3.1–§3.3) appears to exist in
|
||||
acdream today — grep `TruncateChatLog`-equivalent behavior in
|
||||
`ChatLog`/`ChatWindowController` before assuming an unbounded transcript
|
||||
is fine; it will diverge from retail under long play sessions (memory
|
||||
growth) and, more subtly, under the exact wrap point if a port ever needs
|
||||
pixel/line parity with a retail screenshot at high message volume.
|
||||
2. **The auto-scroll / stick-to-bottom vs. flag-unseen-instead split
|
||||
(§3.4–§3.5).** This is a genuine behavioral fork, not a cosmetic one: a
|
||||
naive port that ALWAYS scrolls to bottom on new text will yank the user's
|
||||
scroll position out from under them mid-read whenever a broadcast line
|
||||
arrives — exactly the annoyance retail's `IsAtVerticalEnd` check exists to
|
||||
prevent. Confirm `ChatWindowController` checks "was I at the bottom
|
||||
before this line landed" before auto-scrolling, and confirm the
|
||||
`0x1000048C` unseen-indicator element (window-shell doc's layout dump
|
||||
already has its rect for both window layouts) is wired to light up +
|
||||
clear via click exactly as §3.4/§3.5 describe.
|
||||
3. **The window-ID routing predicate as ONE explicit rule (§1.2).** The
|
||||
color-table doc already flags the routing behavior; this document adds
|
||||
the exact decompiled shape. Verify `RuntimeCommunicationState`'s chat
|
||||
windows model (per the CH6c plan in the window-shell doc §6.1) implements
|
||||
precisely `windowId == m_eWindowID || (windowId == 0 && TypeIsActive)` —
|
||||
not, e.g., "every window with the type enabled shows every line
|
||||
regardless of address," which would make addressed command-output lines
|
||||
leak into windows they were never meant for.
|
||||
4. **Structural composition order (§6.2)** — separator-before-entry (not
|
||||
after), timestamp-before-body, timestamp always present-or-absent as a
|
||||
single unit gated on one option bit. A port that concatenates
|
||||
`timestamp + " " + body` as one string loses retail's separately-colored,
|
||||
separately-truncatable prefix run and the option-driven all-or-nothing
|
||||
presence.
|
||||
5. **The local session chat-log file (§7.1).** Small, but "retail writes a
|
||||
plain-text transcript to disk every session" is the kind of feature users
|
||||
notice is missing only when they go looking for it after the fact.
|
||||
6. **Per-window independent scrollback.** Once §1 is implemented, confirm
|
||||
each of the five windows truncates its OWN transcript independently
|
||||
(§5) rather than sharing one global buffer — a floaty window filtered
|
||||
down to just Tells should never truncate early just because the main
|
||||
window's transcript is huge.
|
||||
7. **Sound cues and transcript text-selection are open questions, not
|
||||
confirmed gaps** (§7.3, §7.4) — do not build negative-result "retail has
|
||||
none of this" code around them; re-check live if/when they become
|
||||
relevant.
|
||||
Loading…
Add table
Add a link
Reference in a new issue