acdream/docs/research/2026-08-21-acdream-text-stack-audit.md
Erik 663129c340 docs: Campaign CT — chat text tags, researched and planned
Six parallel research lanes on retail's chat text and window behaviour, plus a
plan. The headline: the green clickable speaker name is not a chat feature and
not a colour, it is a missing capability in the TEXT stack.

Retail's client sprintfs literal tag markup into the chat line, and the text
element parses the brackets while appending, attaching a ref-counted tag PER
GLYPH. A tagged run is emergent: adjacent glyphs whose tag pointers are equal.
A glyph takes the tag colour (property 0x1D) only when a tag is open and its
type is 0x10000001; otherwise the ordinary line colour (0x1B).

The colour itself was the one thing the decomp could not settle — it is
authored, not runtime-built — so it was MEASURED out of the installed dats
rather than assumed from a screenshot: P0x1D = RGB(0,178,0). That also exposed
a trap: the tag colour is per-ELEMENT and authored while the line colour on the
same element comes from the runtime chat table, so filing "tag green" into the
LogTextType table would put it in the wrong place.

Our own audit found the gap is narrower than feared. UiText ALREADY draws
multi-coloured runs (the character stat panel uses it); the path is just gated
to single-line elements. The draw path needs no renderer work, and HitChar
already resolves a click to line+column. The real blocker is that sender
identity is destroyed before it reaches the renderer: ChatEntry carries
Sender/SenderGuid the whole way, and ChatVM.RecentLinesDetailed drops both.

Two findings beyond the original question. Retail BOUNDS its transcript
(10,000 chars, trimmed to ~7,500 at a newline) and splits auto-scroll from an
unread indicator by sampling "was at bottom" before the line lands — a naive
port auto-scrolls forever and leaks for the life of a session. And the chat-UI
audit turned up an untracked bug: Escape in the chat input does nothing at all,
because UiField has no Escape case and a focused field also suppresses the
input dispatcher's fallback.

Every lane was instructed to write "UNKNOWN — needs X" rather than guess, and
they did; the carried unknowns are listed in the plan rather than papered over.

Seven slices proposed, nothing implemented yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:52:05 +02:00

376 lines
20 KiB
Markdown

# 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).