acdream/src/AcDream.App/UI/Layout/DatRichText.cs
Erik 967b9c57cf fix(ui): systemic escape normalization at the string source
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>
2026-08-17 13:26:25 +02:00

102 lines
4.8 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared multi-segment rich-text composer for the chargen description
/// boxes (Campaign CC gate round 1 Batch C — GF-2/GF-3/GF-11a, and the
/// Summary how-to text). Ports retail's
/// <c>UIElement_Text::SetStringInfoWithFont</c> /
/// <c>AppendStringInfoWithFont @ 0x00469D70</c> composition model: a text
/// box is built from an ORDERED list of string segments, each carrying its
/// OWN font-color palette index
/// (<c>UIElement_Text::AppendStringInfoWithFont</c>'s
/// <c>SetFontColorHelper</c> -&gt; <c>InqProperty(0x1B)</c> array lookup —
/// see <see cref="AcDream.App.UI.UiText.FontColorPalette"/>).
///
/// <para>
/// The description pages used to bypass this entirely: they assigned a raw
/// <c>LinesProvider</c> lambda returning ONE unwrapped <see cref="AcDream.App.UI.UiText.Line"/>
/// per composed string, with no word-wrap. Historical symptom (Batch C):
/// for the Town page an unwrapped single line meant the town-specific
/// SUFFIX of the composed string rendered far outside the box's clipped
/// viewport, so switching towns looked like "the text never changes" even
/// though the underlying string genuinely did (only its INVISIBLE tail
/// differed). Escape decoding (the DAT's literal two-character <c>"\n"</c>,
/// Batch C's other symptom) has since moved to the string source
/// (<see cref="DatStringResolver"/> → <see cref="RetailStringEscapes"/>,
/// the 2026-08-17 systemic round) — segments reach this composer with real
/// line breaks already in place.
/// </para>
/// </summary>
internal static class DatRichText
{
/// <summary>One composed segment: text plus the color it should render
/// in. A null or empty <see cref="Text"/> is silently skipped (mirrors
/// retail's own null-string-info no-op guards throughout this text
/// composition family).</summary>
public readonly record struct Segment(string? Text, Vector4 Color);
/// <summary>
/// Word-wraps every segment (independently, so
/// each segment's wrapped lines keep ITS OWN color), then concatenates
/// the results in order. No separator is inserted between segments —
/// retail's own composition calls concatenate directly
/// (<c>AppendStringInfoWithFont</c>/<c>append_n_chars</c> with no
/// interposed literal), so any blank-line spacing between sections
/// comes from the authored DAT string content itself, not from code
/// here.
/// </summary>
public static IReadOnlyList<UiText.Line> Compose(
UiText target,
IReadOnlyList<Segment> segments)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(segments);
var lines = new List<UiText.Line>();
// R2-1 (Campaign CC gate round 1 Batch E): the wrap width must shrink
// by the SAME left+right inset the draw path now applies (Padding
// plus the four retail margins, UiText.MarginLeft's own doc) — the
// Batch-C regression's second half: text wasn't just drawing at the
// wrong X, it was also wrapping to the FULL box width instead of the
// authored interior width, overflowing the visible right edge too.
float maximumWidth = MathF.Max(
1f,
target.Width - (target.Padding + target.MarginLeft) - (target.Padding + target.MarginRight));
Func<string, float> measure = target.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
foreach (Segment segment in segments)
{
if (string.IsNullOrEmpty(segment.Text))
continue;
// Escape decoding (the DAT's literal two-character "\n") happens
// at the string source (DatStringResolver → RetailStringEscapes,
// 2026-08-17 systemic round — retail's own placement), so
// segments arrive with real line breaks; WrapWords preserves
// them and drops any stray CR itself.
foreach (string wrapped in UiText.WrapWords(segment.Text, measure, maximumWidth))
lines.Add(new UiText.Line(wrapped, segment.Color));
}
return lines;
}
/// <summary>
/// Resolves <paramref name="target"/>'s own authored font-color
/// palette (dat property <c>0x1B</c>) entry at <paramref name="index"/>,
/// falling back to <paramref name="fallback"/> when the palette is
/// absent or too short. Mirrors the same fallback shape
/// <c>CharacterStatController.BuildSelectedTitleRuns</c> already uses
/// for its own palette-indexed colors.
/// </summary>
public static Vector4 PaletteColor(UiText target, int index, Vector4 fallback) =>
index >= 0 && index < target.FontColorPalette.Count
? target.FontColorPalette[index]
: fallback;
}