acdream/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.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

136 lines
6.1 KiB
C#

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>");
}