feat(chat): CT-A4 — speaker names render in retail's tag colour
Campaign CT slice A4, and the first slice of Group A that shows on screen: a player's name in a chat line now draws green while the rest of the line keeps its message colour. The colour is AUTHORED, not computed. Retail keeps two parallel index-selected colour arrays on the text element and refreshes both from the same caller index on every append (UIElement_Text::AppendStringInfoWithFont @0x00469DE0): property 0x1B for ordinary glyphs, 0x1D for glyphs under an open tag. Property 0x1D is read exactly the way 0x1B already was, carried on ElementInfo, and seeded onto UiText beside DefaultColor. Measured on the chat transcript (0x2100006F / 0x10000011) as RGB(0,178,0). It deliberately does NOT go into RetailChatColorTable. That table is the runtime-built per-LogTextType mapping; the tag colour is per-element authored data, and filing it there would put it somewhere it would look right in tests and be wrong in principle. RunsForFragment is the load-bearing piece and is pure. Wrapping can drop the space it broke on, so a fragment is NOT simply the next N characters of the line — BuildLines locates each fragment in the source text to keep the span offsets honest, and the mapper clips spans to the fragment window. A tag straddling a wrap break is therefore split across both fragments and stays green on both, instead of changing colour mid-word. Two guards worth naming. A fragment containing no tag returns NULL rather than a single-run list, so the overwhelming majority of lines keep the existing flat draw path untouched. And an element authoring no 0x1D falls back to the line colour, so a name never renders in a colour nobody chose. The run/fragment contract is property-tested across every substring of a tell line, because CT-A1's RunsMatchLine refuses mismatched runs by silently falling back to flat text — a mapping bug here would degrade quietly rather than fail. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
44fb74f8a6
commit
53395e4de4
6 changed files with 318 additions and 2 deletions
|
|
@ -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 <see cref="RetailChatColorTable"/>'s own doc).
|
||||
/// Callers pass their transcript's <see cref="UiText.DefaultColor"/>.
|
||||
/// </param>
|
||||
/// <summary>
|
||||
/// The runs covering one wrapped fragment, or <see langword="null"/> when
|
||||
/// the fragment is a single colour.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static IReadOnlyList<UiText.TextRun>? RunsForFragment(
|
||||
IReadOnlyList<ChatTextSpan> spans,
|
||||
int fragmentStart,
|
||||
int fragmentLength,
|
||||
Vector4 lineColor,
|
||||
Vector4 tagColor)
|
||||
{
|
||||
int fragmentEnd = fragmentStart + fragmentLength;
|
||||
var runs = new List<UiText.TextRun>();
|
||||
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<UiText.Line> BuildLines(
|
||||
IReadOnlyList<FormattedLine> detailed,
|
||||
float maxW,
|
||||
Func<string, float> measure,
|
||||
Func<uint, bool>? accept,
|
||||
Vector4 defaultColor)
|
||||
Vector4 defaultColor,
|
||||
Vector4? tagColor = null,
|
||||
List<IReadOnlyList<UiText.TextRun>?>? runsPerLine = null)
|
||||
{
|
||||
var result = new List<UiText.Line>(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
|
||||
|
||||
/// <summary>
|
||||
/// Per-cached-line runs, index-aligned with
|
||||
/// <see cref="_cachedTranscriptLines"/>. Null at an index means that line
|
||||
/// is a single colour and draws through the ordinary flat path.
|
||||
/// </summary>
|
||||
private readonly List<IReadOnlyList<UiText.TextRun>?> _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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -131,6 +131,22 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public Vector4? FontColor;
|
||||
|
||||
/// <summary>
|
||||
/// Authored TAG font colour (dat property <c>0x1D</c>), the colour retail
|
||||
/// gives a tagged glyph run — a clickable speaker name — as distinct from
|
||||
/// <see cref="FontColor"/> (<c>0x1B</c>) for everything else.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The two are parallel index-selected arrays refreshed from the same
|
||||
/// caller index on every append
|
||||
/// (<c>UIElement_Text::AppendStringInfoWithFont @0x00469DE0</c>), 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 <c>RetailChatColorTable</c>.
|
||||
/// </remarks>
|
||||
public Vector4? TagFontColor;
|
||||
|
||||
/// <summary>
|
||||
/// Outline flag from dat <c>Properties[0x21]</c> (<c>BoolBaseProperty</c>). Retail
|
||||
/// <c>UIElement_Text::SetOutline @0x0046a81c</c> / <c>m_bitField & 0x10</c>.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -167,6 +167,13 @@ public sealed class UiText : UiElement, IUiDatStateful
|
|||
/// </summary>
|
||||
public Vector4 DefaultColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>
|
||||
/// Colour for a TAGGED run (dat property <c>0x1D</c>) — retail's clickable
|
||||
/// speaker name. Falls back to <see cref="DefaultColor"/> when the element
|
||||
/// authors none.
|
||||
/// </summary>
|
||||
public Vector4? TagColor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authored <c>UIElement_Text</c> font-color list from LayoutDesc property
|
||||
/// <c>0x1B</c>. Retail <c>AppendTextWithFont @ 0x00469D70</c> selects an
|
||||
|
|
|
|||
161
tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
Normal file
161
tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ChatTranscriptRunsTests
|
||||
{
|
||||
private static readonly Vector4 LineColor = new(0.8f, 0.8f, 0.8f, 1f);
|
||||
|
||||
/// <summary>Measured from the installed dats: chat 0x2100006F / 0x10000011.</summary>
|
||||
private static readonly Vector4 TagGreen = new(0f, 178f / 255f, 0f, 1f);
|
||||
|
||||
/// <summary>10px per character, so wrap points are predictable.</summary>
|
||||
private static float Measure(string text) => text.Length * 10f;
|
||||
|
||||
private static IReadOnlyList<ChatTextSpan> TellSpans(string name, string rest)
|
||||
=> new[]
|
||||
{
|
||||
new ChatTextSpan(name, new ChatTextTag("Tell", "IIDString", $"1342177290:{name}")),
|
||||
new ChatTextSpan(rest, null),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void TheNameTakesTheTagColourAndTheRestTakesTheLineColour()
|
||||
{
|
||||
IReadOnlyList<UiText.TextRun>? 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<ChatTextSpan> 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<ChatTextSpan> spans = TellSpans("Bartholomew", " tells you");
|
||||
|
||||
IReadOnlyList<UiText.TextRun>? first = ChatTranscriptRenderer.RunsForFragment(
|
||||
spans, fragmentStart: 0, fragmentLength: 5, LineColor, TagGreen);
|
||||
IReadOnlyList<UiText.TextRun>? 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<ChatTextSpan> 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<UiText.TextRun>? 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<FormattedLine>
|
||||
{
|
||||
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<IReadOnlyList<UiText.TextRun>?>();
|
||||
|
||||
List<UiText.Line> 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<FormattedLine>
|
||||
{
|
||||
new("Dww tells you", ChatKind.Tell, null, 0x03u,
|
||||
TellSpans("Dww", " tells you")),
|
||||
};
|
||||
var runs = new List<IReadOnlyList<UiText.TextRun>?>();
|
||||
|
||||
ChatTranscriptRenderer.BuildLines(
|
||||
detailed, maxW: 1000f, Measure, accept: null,
|
||||
defaultColor: LineColor, tagColor: null, runsPerLine: runs);
|
||||
|
||||
IReadOnlyList<UiText.TextRun> line = Assert.Single(runs)!;
|
||||
Assert.All(line, run => Assert.Equal(line[0].Color, run.Color));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue