diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
index f712e189..9ee09e71 100644
--- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
+++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
+using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
@@ -64,14 +65,66 @@ internal static class ChatTranscriptRenderer
/// unchanged" rule — see 's own doc).
/// Callers pass their transcript's .
///
+ ///
+ /// The runs covering one wrapped fragment, or when
+ /// the fragment is a single colour.
+ ///
+ ///
+ ///
+ /// Campaign CT slice A4. Wrapping splits a line into fragments, and a tag
+ /// can straddle a break, so a fragment may hold part of a tagged run, all
+ /// of it, or none.
+ ///
+ ///
+ /// Returns null unless a tag actually falls inside the window — a
+ /// single-colour fragment must take the ordinary flat draw path rather
+ /// than a one-run list that means the same thing.
+ ///
+ ///
+ internal static IReadOnlyList? RunsForFragment(
+ IReadOnlyList spans,
+ int fragmentStart,
+ int fragmentLength,
+ Vector4 lineColor,
+ Vector4 tagColor)
+ {
+ int fragmentEnd = fragmentStart + fragmentLength;
+ var runs = new List();
+ bool sawTag = false;
+ int at = 0;
+
+ foreach (ChatTextSpan span in spans)
+ {
+ int spanStart = at;
+ int spanEnd = at + span.Text.Length;
+ at = spanEnd;
+
+ int from = Math.Max(spanStart, fragmentStart);
+ int to = Math.Min(spanEnd, fragmentEnd);
+ if (to <= from)
+ continue;
+
+ bool tagged = span.Tag is not null;
+ sawTag |= tagged;
+ runs.Add(new UiText.TextRun(
+ span.Text.Substring(from - spanStart, to - from),
+ tagged ? tagColor : lineColor));
+ }
+
+ return sawTag ? runs : null;
+ }
+
public static List BuildLines(
IReadOnlyList detailed,
float maxW,
Func measure,
Func? accept,
- Vector4 defaultColor)
+ Vector4 defaultColor,
+ Vector4? tagColor = null,
+ List?>? runsPerLine = null)
{
var result = new List(detailed.Count);
+ runsPerLine?.Clear();
if (detailed.Count == 0)
return result;
@@ -88,8 +141,40 @@ internal static class ChatTranscriptRenderer
continue;
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved;
+ // Wrapping can DROP the space it broke on, so a fragment is not
+ // simply the next N characters — locate each one in the source
+ // line to keep the span offsets honest.
+ int searchFrom = 0;
foreach (string frag in WrapText(d.Text, maxW, measure))
+ {
result.Add(new UiText.Line(frag, currentColor));
+
+ if (runsPerLine is null)
+ continue;
+
+ if (d.Spans is not { Count: > 0 } spans || frag.Length == 0)
+ {
+ runsPerLine.Add(null);
+ continue;
+ }
+
+ int at = d.Text.IndexOf(frag, searchFrom, StringComparison.Ordinal);
+ if (at < 0)
+ {
+ // Should not happen; a fragment always comes from the line.
+ // Fall back to the flat colour rather than mis-colouring.
+ runsPerLine.Add(null);
+ continue;
+ }
+ searchFrom = at + frag.Length;
+
+ runsPerLine.Add(RunsForFragment(
+ spans,
+ at,
+ frag.Length,
+ currentColor,
+ tagColor ?? currentColor));
+ }
}
return result;
}
diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index da8e21df..c000034e 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -128,6 +128,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// metrics that determine wrapping change. This makes an idle chat window
// allocation-free instead of snapshotting/formatting/wrapping every frame.
private IReadOnlyList _cachedTranscriptLines = Array.Empty();
+
+ ///
+ /// Per-cached-line runs, index-aligned with
+ /// . Null at an index means that line
+ /// is a single colour and draws through the ordinary flat path.
+ ///
+ private readonly List?> _cachedTranscriptRuns = new();
private long _cachedTranscriptRevision = -1;
private ulong _cachedFilter;
private float _cachedTranscriptWrapWidth = float.NaN;
@@ -325,6 +332,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// fallback — a flat overlay here only mismatched it under the wrong
// (0x21000006) layout, whose transcript panel lacked one.
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm);
+ // Index-aligned with the lines the provider above returns, and read
+ // from the same cache — so a tagged speaker name draws in the
+ // element's authored tag colour while the rest of its line keeps the
+ // message colour.
+ c.Transcript.LineRunsProvider = index =>
+ index >= 0 && index < c._cachedTranscriptRuns.Count
+ ? c._cachedTranscriptRuns[index]
+ : null;
// ── Input ────────────────────────────────────────────────────────
// Editable/selectable/one-line semantics and state sprites came from the
@@ -775,8 +790,18 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// no more accept:null "no user filter" placeholder.
bool Accept(uint logTextType) => _windowFilters.ShouldDisplay(
ChatWindowState.MainWindowId, ChatWindowState.BroadcastTargetWindow, logTextType);
+ // Campaign CT slice A4: the runs come back alongside the flat lines and
+ // are cached with them, so the transcript's per-line run lookup costs
+ // nothing per frame — it reads the same cache the lines do.
+ _cachedTranscriptRuns.Clear();
var result = ChatTranscriptRenderer.BuildLines(
- detailed, maxW, measure, Accept, Transcript.DefaultColor);
+ detailed,
+ maxW,
+ measure,
+ Accept,
+ Transcript.DefaultColor,
+ Transcript.TagColor,
+ _cachedTranscriptRuns);
return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont);
}
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 9713c159..bb12f3fb 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -845,6 +845,8 @@ public static class DatWidgetFactory
// the build-time default.
if (info.FontColor.HasValue)
t.DefaultColor = info.FontColor.Value;
+ if (info.TagFontColor.HasValue)
+ t.TagColor = info.TagFontColor.Value;
// Outline color from dat property 0x22 (ColorBaseProperty). Only 9 elements in the
// whole DAT set author a non-black value; when absent, UiText's own ctor default
diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs
index 2cf514f1..cd335cc6 100644
--- a/src/AcDream.App/UI/Layout/ElementReader.cs
+++ b/src/AcDream.App/UI/Layout/ElementReader.cs
@@ -131,6 +131,22 @@ public sealed class ElementInfo
///
public Vector4? FontColor;
+ ///
+ /// Authored TAG font colour (dat property 0x1D), the colour retail
+ /// gives a tagged glyph run — a clickable speaker name — as distinct from
+ /// (0x1B) for everything else.
+ ///
+ ///
+ /// The two are parallel index-selected arrays refreshed from the same
+ /// caller index on every append
+ /// (UIElement_Text::AppendStringInfoWithFont @0x00469DE0), and a
+ /// glyph takes this one only while a tag is open. Measured on the chat
+ /// transcript (0x2100006F / 0x10000011) as RGB(0,178,0); it is AUTHORED
+ /// per element, not built by the runtime chat colour table, so it does not
+ /// belong in RetailChatColorTable.
+ ///
+ public Vector4? TagFontColor;
+
///
/// Outline flag from dat Properties[0x21] (BoolBaseProperty). Retail
/// UIElement_Text::SetOutline @0x0046a81c / m_bitField & 0x10.
@@ -576,6 +592,7 @@ public static class ElementReader
// FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base.
// Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base.
FontColor = derived.FontColor ?? base_.FontColor,
+ TagFontColor = derived.TagFontColor ?? base_.TagFontColor,
// Outline: derived wins when true (the dat property 0x21 was present and read as
// true); otherwise inherit the base. False-derived never overrides a true base —
// matching the FontDid/HJustify "non-default wins" convention.
@@ -669,6 +686,25 @@ public static class ElementReader
}
}
+ // Tag font colour (0x1D) — same shape as 0x1B above, read the same way.
+ if (info.TryGetEffectiveProperty(0x1Du, out var tagColor))
+ {
+ UiPropertyValue? tagValue = tagColor.Kind == UiPropertyKind.Color
+ ? tagColor
+ : tagColor.Kind == UiPropertyKind.Array
+ && tagColor.ArrayValue.Count > 0
+ && tagColor.ArrayValue[0].Kind == UiPropertyKind.Color
+ ? tagColor.ArrayValue[0]
+ : null;
+ if (tagValue is not null)
+ {
+ var t = tagValue.ColorValue;
+ float alpha = t.Alpha == 0 ? 1f : t.Alpha / 255f;
+ info.TagFontColor =
+ new Vector4(t.Red / 255f, t.Green / 255f, t.Blue / 255f, alpha);
+ }
+ }
+
// Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & 0x10.
if (info.TryGetEffectiveProperty(0x21u, out var outline)
&& outline.Kind == UiPropertyKind.Bool)
diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs
index 8c228105..9e7736a4 100644
--- a/src/AcDream.App/UI/UiText.cs
+++ b/src/AcDream.App/UI/UiText.cs
@@ -167,6 +167,13 @@ public sealed class UiText : UiElement, IUiDatStateful
///
public Vector4 DefaultColor { get; set; } = Vector4.One;
+ ///
+ /// Colour for a TAGGED run (dat property 0x1D) — retail's clickable
+ /// speaker name. Falls back to when the element
+ /// authors none.
+ ///
+ public Vector4? TagColor { get; set; }
+
///
/// Authored UIElement_Text font-color list from LayoutDesc property
/// 0x1B. Retail AppendTextWithFont @ 0x00469D70 selects an
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
new file mode 100644
index 00000000..15fe1d4b
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.Core.Chat;
+using AcDream.UI.Abstractions.Panels.Chat;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Campaign CT slice A4: a tagged speaker name draws in the element's authored
+/// TAG colour while the rest of its line keeps the message colour.
+///
+public sealed class ChatTranscriptRunsTests
+{
+ private static readonly Vector4 LineColor = new(0.8f, 0.8f, 0.8f, 1f);
+
+ /// Measured from the installed dats: chat 0x2100006F / 0x10000011.
+ private static readonly Vector4 TagGreen = new(0f, 178f / 255f, 0f, 1f);
+
+ /// 10px per character, so wrap points are predictable.
+ private static float Measure(string text) => text.Length * 10f;
+
+ private static IReadOnlyList TellSpans(string name, string rest)
+ => new[]
+ {
+ new ChatTextSpan(name, new ChatTextTag("Tell", "IIDString", $"1342177290:{name}")),
+ new ChatTextSpan(rest, null),
+ };
+
+ [Fact]
+ public void TheNameTakesTheTagColourAndTheRestTakesTheLineColour()
+ {
+ IReadOnlyList? runs = ChatTranscriptRenderer.RunsForFragment(
+ TellSpans("Dww", " tells you"),
+ fragmentStart: 0,
+ fragmentLength: "Dww tells you".Length,
+ LineColor,
+ TagGreen);
+
+ Assert.NotNull(runs);
+ Assert.Equal(2, runs!.Count);
+ Assert.Equal(("Dww", TagGreen), (runs[0].Text, runs[0].Color));
+ Assert.Equal((" tells you", LineColor), (runs[1].Text, runs[1].Color));
+ }
+
+ [Fact]
+ public void AFragmentWithNoTagInItGetsNoRunsAtAll()
+ {
+ // A single-colour fragment must take the ordinary flat draw path
+ // rather than a one-run list that means the same thing.
+ IReadOnlyList spans = TellSpans("Dww", " tells you, hello there");
+
+ Assert.Null(ChatTranscriptRenderer.RunsForFragment(
+ spans,
+ fragmentStart: 10, // well past the name
+ fragmentLength: 5,
+ LineColor,
+ TagGreen));
+ }
+
+ [Fact]
+ public void ATagStraddlingAWrapBreakIsSplitAcrossBothFragments()
+ {
+ // The name is long enough to be cut in half by the wrap; both halves
+ // must still be green, or a name would change colour mid-word.
+ IReadOnlyList spans = TellSpans("Bartholomew", " tells you");
+
+ IReadOnlyList? first = ChatTranscriptRenderer.RunsForFragment(
+ spans, fragmentStart: 0, fragmentLength: 5, LineColor, TagGreen);
+ IReadOnlyList? second = ChatTranscriptRenderer.RunsForFragment(
+ spans, fragmentStart: 5, fragmentLength: 6, LineColor, TagGreen);
+
+ Assert.Equal("Barth", Assert.Single(first!).Text);
+ Assert.Equal(TagGreen, first![0].Color);
+ Assert.Equal("olomew", Assert.Single(second!).Text);
+ Assert.Equal(TagGreen, second![0].Color);
+ }
+
+ [Fact]
+ public void RunsAlwaysReproduceTheFragmentTheyCover()
+ {
+ // UiText.RunsMatchLine refuses to draw runs that disagree with the
+ // line, so a mapping bug here would silently fall back to flat text
+ // rather than fail loudly. Assert the contract directly.
+ IReadOnlyList spans = TellSpans("Dww", " tells you, \"hi\"");
+ string line = string.Concat(spans.Select(s => s.Text));
+
+ for (int start = 0; start < line.Length; start++)
+ {
+ for (int len = 1; len <= line.Length - start; len++)
+ {
+ IReadOnlyList? runs =
+ ChatTranscriptRenderer.RunsForFragment(
+ spans, start, len, LineColor, TagGreen);
+ if (runs is null)
+ continue;
+
+ string fragment = line.Substring(start, len);
+ Assert.True(
+ UiText.RunsMatchLine(runs, fragment),
+ $"runs disagree with fragment [{start}..{start + len})");
+ }
+ }
+ }
+
+ [Fact]
+ public void BuildLines_EmitsOneRunEntryPerWrappedFragment()
+ {
+ // The run list is index-aligned with the lines the transcript draws;
+ // if the two ever fall out of step, names colour on the wrong rows.
+ var detailed = new List
+ {
+ new("Dww tells you, hello there friend", ChatKind.Tell, null, 0x03u,
+ TellSpans("Dww", " tells you, hello there friend")),
+ new("Welcome.", ChatKind.System, null, 0x05u),
+ };
+ var runs = new List?>();
+
+ List lines = ChatTranscriptRenderer.BuildLines(
+ detailed,
+ maxW: 150f, // forces the first entry to wrap
+ Measure,
+ accept: null,
+ defaultColor: LineColor,
+ tagColor: TagGreen,
+ runsPerLine: runs);
+
+ Assert.True(lines.Count > 2, "the first line should have wrapped");
+ Assert.Equal(lines.Count, runs.Count);
+
+ // The very first fragment holds the name, so it is the one with runs.
+ Assert.NotNull(runs[0]);
+ Assert.Equal(TagGreen, runs[0]![0].Color);
+
+ // The untagged system line never gets runs.
+ Assert.Null(runs[^1]);
+ }
+
+ [Fact]
+ public void BuildLines_WithoutATagColourFallsBackToTheLineColour()
+ {
+ // An element that authors no 0x1D must not render the name in a
+ // colour nobody chose; it renders like the rest of the line.
+ var detailed = new List
+ {
+ new("Dww tells you", ChatKind.Tell, null, 0x03u,
+ TellSpans("Dww", " tells you")),
+ };
+ var runs = new List?>();
+
+ ChatTranscriptRenderer.BuildLines(
+ detailed, maxW: 1000f, Measure, accept: null,
+ defaultColor: LineColor, tagColor: null, runsPerLine: runs);
+
+ IReadOnlyList line = Assert.Single(runs)!;
+ Assert.All(line, run => Assert.Equal(line[0].Color, run.Color));
+ }
+}