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>
136 lines
6.1 KiB
C#
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>");
|
|
}
|