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