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>
102 lines
4.8 KiB
C#
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> -> <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;
|
|
}
|