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>
This commit is contained in:
Erik 2026-08-17 13:26:25 +02:00
parent fdc4fd496d
commit 967b9c57cf
15 changed files with 605 additions and 116 deletions

View file

@ -856,9 +856,11 @@ public static class DatWidgetFactory
{
// 2026-08-13 social gate: authored strings can carry embedded
// newlines (the fellowship empty-state is three sentences over
// '\n's). Gate round 2: the DAT stores the LITERAL two-character
// escape "\n" (0x5C 0x6E — probe-verified: the dump printed
// backslash-n, not a line break), so normalize the escape first.
// '\n's). The DAT stores those as the LITERAL two-character
// escape "\n" (0x5C 0x6E — probe-verified), decoded at the
// string SOURCE since the 2026-08-17 systemic round
// (DatStringResolver → RetailStringEscapes; retail's own
// placement) — `authored` arrives with REAL line breaks here.
// Gate round 3: retail additionally WORD-WRAPS each authored line
// within the element extent (its GlyphList draw — the same wrap
// the confirmation dialog view already uses), so a multiline
@ -868,10 +870,7 @@ public static class DatWidgetFactory
// re-wrapping them is a client-wide behavior change no gate has
// asked for). Providers re-read DefaultColor/width/font per call
// (NOT captured eagerly) so state-driven changes keep tracking.
string normalized = authored
.Replace("\\n", "\n")
.Replace("\r", string.Empty);
if (normalized.Contains('\n'))
if (authored.Contains('\n'))
{
float cachedWidth = float.NaN;
UiDatFont? cachedFont = null;
@ -897,7 +896,7 @@ public static class DatWidgetFactory
? font.MeasureWidth
: static value => value.Length * 8f;
cachedLines = [.. UiText
.WrapWords(normalized, measure, maximumWidth)
.WrapWords(authored, measure, maximumWidth)
.Select(line => new UiText.Line(line, t.DefaultColor))];
}
return cachedLines;
@ -906,7 +905,7 @@ public static class DatWidgetFactory
else
{
t.LinesProvider = () =>
[new UiText.Line(normalized, t.DefaultColor)];
[new UiText.Line(authored, t.DefaultColor)];
}
}
@ -921,7 +920,7 @@ public static class DatWidgetFactory
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|| stateCaption.Kind != UiPropertyKind.StringInfo)
continue;
if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue))
if (stringResolve?.Invoke(stateCaption.StringInfoValue)
is { Length: > 0 } text)
(stateStrings ??= new Dictionary<uint, string>())[stateId] = text;
}
@ -1220,6 +1219,20 @@ public static class DatWidgetFactory
.OrderBy(child => child.ReadOrder)
.ToArray();
/// <summary>
/// Resolves the effective authored caption (dat property <c>0x17</c>)
/// for a widget. Escape decoding is NOT done here: since the 2026-08-17
/// systemic round the string SOURCE (<see cref="DatStringResolver"/> →
/// <see cref="RetailStringEscapes"/>, retail's own placement — every
/// <c>StringInfo</c> resolution ends in
/// <c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c>) hands
/// every consumer already-decoded text. That supersedes R2-2 (Campaign
/// CC gate round 1 Batch E)'s consumer-level normalize, which covered
/// only the P0x17 resolutions in THIS file and missed sibling consumers
/// (the exit-world confirmation dialog, gate round 2) — the exact class
/// of bug source placement closes. Re-decoding here would corrupt an
/// authored <c>\\n</c> (escaped backslash then 'n') into a line break.
/// </summary>
private static string? ResolveAuthoredString(
ElementInfo info,
Func<UiStringInfoValue, string?>? stringResolve)
@ -1228,42 +1241,16 @@ public static class DatWidgetFactory
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|| property.Kind != UiPropertyKind.StringInfo)
return null;
string? resolved = stringResolve(property.StringInfoValue);
// R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL
// two-character escape "\n" (0x5C 0x6E), not a real line break — same
// fact BuildText's own authored-string path already normalized for
// (see that call site's own comment). Centralizing the normalize
// HERE, at the single choke point every P0x17 caption resolution in
// this file goes through (BuildText, BuildButton's own caption AND
// its lifted-child caption, BuildButton's coexisting ValueLabel,
// BuildCheckbox), closes the exact class of bug R2-2 found: a caption
// like the Profession credits button's own "Attribute\n Credits"
// rendered the literal backslash-n because BuildButton never
// normalized while BuildText did. BuildText's own subsequent
// Replace("\\n","\n") is now a harmless no-op (idempotent) — left in
// place rather than removed, since it costs nothing and documents the
// same fact locally.
return NormalizeEscapes(resolved);
return stringResolve(property.StringInfoValue);
}
/// <summary>
/// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize
/// <see cref="ResolveAuthoredString"/> applies, pulled out so the
/// per-STATE authored-caption loop below (which resolves a state's own
/// <c>0x17</c> directly, bypassing the effective-property resolution
/// <see cref="ResolveAuthoredString"/> wraps) gets the SAME normalize
/// instead of a second, easily-forgotten copy.
/// </summary>
private static string? NormalizeEscapes(string? raw) =>
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
/// <summary>
/// #409 (client-wide retail tooltip system): resolves the already-
/// extracted <see cref="ElementInfo.TooltipText"/> (dat property
/// <c>0x49</c>) through <paramref name="stringResolve"/>, applying the
/// SAME escape normalization every other authored <c>StringInfo</c>
/// (captions, <c>0x17</c>) gets at this one choke point. Null when the
/// element authors no tooltip text or no resolver is available.
/// <c>0x49</c>) through <paramref name="stringResolve"/>. Arrives
/// escape-decoded from the string source, like every authored
/// <c>StringInfo</c> (see <see cref="ResolveAuthoredString"/>). Null
/// when the element authors no tooltip text or no resolver is available.
/// </summary>
internal static string? ResolveTooltipText(
ElementInfo info,
@ -1271,6 +1258,6 @@ public static class DatWidgetFactory
{
if (stringResolve is null || info.TooltipText is not { } tooltipText)
return null;
return NormalizeEscapes(stringResolve(tooltipText));
return stringResolve(tooltipText);
}
}