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

@ -553,16 +553,20 @@ public class UiButtonTests
Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button");
}
// ── R2-2 escape-normalize ────────────────────────────────────────────
// ── R2-2 authored caption (source-decoded) ───────────────────────────
/// <summary>
/// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way
/// BuildText's authored-string path always has — the DAT stores the
/// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession
/// credits button's own authored caption is exactly this shape.
/// R2-2's successor contract (2026-08-17 systemic round): the DAT's
/// LITERAL two-character escape "\n" (0x5C 0x6E — the Profession
/// credits button's own authored caption is exactly this shape) decodes
/// at the string SOURCE (DatStringResolver → RetailStringEscapes,
/// retail's own placement), so the resolver seam hands BuildButton a
/// caption with a REAL line break — and the factory passes it through
/// verbatim, with no second decode that would corrupt an authored
/// backslash pair.
/// </summary>
[Fact]
public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape()
public void BuildButton_OwnCaption_PassesSourceDecodedTextThrough()
{
uint stringId = 444u;
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
@ -575,11 +579,14 @@ public class UiButtonTests
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
// The raw resolved string carries the LITERAL two characters
// '\' and 'n', matching what the installed DAT actually stores.
stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null));
// The resolver seam models DatStringResolver's post-decode
// output: a REAL '\n', plus a literal backslash pair that a
// stray second decode would corrupt into a line break.
stringResolve: value => value.StringId == stringId
? "Attribute\n Credits \\not-an-escape"
: null));
Assert.Equal("Attribute\n Credits", button.Label);
Assert.Equal("Attribute\n Credits \\not-an-escape", button.Label);
}
private static UiButton ButtonWithStates(params string[] states)