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>
140 lines
5.6 KiB
C#
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;
|
|
}
|
|
}
|