acdream/src/AcDream.App/UI/Layout/RetailStringEscapes.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

140 lines
5.6 KiB
C#

using System.Text;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Exact port of retail's string-table escape codec
/// (<c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c> /
/// <c>EscapeString @ 0x0067BBC0</c> and their character tables
/// <c>GetUnEscapedChar @ 0x0067B750</c> / <c>GetEscapedChar @ 0x0067B6C0</c>).
/// </summary>
/// <remarks>
/// <para>
/// PLACEMENT (the systemic 2026-08-17 normalization round): retail decodes
/// escapes at the string SOURCE, not per-widget. Every public
/// <c>StringInfo</c> resolution runs the unescape unconditionally before any
/// consumer sees the text — <c>StringInfo::InqString @ 0x0042E490</c> tail
/// and <c>StringInfo::GetLiteralValue @ 0x0042CA50</c> both end in
/// <c>UnescapeString</c>. The write side is the inverse:
/// <c>StringInfo::SetLiteralValue @ 0x0042C980</c> runs <c>EscapeString</c>
/// when storing plain text (and <c>StringInfo::AddVariable_String
/// @ 0x0042E6C0</c> always stores variables that way), so stored text is
/// escaped, resolved text is decoded, and variable content round-trips
/// verbatim. acdream's equivalent source is <see cref="DatStringResolver"/>;
/// widgets and controllers receive already-decoded strings and must not
/// re-decode (a second pass corrupts an authored <c>\\n</c> — escaped
/// backslash then 'n' — into a line break).
/// </para>
/// <para>
/// The escape set (byte-verified against the PDB-paired 2013 binary; the
/// metalanguage character-set literal at file offset 0x3FE178 is the ten
/// characters <c>[]!{}#\|^$</c>):
/// <c>\n</c> → LF (0x0A), <c>\t</c> → TAB (0x09), <c>\r</c> → CR (0x0D),
/// <c>\q</c> → '"' (0x22), and a backslash before any of the ten
/// metalanguage characters yields that character itself. A backslash before
/// anything else is NOT an escape — retail copies it through verbatim
/// (<c>GetUnEscapedChar</c> returns 0 and <c>UnescapeString</c>'s
/// else-branch keeps the current character).
/// </para>
/// </remarks>
public static class RetailStringEscapes
{
/// <summary>The ten metalanguage-significant characters that escape to
/// themselves. Byte-decoded from the retail binary (see class remarks) —
/// the same literal both character tables test with <c>wcschr</c>.</summary>
private const string MetaCharacters = "[]!{}#\\|^$";
/// <summary>
/// <c>StringTableMetaLanguage::GetUnEscapedChar @ 0x0067B750</c>: the
/// character an escape pair <c>\</c>+<paramref name="value"/> decodes
/// to, or <c>'\0'</c> when the pair is not an escape.
/// </summary>
internal static char GetUnEscapedChar(char value) => value switch
{
'n' => '\n',
'q' => '"',
'r' => '\r',
't' => '\t',
not '\0' when MetaCharacters.Contains(value) => value,
_ => '\0',
};
/// <summary>
/// <c>StringTableMetaLanguage::GetEscapedChar @ 0x0067B6C0</c>: the
/// character that follows the backslash when <paramref name="value"/>
/// must be stored escaped, or <c>'\0'</c> when it is stored verbatim.
/// </summary>
internal static char GetEscapedChar(char value) => value switch
{
'\t' => 't',
'\n' => 'n',
'\r' => 'r',
'"' => 'q',
not '\0' when MetaCharacters.Contains(value) => value,
_ => '\0',
};
/// <summary>
/// <c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c>: decodes
/// every two-character escape pair; all other characters (including a
/// backslash that does not start a recognized pair, and a trailing
/// backslash) copy through verbatim.
/// </summary>
public static string Unescape(string value)
{
ArgumentNullException.ThrowIfNull(value);
// Fast path: a string with no backslash cannot contain an escape.
int first = value.IndexOf('\\');
if (first < 0)
return value;
var result = new StringBuilder(value.Length);
for (int i = 0; i < value.Length; i++)
{
char current = value[i];
// Retail reads the character AFTER the candidate backslash (the
// terminator — never an escape — when at the end of the buffer).
char next = i + 1 < value.Length ? value[i + 1] : '\0';
char unescaped = GetUnEscapedChar(next);
if (current == '\\' && unescaped != '\0')
{
result.Append(unescaped);
i++; // consume the pair
}
else if (current != '\0')
{
result.Append(current);
}
}
return result.ToString();
}
/// <summary>
/// <c>StringTableMetaLanguage::EscapeString @ 0x0067BBC0</c>: the exact
/// inverse — every character with a <see cref="GetEscapedChar"/> mapping
/// is stored as <c>\</c> + that mapping; everything else verbatim.
/// <c>Unescape(Escape(x)) == x</c> for every <paramref name="value"/> —
/// the round-trip retail relies on for template variables.
/// </summary>
public static string Escape(string value)
{
ArgumentNullException.ThrowIfNull(value);
StringBuilder? result = null;
for (int i = 0; i < value.Length; i++)
{
char current = value[i];
char escaped = GetEscapedChar(current);
if (escaped != '\0')
{
result ??= new StringBuilder(value.Length + 4)
.Append(value, 0, i);
result.Append('\\').Append(escaped);
}
else if (current != '\0')
{
result?.Append(current);
}
}
return result?.ToString() ?? value;
}
}