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>
103 lines
4 KiB
C#
103 lines
4 KiB
C#
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Conformance for <see cref="RetailStringEscapes"/> — the exact port of
|
|
/// retail's string-table escape codec
|
|
/// (<c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c> /
|
|
/// <c>EscapeString @ 0x0067BBC0</c>, character tables
|
|
/// <c>GetUnEscapedChar @ 0x0067B750</c> / <c>GetEscapedChar @ 0x0067B6C0</c>).
|
|
/// The metalanguage character set is byte-verified against the PDB-paired
|
|
/// 2013 binary (file offset 0x3FE178: <c>[]!{}#\|^$</c>).
|
|
/// </summary>
|
|
public sealed class RetailStringEscapesTests
|
|
{
|
|
[Theory]
|
|
[InlineData("line one\\nline two", "line one\nline two")]
|
|
[InlineData("a\\tb", "a\tb")]
|
|
[InlineData("a\\rb", "a\rb")]
|
|
[InlineData("say \\qhi\\q", "say \"hi\"")]
|
|
public void Unescape_DecodesTheFourCharacterEscapes(
|
|
string raw, string expected)
|
|
=> Assert.Equal(expected, RetailStringEscapes.Unescape(raw));
|
|
|
|
[Theory]
|
|
[InlineData("\\[", "[")]
|
|
[InlineData("\\]", "]")]
|
|
[InlineData("\\!", "!")]
|
|
[InlineData("\\{", "{")]
|
|
[InlineData("\\}", "}")]
|
|
[InlineData("\\#", "#")]
|
|
[InlineData("\\\\", "\\")]
|
|
[InlineData("\\|", "|")]
|
|
[InlineData("\\^", "^")]
|
|
[InlineData("\\$", "$")]
|
|
public void Unescape_DecodesEveryMetalanguageSelfEscape(
|
|
string raw, string expected)
|
|
=> Assert.Equal(expected, RetailStringEscapes.Unescape(raw));
|
|
|
|
/// <summary>
|
|
/// GetUnEscapedChar returns 0 for anything else — retail keeps the
|
|
/// backslash verbatim (UnescapeString's else-branch), including a
|
|
/// trailing backslash whose "next" character is the terminator.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("\\z", "\\z")]
|
|
[InlineData("C:\\path\\dir", "C:\\path\\dir")]
|
|
[InlineData("ends with \\", "ends with \\")]
|
|
[InlineData("\\N upper is not an escape", "\\N upper is not an escape")]
|
|
public void Unescape_KeepsUnrecognizedPairsVerbatim(
|
|
string raw, string expected)
|
|
=> Assert.Equal(expected, RetailStringEscapes.Unescape(raw));
|
|
|
|
/// <summary>
|
|
/// The double-decode hazard the 2026-08-17 systemic round exists to
|
|
/// close: an authored escaped backslash before an 'n' decodes ONCE to
|
|
/// the literal two characters backslash+n — a second decode pass (the
|
|
/// retired per-consumer copies) would corrupt it into a line break.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Unescape_EscapedBackslashBeforeN_YieldsLiteralPair()
|
|
=> Assert.Equal("\\n", RetailStringEscapes.Unescape("\\\\n"));
|
|
|
|
[Fact]
|
|
public void Unescape_EmptyString_IsEmpty()
|
|
=> Assert.Equal(string.Empty, RetailStringEscapes.Unescape(string.Empty));
|
|
|
|
/// <summary>No backslash → no allocation: the same instance returns.</summary>
|
|
[Fact]
|
|
public void Unescape_NoEscapes_ReturnsTheSameInstance()
|
|
{
|
|
const string plain = "Please Wait";
|
|
Assert.Same(plain, RetailStringEscapes.Unescape(plain));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("line one\nline two", "line one\\nline two")]
|
|
[InlineData("a\tb", "a\\tb")]
|
|
[InlineData("a\rb", "a\\rb")]
|
|
[InlineData("say \"hi\"", "say \\qhi\\q")]
|
|
[InlineData("[x]", "\\[x\\]")]
|
|
[InlineData("back\\slash", "back\\\\slash")]
|
|
[InlineData("plain", "plain")]
|
|
public void Escape_IsTheStorageInverse(string plain, string expected)
|
|
=> Assert.Equal(expected, RetailStringEscapes.Escape(plain));
|
|
|
|
/// <summary>
|
|
/// Retail's template-variable round trip
|
|
/// (<c>AddVariable_String @ 0x0042E6C0</c> escapes on insert;
|
|
/// <c>InqString @ 0x0042E490</c> unescapes the composed whole): variable
|
|
/// content must come out verbatim.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("plain name")]
|
|
[InlineData("Odd\\Name")]
|
|
[InlineData("multi\nline")]
|
|
[InlineData("tabs\tand \"quotes\"")]
|
|
[InlineData("[]!{}#\\|^$")]
|
|
[InlineData("")]
|
|
public void UnescapeOfEscape_RoundTripsVerbatim(string value)
|
|
=> Assert.Equal(value, RetailStringEscapes.Unescape(
|
|
RetailStringEscapes.Escape(value)));
|
|
}
|