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
136
tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs
Normal file
136
tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System.IO;
|
||||
using System.Text;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter.Options;
|
||||
using StringTable = DatReaderWriter.DBObjs.StringTable;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-17 systemic escape round: installed-DAT sweep of EVERY string
|
||||
/// table for literal escape content, plus the source-normalization contract
|
||||
/// (<see cref="DatStringResolver"/> resolutions must equal
|
||||
/// <see cref="RetailStringEscapes.Unescape"/> of the raw stored text —
|
||||
/// retail's <c>StringInfo::InqString @ 0x0042E490</c> placement). This is
|
||||
/// the measurement companion to the per-consumer normalize retirement: it
|
||||
/// proves the escape class genuinely exists in shipping data and prints
|
||||
/// which tables carry it.
|
||||
/// </summary>
|
||||
public sealed class DatStringEscapeSweepTests
|
||||
{
|
||||
[InstalledDatFact]
|
||||
public void EveryInstalledStringResolvesSourceDecoded()
|
||||
{
|
||||
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
|
||||
var resolver = new DatStringResolver(dats);
|
||||
|
||||
int tables = 0;
|
||||
int strings = 0;
|
||||
int withNewlineEscape = 0;
|
||||
int withTabEscape = 0;
|
||||
int withCrEscape = 0;
|
||||
int withQuoteEscape = 0;
|
||||
int withMetaSelfEscape = 0;
|
||||
int withUnknownPair = 0;
|
||||
int withRealCr = 0;
|
||||
var perTableNewlines = new SortedDictionary<uint, int>();
|
||||
var examples = new List<string>();
|
||||
string? userReportedExitText = null;
|
||||
|
||||
foreach (uint tableId in dats.GetAllIdsOfType<StringTable>().Order())
|
||||
{
|
||||
StringTable? table = dats.Get<StringTable>(tableId);
|
||||
if (table is null)
|
||||
continue;
|
||||
tables++;
|
||||
|
||||
foreach ((uint stringId, var entry) in table.Strings)
|
||||
{
|
||||
for (int token = 0; token < entry.Strings.Count; token++)
|
||||
{
|
||||
string raw = entry.Strings[token].Value;
|
||||
strings++;
|
||||
|
||||
bool newline = false, unknown = false, meta = false;
|
||||
for (int i = 0; i < raw.Length - 1; i++)
|
||||
{
|
||||
if (raw[i] != '\\')
|
||||
continue;
|
||||
char next = raw[i + 1];
|
||||
char decoded = RetailStringEscapes.GetUnEscapedChar(next);
|
||||
switch (decoded)
|
||||
{
|
||||
case '\n': newline = true; break;
|
||||
case '\t': withTabEscape++; break;
|
||||
case '\r': withCrEscape++; break;
|
||||
case '"': withQuoteEscape++; break;
|
||||
case '\0': unknown = true; break;
|
||||
default: meta = true; break;
|
||||
}
|
||||
i++; // the pair is consumed either way it decodes
|
||||
}
|
||||
if (newline)
|
||||
{
|
||||
withNewlineEscape++;
|
||||
perTableNewlines[tableId] =
|
||||
perTableNewlines.GetValueOrDefault(tableId) + 1;
|
||||
if (examples.Count < 12)
|
||||
examples.Add(
|
||||
$"0x{tableId:X8}/0x{stringId:X8}: \"{Truncate(raw)}\"");
|
||||
}
|
||||
if (meta) withMetaSelfEscape++;
|
||||
if (unknown) withUnknownPair++;
|
||||
if (raw.Contains('\r')) withRealCr++;
|
||||
|
||||
// The source contract: what consumers receive from the
|
||||
// resolver is EXACTLY the retail unescape of the stored
|
||||
// text — nothing more (no consumer re-decode is owed),
|
||||
// nothing less (no escape survives to render literally).
|
||||
Assert.Equal(
|
||||
RetailStringEscapes.Unescape(raw),
|
||||
resolver.Resolve(tableId, stringId, token));
|
||||
|
||||
if (raw.Contains("exit your character", StringComparison.OrdinalIgnoreCase))
|
||||
userReportedExitText =
|
||||
$"0x{tableId:X8}/0x{stringId:X8}: \"{raw}\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var summary = new StringBuilder()
|
||||
.AppendLine("[escape-sweep] installed-DAT string-table inventory:")
|
||||
.AppendLine($" tables={tables} strings={strings}")
|
||||
.AppendLine($" strings with literal \\n escape: {withNewlineEscape}")
|
||||
.AppendLine($" \\t pairs: {withTabEscape}; \\r pairs: {withCrEscape}; \\q pairs: {withQuoteEscape}")
|
||||
.AppendLine($" strings with metalanguage self-escapes: {withMetaSelfEscape}")
|
||||
.AppendLine($" strings with unrecognized backslash pairs (kept verbatim): {withUnknownPair}")
|
||||
.AppendLine($" strings containing a REAL CR character: {withRealCr}")
|
||||
.AppendLine(" \\n-escape counts per table: "
|
||||
+ string.Join(", ", perTableNewlines.Select(
|
||||
static pair => $"0x{pair.Key:X8}={pair.Value}")))
|
||||
.AppendLine(" examples:");
|
||||
foreach (string example in examples)
|
||||
summary.AppendLine($" {example}");
|
||||
summary.AppendLine(userReportedExitText is null
|
||||
? " user-reported exit-world text: NOT found by content scan"
|
||||
: $" user-reported exit-world text: {userReportedExitText}");
|
||||
Console.WriteLine(summary.ToString());
|
||||
|
||||
// The escape class must genuinely exist in shipping data — if this
|
||||
// ever goes to zero the sweep (and the source decode) is measuring
|
||||
// nothing and needs re-examination, not silent success.
|
||||
Assert.True(
|
||||
withNewlineEscape > 0,
|
||||
"expected at least one installed string carrying the literal \\n escape");
|
||||
}
|
||||
|
||||
private static string Truncate(string value) =>
|
||||
(value.Length <= 90 ? value : value[..90] + "…")
|
||||
.Replace("\r", "<CR>").Replace("\n", "<LF>");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue