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>
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:
Line—UiText.cs:50:public readonly record struct Line(string Text, Vector4 Color);One string, oneVector4color for the WHOLE string. This is whatLinesProvider(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 toctx.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 byRunsProvider(UiText.cs:69,Func<IReadOnlyList<TextRun>>?) and drawn byDrawSingleLineRuns(UiText.cs:693-754). This is real per-run coloring — each run gets its ownctx.DrawStringDatPasscall at its own pen X (UiText.cs:725-743) — but it is only reachable whenOneLine == true(UiText.cs:506-510:if (OneLine && RunsProvider is { } runsProvider)), i.e. the static single-line label path. The chat transcript is NOTOneLine(ChatWindowController.cs:320:c.Transcript.OneLine = false;), so it can never reachDrawSingleLineRuns— the scrollable multi-line path only ever readsLinesProvider/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:
- A per-line list of run boundaries (start col, end col, and a
payload — e.g. sender guid) reaching
UiTextalongside the text, which does not exist today (Linehas no such field, see §1/§2). HitChar's existing(line, col)result checked against that list — this is a small, local addition toUiText, not a new hit-test mechanism.- A dispatch from "run payload resolved" to an actual action (e.g.
pre-filling a
/tell <name>in the chat input) — analogous to the existingOnClickdelegate, 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.Coremust 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 aboutVector4/GL/rendering.- UI panels target
AcDream.UI.Abstractionsonly (rule 3) — the composition of "structured entry -> ordered list of colored, optionally-actionable spans" is exactly the kind of pure formatting logicChatVMalready owns (ChatVM.FormatEntry/RecentLinesDetailed,ChatVM.cs:262-371) and should keep owning; it must not reach intoAcDream.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):
AcDream.UI.Abstractions(data shape) — introduce a run-carrying line shape parallel to (not replacing)FormattedLine. Sketch:FormattedLinegains an optional ordered list of spans, or a newRichFormattedLine(IReadOnlyList<FormattedRun> Runs, ...)is added, whereFormattedRunis something like(string Text, uint? ActorGuid, bool IsSpeakerName)— deliberately NOT carrying a color yet (AcDream.UI.Abstractionshas noSystem.Numerics/GL dependency requirement today, but keeping color resolution inAcDream.Appmirrors the existingRetailChatColorTable/ChatTranscriptRenderersplit, whereChatVMsuppliesLogTextType/structured data andChatTranscriptRendererinAcDream.Appresolves it toVector4).ChatVM.FormatEntry(ChatVM.cs:262-313) would need a sibling that returns spans instead of one interpolated string — e.g. split eachcaseinto "prefix run" / "sender run" / "suffix run" instead of a single$"..."— andRecentLinesDetailedwould carryentry.Sender/entry.SenderGuidthrough instead of discarding them (the §2 fix).AcDream.App/UI/Layout(composition) —ChatTranscriptRenderer.BuildLines(ChatTranscriptRenderer.cs:67-95) is the existing per-controller- shared seam that already resolvesLogTextType -> Vector4and 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 ofUiText.Line. BothChatWindowController(ChatWindowController.cs:778) andFloatingChatWindowController(same shared function, per the class doc atChatTranscriptRenderer.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.AcDream.App/UI/UiText.cs(widget) — this is where the actual gap is.Lineneeds an additiveRunsconcept for the MULTI-LINE (OneLine == false) path, not just the existingOneLine+TextRunpath (§1). Minimal shape: extendLine(or add a parallelRichLine) to carryIReadOnlyList<TextRun>alongside or instead of a flatstring Text+ singleVector4 Color; changeDrawClippedText's multi-line loop (UiText.cs:636-691) to, for a line with runs, do whatDrawSingleLineRunsalready does per-run (walk runs, accumulatepenX, callDrawStringDatPassper run, batch all outlines-then-all-fills at the BLOCK level exactly asUiText.cs:681-690already 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 hitcol, andOnEvent'sClickcase (UiText.cs:809-813) needs a second dispatch path (run-click, distinct from whole-elementOnClick) that a controller (e.g.ChatWindowController) can bind to "prefill a tell to this guid," mirroring howOnClickis 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.ClientLocalperChatVM.cs:159-183; likely wants the SAME run model eventually since it rendersLogTextType-colored lines too). - Tooltips:
RetailTooltipPresenter.cs— world/UI hover tooltips; currently plainLines. - Appraisal / item & creature reports:
AppraisalUiController.cs,CreatureAppraisalRows.cs,ItemAppraisalReport.cs— these already render multi-colored informational text (spell names, damage types) as SEPARATELines 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 onUiText, 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 existingTextRunconsumer, §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 everyUiTextfrom 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 onUiText.Lineshape/count would need review ifLine'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/Segmentshapes above are illustrative sketches for sizing, not a spec. - Did not assess whether
AppraisalUiController/CreatureAppraisalRows's existing one-color-per-Lineapproach is already retail-faithful for their own content (out of scope; flagged only as a blast-radius member).