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:
parent
fdc4fd496d
commit
967b9c57cf
15 changed files with 605 additions and 116 deletions
103
tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs
Normal file
103
tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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)));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue