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

@ -9,9 +9,23 @@ namespace AcDream.App.UI.Layout;
/// The caller owns synchronization around <see cref="DatCollection"/> reads.
/// </summary>
/// <remarks>
/// <para>
/// Retail reference: <c>StringInfo::GetString</c> and
/// <c>compute_str_hash @ 0x00413110</c>. A StringInfo's token selects one
/// localized string variant; ordinary UI labels use token zero.
/// </para>
/// <para>
/// Every resolution decodes the DAT's two-character escapes
/// (<c>\n</c>, <c>\t</c>, <c>\r</c>, <c>\q</c>, and the metalanguage
/// self-escapes) HERE, at the source — retail's own placement: every public
/// <c>StringInfo</c> resolution ends in
/// <c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c>
/// (<c>StringInfo::InqString @ 0x0042E490</c>,
/// <c>StringInfo::GetLiteralValue @ 0x0042CA50</c>). Consumers receive
/// already-decoded text and must not re-decode — see
/// <see cref="RetailStringEscapes"/>' remarks for the double-decode hazard
/// (the 2026-08-17 systemic round that retired the per-consumer copies).
/// </para>
/// </remarks>
public sealed class DatStringResolver
{
@ -41,7 +55,9 @@ public sealed class DatStringResolver
return null;
int index = token >= 0 && token < entry.Strings.Count ? token : 0;
return entry.Strings[index].Value;
// StringInfo::InqString @ 0x0042E490's unconditional tail: the stored
// string is escaped; the resolved string is decoded.
return RetailStringEscapes.Unescape(entry.Strings[index].Value);
}
/// <summary>Returns every literal token for one retail StringInfo entry.</summary>
@ -57,7 +73,9 @@ public sealed class DatStringResolver
return table is not null
&& table.Strings.TryGetValue(stringId, out var entry)
&& entry.Strings.Count != 0
? entry.Strings.Select(value => value.Value).ToArray()
? entry.Strings
.Select(value => RetailStringEscapes.Unescape(value.Value))
.ToArray()
: null;
}
@ -105,14 +123,21 @@ public sealed class DatStringResolver
{
composed.Append(entry.Strings[i].Value);
// Variables are stored as the pre-computed name hashes (the same
// compute_str_hash space PlayerVariable lives in).
// compute_str_hash space PlayerVariable lives in). Each value is
// escaped on insert — retail's AddVariable_String @ 0x0042E6C0
// stores every variable through SetLiteralValue(escape=1)
// @ 0x0042C980 → EscapeString — so the final whole-string
// unescape below returns variable content verbatim while
// decoding the authored fragments' escapes.
if (i < entry.Variables.Count
&& variables.TryGetValue(entry.Variables[i], out string? value))
{
composed.Append(value);
composed.Append(RetailStringEscapes.Escape(value));
}
}
return composed.ToString();
// StringInfo::InqString @ 0x0042E490's unconditional tail, same as
// Resolve above: composed text decodes its escapes at the source.
return RetailStringEscapes.Unescape(composed.ToString());
}
/// <summary>