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

20 KiB

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:

  • LineUiText.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.
  • TextRunUiText.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 Lines (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 Lines), 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 Lines.
  • Appraisal / item & creature reports: AppraisalUiController.cs, CreatureAppraisalRows.cs, ItemAppraisalReport.cs — these already render multi-colored informational text (spell names, damage types) as SEPARATE Lines 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).