The exit-world confirmation (ID_Client_EndCharacterSessionConfirm, table
0x23000001 key 0x0EB1C41D) rendered its literal two-character "\n" escapes
because escape decoding lived in individual consumers — Batch E centralized
it for authored captions only (DatWidgetFactory.ResolveAuthoredString), and
each new string surface had to remember its own copy. The installed DAT
carries the escape in 4,365 of 7,050 strings; per-consumer normalization
was structurally guaranteed to keep leaking.
Retail's placement is the SOURCE, not the widget: every public StringInfo
resolution ends in StringTableMetaLanguage::UnescapeString @ 0x0067BDC0
(StringInfo::InqString @ 0x0042E490, GetLiteralValue @ 0x0042CA50), the
write side escapes (SetLiteralValue @ 0x0042C980; AddVariable_String
@ 0x0042E6C0 for template variables), and widgets receive decoded text.
Ported exactly:
- NEW RetailStringEscapes: UnescapeString/EscapeString + the
GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0 tables
(\n \t \r \q + the ten metalanguage self-escapes []!{}#\|^$,
byte-verified against the PDB-paired 2013 binary at 0x3FE178;
unrecognized pairs stay verbatim).
- DatStringResolver.Resolve/ResolveAll unescape at the source;
ResolveTemplate escapes each variable on insert and unescapes the
composed whole — retail's round trip, so variable content (player
names) can never be corrupted by the final decode.
- RETIRED the consumer copies (double paths would corrupt an authored
"\n" into a line break): DatWidgetFactory.NormalizeEscapes + BuildText's
inline replace, RetailUiRuntime.NormalizeRetailNewlines + the
OpenCaptureInstructions inline replace, DatRichText.Compose's replace,
IndicatorDetailText.Shape's replace. ItemAppraisalTextLayout's replace
stays — WIRE-domain (server strings never pass the DAT source; retail's
ItemExamineUI::AddItemInfo @ 0x004AC050 appends wire text verbatim), now
documented as such.
- Consumer CR-strips retired with them: the installed DATs contain ZERO
real CR characters (sweep-measured) and UiText.WrapWords already drops
strays.
Tests: RetailStringEscapes conformance (escape set, unknown pairs,
round trip), DatStringResolver source-decode pins (including the exact
user-reported exit-world text shape and a backslash-carrying variable),
the installed-DAT escape sweep (7,050 strings; every resolution must equal
the retail unescape of the raw entry; inventory printed), and the existing
caption/rich-text/live-DAT pins relocated to the source contract.
App 5550/3 (live-DAT), Runtime 1747/0, complete Release solution green
across all suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
156 lines
5.8 KiB
C#
156 lines
5.8 KiB
C#
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign CC gate round 1 Batch C: unit tests for the shared
|
|
/// word-wrap + per-segment-color helper feeding GF-2/GF-3/GF-11a and the
|
|
/// Summary how-to text (Commit 3). Escape decoding moved to the string
|
|
/// source in the 2026-08-17 systemic round (DatStringResolver →
|
|
/// RetailStringEscapes) — segments reach Compose with real line breaks.
|
|
/// </summary>
|
|
public class DatRichTextTests
|
|
{
|
|
private static readonly Vector4 White = Vector4.One;
|
|
private static readonly Vector4 Green = new(0f, 1f, 0f, 1f);
|
|
|
|
private static UiText MakeTarget(float width) =>
|
|
new() { Width = width, Height = 200f };
|
|
|
|
/// <summary>Segments arrive source-decoded (real '\n'); Compose keeps
|
|
/// the authored break as a line split. A literal backslash-n pair in a
|
|
/// segment must stay VERBATIM — re-decoding here is the double-decode
|
|
/// hazard the 2026-08-17 round retired.</summary>
|
|
[Fact]
|
|
public void Compose_SplitsOnRealNewlines_AndKeepsLiteralPairsVerbatim()
|
|
{
|
|
UiText target = MakeTarget(1000f); // wide enough that nothing wraps
|
|
var segments = new[]
|
|
{
|
|
new DatRichText.Segment("line one\nliteral \\n stays", White),
|
|
};
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal("line one", lines[0].Text);
|
|
Assert.Equal("literal \\n stays", lines[1].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_WordWrapsToTheTargetWidth()
|
|
{
|
|
// Bitmap-font-shaped measure: 8px/char, matching BuildText's own
|
|
// authored-string fallback measure.
|
|
UiText target = MakeTarget(80f); // 10 chars per line at 8px/char
|
|
var segments = new[]
|
|
{
|
|
new DatRichText.Segment("one two three four five six seven eight", White),
|
|
};
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
Assert.True(lines.Count > 1, "a long segment must wrap to more than one line");
|
|
foreach (UiText.Line line in lines)
|
|
Assert.True(line.Text.Length * 8f <= 80f, $"line '{line.Text}' overflowed the target width");
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_EachSegmentKeepsItsOwnColorAcrossItsWrappedLines()
|
|
{
|
|
UiText target = MakeTarget(1000f);
|
|
var segments = new[]
|
|
{
|
|
new DatRichText.Segment("Header:", Green),
|
|
new DatRichText.Segment("Body text.", White),
|
|
};
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal(Green, lines[0].Color);
|
|
Assert.Equal(White, lines[1].Color);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_NullOrEmptySegmentText_IsSkipped()
|
|
{
|
|
UiText target = MakeTarget(1000f);
|
|
var segments = new[]
|
|
{
|
|
new DatRichText.Segment(null, White),
|
|
new DatRichText.Segment(string.Empty, White),
|
|
new DatRichText.Segment("real text", White),
|
|
};
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
Assert.Single(lines);
|
|
Assert.Equal("real text", lines[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_NoSeparatorInsertedBetweenSegments()
|
|
{
|
|
// Retail's own composition calls concatenate directly
|
|
// (AppendStringInfoWithFont / append_n_chars, no interposed
|
|
// literal) — this helper must not invent one either.
|
|
UiText target = MakeTarget(1000f);
|
|
var segments = new[]
|
|
{
|
|
new DatRichText.Segment("first", White),
|
|
new DatRichText.Segment("second", White),
|
|
};
|
|
|
|
var lines = DatRichText.Compose(target, segments);
|
|
|
|
// Each segment still wraps independently (so "first"/"second" stay
|
|
// on separate output lines, not glued into "firstsecond") — but no
|
|
// BLANK line is inserted between them unless the segment's own
|
|
// text carried one.
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal("first", lines[0].Text);
|
|
Assert.Equal("second", lines[1].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compose_WordWrapsToTheTargetWidth_MinusTheFourRetailMargins()
|
|
{
|
|
// R2-1 (Campaign CC gate round 1 Batch E): the wrap width must
|
|
// shrink by BOTH Padding and the four retail margins (properties
|
|
// 0x23-0x26 — MarginLeft's own doc comment on UiText), not just the
|
|
// element's raw Width. A 100px-wide box with margL=10/margR=10
|
|
// leaves only 80px of usable width — one 10-char/8px-per-char word
|
|
// ("aaaaaaaaaa", 80px) must fit on one line, but appending an 11th
|
|
// 'a' (88px) must force a wrap.
|
|
UiText fits = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f };
|
|
var fitsLines = DatRichText.Compose(fits, [new DatRichText.Segment("aaaaaaaaaa", White)]);
|
|
Assert.Single(fitsLines);
|
|
|
|
UiText overflows = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f };
|
|
var overflowLines = DatRichText.Compose(overflows, [new DatRichText.Segment("aaaaaaaaaaa", White)]);
|
|
Assert.True(overflowLines.Count > 1, "an 88px word in an 80px content width must wrap");
|
|
}
|
|
|
|
[Fact]
|
|
public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent()
|
|
{
|
|
UiText target = new()
|
|
{
|
|
FontColorPalette = [White, Green],
|
|
};
|
|
|
|
Assert.Equal(White, DatRichText.PaletteColor(target, 0, Green));
|
|
Assert.Equal(Green, DatRichText.PaletteColor(target, 1, White));
|
|
}
|
|
|
|
[Fact]
|
|
public void PaletteColor_FallsBack_WhenPaletteTooShortOrMissing()
|
|
{
|
|
UiText target = new(); // empty palette
|
|
|
|
Assert.Equal(Green, DatRichText.PaletteColor(target, 1, Green));
|
|
Assert.Equal(Green, DatRichText.PaletteColor(target, -1, Green));
|
|
}
|
|
}
|