diff --git a/src/AcDream.App/UI/Layout/DatRichText.cs b/src/AcDream.App/UI/Layout/DatRichText.cs
index 60b10bc3..b1b8e333 100644
--- a/src/AcDream.App/UI/Layout/DatRichText.cs
+++ b/src/AcDream.App/UI/Layout/DatRichText.cs
@@ -19,17 +19,16 @@ namespace AcDream.App.UI.Layout;
///
/// The description pages used to bypass this entirely: they assigned a raw
/// LinesProvider lambda returning ONE unwrapped
-/// per composed string, with no escape-normalize and no word-wrap. Two
-/// concrete symptoms this caused: literal two-character "\n"
-/// escapes rendered as backslash-n instead of a real line break (the DAT
-/// stores that literal escape — DatWidgetFactory.BuildText's own
-/// authored-string path already normalizes it for single-element authored
-/// captions; this helper reproduces the SAME normalize for
-/// runtime-composed multi-segment text), and — for the Town page
-/// specifically — an unwrapped single line meant the town-specific SUFFIX
-/// of the composed string rendered far outside the box's clipped viewport,
-/// so switching towns looked like "the text never changes" even though the
-/// underlying string genuinely did (only its INVISIBLE tail differed).
+/// per composed string, with no word-wrap. Historical symptom (Batch C):
+/// for the Town page an unwrapped single line meant the town-specific
+/// SUFFIX of the composed string rendered far outside the box's clipped
+/// viewport, so switching towns looked like "the text never changes" even
+/// though the underlying string genuinely did (only its INVISIBLE tail
+/// differed). Escape decoding (the DAT's literal two-character "\n",
+/// Batch C's other symptom) has since moved to the string source
+/// ( → ,
+/// the 2026-08-17 systemic round) — segments reach this composer with real
+/// line breaks already in place.
///
///
internal static class DatRichText
@@ -41,7 +40,7 @@ internal static class DatRichText
public readonly record struct Segment(string? Text, Vector4 Color);
///
- /// Escape-normalizes and word-wraps every segment (independently, so
+ /// Word-wraps every segment (independently, so
/// each segment's wrapped lines keep ITS OWN color), then concatenates
/// the results in order. No separator is inserted between segments —
/// retail's own composition calls concatenate directly
@@ -76,15 +75,12 @@ internal static class DatRichText
if (string.IsNullOrEmpty(segment.Text))
continue;
- // The installed DAT stores the LITERAL two-character escape
- // "\n" (0x5C 0x6E), not a real line break — same normalize
- // DatWidgetFactory.BuildText's authored-string path already
- // applies for single-element authored captions.
- string normalized = segment.Text
- .Replace("\\n", "\n")
- .Replace("\r", string.Empty);
-
- foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth))
+ // Escape decoding (the DAT's literal two-character "\n") happens
+ // at the string source (DatStringResolver → RetailStringEscapes,
+ // 2026-08-17 systemic round — retail's own placement), so
+ // segments arrive with real line breaks; WrapWords preserves
+ // them and drops any stray CR itself.
+ foreach (string wrapped in UiText.WrapWords(segment.Text, measure, maximumWidth))
lines.Add(new UiText.Line(wrapped, segment.Color));
}
diff --git a/src/AcDream.App/UI/Layout/DatStringResolver.cs b/src/AcDream.App/UI/Layout/DatStringResolver.cs
index 4932c47a..70f3df7f 100644
--- a/src/AcDream.App/UI/Layout/DatStringResolver.cs
+++ b/src/AcDream.App/UI/Layout/DatStringResolver.cs
@@ -9,9 +9,23 @@ namespace AcDream.App.UI.Layout;
/// The caller owns synchronization around reads.
///
///
+///
/// Retail reference: StringInfo::GetString and
/// compute_str_hash @ 0x00413110. A StringInfo's token selects one
/// localized string variant; ordinary UI labels use token zero.
+///
+///
+/// Every resolution decodes the DAT's two-character escapes
+/// (\n, \t, \r, \q, and the metalanguage
+/// self-escapes) HERE, at the source — retail's own placement: every public
+/// StringInfo resolution ends in
+/// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0
+/// (StringInfo::InqString @ 0x0042E490,
+/// StringInfo::GetLiteralValue @ 0x0042CA50). Consumers receive
+/// already-decoded text and must not re-decode — see
+/// ' remarks for the double-decode hazard
+/// (the 2026-08-17 systemic round that retired the per-consumer copies).
+///
///
public sealed class DatStringResolver
{
@@ -41,7 +55,9 @@ public sealed class DatStringResolver
return null;
int index = token >= 0 && token < entry.Strings.Count ? token : 0;
- return entry.Strings[index].Value;
+ // StringInfo::InqString @ 0x0042E490's unconditional tail: the stored
+ // string is escaped; the resolved string is decoded.
+ return RetailStringEscapes.Unescape(entry.Strings[index].Value);
}
/// Returns every literal token for one retail StringInfo entry.
@@ -57,7 +73,9 @@ public sealed class DatStringResolver
return table is not null
&& table.Strings.TryGetValue(stringId, out var entry)
&& entry.Strings.Count != 0
- ? entry.Strings.Select(value => value.Value).ToArray()
+ ? entry.Strings
+ .Select(value => RetailStringEscapes.Unescape(value.Value))
+ .ToArray()
: null;
}
@@ -105,14 +123,21 @@ public sealed class DatStringResolver
{
composed.Append(entry.Strings[i].Value);
// Variables are stored as the pre-computed name hashes (the same
- // compute_str_hash space PlayerVariable lives in).
+ // compute_str_hash space PlayerVariable lives in). Each value is
+ // escaped on insert — retail's AddVariable_String @ 0x0042E6C0
+ // stores every variable through SetLiteralValue(escape=1)
+ // @ 0x0042C980 → EscapeString — so the final whole-string
+ // unescape below returns variable content verbatim while
+ // decoding the authored fragments' escapes.
if (i < entry.Variables.Count
&& variables.TryGetValue(entry.Variables[i], out string? value))
{
- composed.Append(value);
+ composed.Append(RetailStringEscapes.Escape(value));
}
}
- return composed.ToString();
+ // StringInfo::InqString @ 0x0042E490's unconditional tail, same as
+ // Resolve above: composed text decodes its escapes at the source.
+ return RetailStringEscapes.Unescape(composed.ToString());
}
///
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 29b71eeb..9713c159 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -856,9 +856,11 @@ public static class DatWidgetFactory
{
// 2026-08-13 social gate: authored strings can carry embedded
// newlines (the fellowship empty-state is three sentences over
- // '\n's). Gate round 2: the DAT stores the LITERAL two-character
- // escape "\n" (0x5C 0x6E — probe-verified: the dump printed
- // backslash-n, not a line break), so normalize the escape first.
+ // '\n's). The DAT stores those as the LITERAL two-character
+ // escape "\n" (0x5C 0x6E — probe-verified), decoded at the
+ // string SOURCE since the 2026-08-17 systemic round
+ // (DatStringResolver → RetailStringEscapes; retail's own
+ // placement) — `authored` arrives with REAL line breaks here.
// Gate round 3: retail additionally WORD-WRAPS each authored line
// within the element extent (its GlyphList draw — the same wrap
// the confirmation dialog view already uses), so a multiline
@@ -868,10 +870,7 @@ public static class DatWidgetFactory
// re-wrapping them is a client-wide behavior change no gate has
// asked for). Providers re-read DefaultColor/width/font per call
// (NOT captured eagerly) so state-driven changes keep tracking.
- string normalized = authored
- .Replace("\\n", "\n")
- .Replace("\r", string.Empty);
- if (normalized.Contains('\n'))
+ if (authored.Contains('\n'))
{
float cachedWidth = float.NaN;
UiDatFont? cachedFont = null;
@@ -897,7 +896,7 @@ public static class DatWidgetFactory
? font.MeasureWidth
: static value => value.Length * 8f;
cachedLines = [.. UiText
- .WrapWords(normalized, measure, maximumWidth)
+ .WrapWords(authored, measure, maximumWidth)
.Select(line => new UiText.Line(line, t.DefaultColor))];
}
return cachedLines;
@@ -906,7 +905,7 @@ public static class DatWidgetFactory
else
{
t.LinesProvider = () =>
- [new UiText.Line(normalized, t.DefaultColor)];
+ [new UiText.Line(authored, t.DefaultColor)];
}
}
@@ -921,7 +920,7 @@ public static class DatWidgetFactory
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|| stateCaption.Kind != UiPropertyKind.StringInfo)
continue;
- if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue))
+ if (stringResolve?.Invoke(stateCaption.StringInfoValue)
is { Length: > 0 } text)
(stateStrings ??= new Dictionary())[stateId] = text;
}
@@ -1220,6 +1219,20 @@ public static class DatWidgetFactory
.OrderBy(child => child.ReadOrder)
.ToArray();
+ ///
+ /// Resolves the effective authored caption (dat property 0x17)
+ /// for a widget. Escape decoding is NOT done here: since the 2026-08-17
+ /// systemic round the string SOURCE ( →
+ /// , retail's own placement — every
+ /// StringInfo resolution ends in
+ /// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0) hands
+ /// every consumer already-decoded text. That supersedes R2-2 (Campaign
+ /// CC gate round 1 Batch E)'s consumer-level normalize, which covered
+ /// only the P0x17 resolutions in THIS file and missed sibling consumers
+ /// (the exit-world confirmation dialog, gate round 2) — the exact class
+ /// of bug source placement closes. Re-decoding here would corrupt an
+ /// authored \\n (escaped backslash then 'n') into a line break.
+ ///
private static string? ResolveAuthoredString(
ElementInfo info,
Func? stringResolve)
@@ -1228,42 +1241,16 @@ public static class DatWidgetFactory
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|| property.Kind != UiPropertyKind.StringInfo)
return null;
- string? resolved = stringResolve(property.StringInfoValue);
- // R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL
- // two-character escape "\n" (0x5C 0x6E), not a real line break — same
- // fact BuildText's own authored-string path already normalized for
- // (see that call site's own comment). Centralizing the normalize
- // HERE, at the single choke point every P0x17 caption resolution in
- // this file goes through (BuildText, BuildButton's own caption AND
- // its lifted-child caption, BuildButton's coexisting ValueLabel,
- // BuildCheckbox), closes the exact class of bug R2-2 found: a caption
- // like the Profession credits button's own "Attribute\n Credits"
- // rendered the literal backslash-n because BuildButton never
- // normalized while BuildText did. BuildText's own subsequent
- // Replace("\\n","\n") is now a harmless no-op (idempotent) — left in
- // place rather than removed, since it costs nothing and documents the
- // same fact locally.
- return NormalizeEscapes(resolved);
+ return stringResolve(property.StringInfoValue);
}
- ///
- /// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize
- /// applies, pulled out so the
- /// per-STATE authored-caption loop below (which resolves a state's own
- /// 0x17 directly, bypassing the effective-property resolution
- /// wraps) gets the SAME normalize
- /// instead of a second, easily-forgotten copy.
- ///
- private static string? NormalizeEscapes(string? raw) =>
- raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
-
///
/// #409 (client-wide retail tooltip system): resolves the already-
/// extracted (dat property
- /// 0x49) through , applying the
- /// SAME escape normalization every other authored StringInfo
- /// (captions, 0x17) gets at this one choke point. Null when the
- /// element authors no tooltip text or no resolver is available.
+ /// 0x49) through . Arrives
+ /// escape-decoded from the string source, like every authored
+ /// StringInfo (see ). Null
+ /// when the element authors no tooltip text or no resolver is available.
///
internal static string? ResolveTooltipText(
ElementInfo info,
@@ -1271,6 +1258,6 @@ public static class DatWidgetFactory
{
if (stringResolve is null || info.TooltipText is not { } tooltipText)
return null;
- return NormalizeEscapes(stringResolve(tooltipText));
+ return stringResolve(tooltipText);
}
}
diff --git a/src/AcDream.App/UI/Layout/IndicatorDetailText.cs b/src/AcDream.App/UI/Layout/IndicatorDetailText.cs
index baf4b5b2..f47d0af8 100644
--- a/src/AcDream.App/UI/Layout/IndicatorDetailText.cs
+++ b/src/AcDream.App/UI/Layout/IndicatorDetailText.cs
@@ -23,8 +23,13 @@ internal static class IndicatorDetailText
?? value.Length * 8f;
var lines = new List();
- string normalized = text.Replace("\\n", "\n", StringComparison.Ordinal);
- foreach (string paragraph in normalized.Split('\n'))
+ // DAT-resolved bodies (vitae, link status, effects) arrive with
+ // real line breaks — escapes decode at the string source
+ // (DatStringResolver → RetailStringEscapes, 2026-08-17 systemic
+ // round). Wire-sourced text (the appraisal inscription) renders
+ // verbatim, exactly like retail's ItemExamineUI::AddItemInfo
+ // @ 0x004AC050 → UIElement_Text::AppendTextWithFont direct append.
+ foreach (string paragraph in text.Split('\n'))
{
if (paragraph.Length == 0)
{
diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs
index 5f062813..aa3094bc 100644
--- a/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs
+++ b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs
@@ -157,6 +157,14 @@ internal static class ItemAppraisalTextLayout
}
Vector4 color = ResolveColor(target, fragment.Style);
+ // WIRE-domain normalize — deliberately NOT the DAT source
+ // decode (DatStringResolver → RetailStringEscapes, 2026-08-17
+ // systemic round): appraisal fragments are server strings
+ // (long description, use text, inscription), which never pass
+ // the DAT string source, so this is not a duplicate path. It
+ // accommodates literal "\n" sequences in ACE's database
+ // strings; server strings with REAL line breaks flow through
+ // the Split below either way.
string normalized = fragment.Text.Replace(
"\\n",
"\n",
diff --git a/src/AcDream.App/UI/Layout/RetailStringEscapes.cs b/src/AcDream.App/UI/Layout/RetailStringEscapes.cs
new file mode 100644
index 00000000..1a239f9d
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/RetailStringEscapes.cs
@@ -0,0 +1,140 @@
+using System.Text;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Exact port of retail's string-table escape codec
+/// (StringTableMetaLanguage::UnescapeString @ 0x0067BDC0 /
+/// EscapeString @ 0x0067BBC0 and their character tables
+/// GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0).
+///
+///
+///
+/// PLACEMENT (the systemic 2026-08-17 normalization round): retail decodes
+/// escapes at the string SOURCE, not per-widget. Every public
+/// StringInfo resolution runs the unescape unconditionally before any
+/// consumer sees the text — StringInfo::InqString @ 0x0042E490 tail
+/// and StringInfo::GetLiteralValue @ 0x0042CA50 both end in
+/// UnescapeString. The write side is the inverse:
+/// StringInfo::SetLiteralValue @ 0x0042C980 runs EscapeString
+/// when storing plain text (and StringInfo::AddVariable_String
+/// @ 0x0042E6C0 always stores variables that way), so stored text is
+/// escaped, resolved text is decoded, and variable content round-trips
+/// verbatim. acdream's equivalent source is ;
+/// widgets and controllers receive already-decoded strings and must not
+/// re-decode (a second pass corrupts an authored \\n — escaped
+/// backslash then 'n' — into a line break).
+///
+///
+/// The escape set (byte-verified against the PDB-paired 2013 binary; the
+/// metalanguage character-set literal at file offset 0x3FE178 is the ten
+/// characters []!{}#\|^$):
+/// \n → LF (0x0A), \t → TAB (0x09), \r → CR (0x0D),
+/// \q → '"' (0x22), and a backslash before any of the ten
+/// metalanguage characters yields that character itself. A backslash before
+/// anything else is NOT an escape — retail copies it through verbatim
+/// (GetUnEscapedChar returns 0 and UnescapeString's
+/// else-branch keeps the current character).
+///
+///
+public static class RetailStringEscapes
+{
+ /// The ten metalanguage-significant characters that escape to
+ /// themselves. Byte-decoded from the retail binary (see class remarks) —
+ /// the same literal both character tables test with wcschr.
+ private const string MetaCharacters = "[]!{}#\\|^$";
+
+ ///
+ /// StringTableMetaLanguage::GetUnEscapedChar @ 0x0067B750: the
+ /// character an escape pair \+ decodes
+ /// to, or '\0' when the pair is not an escape.
+ ///
+ internal static char GetUnEscapedChar(char value) => value switch
+ {
+ 'n' => '\n',
+ 'q' => '"',
+ 'r' => '\r',
+ 't' => '\t',
+ not '\0' when MetaCharacters.Contains(value) => value,
+ _ => '\0',
+ };
+
+ ///
+ /// StringTableMetaLanguage::GetEscapedChar @ 0x0067B6C0: the
+ /// character that follows the backslash when
+ /// must be stored escaped, or '\0' when it is stored verbatim.
+ ///
+ internal static char GetEscapedChar(char value) => value switch
+ {
+ '\t' => 't',
+ '\n' => 'n',
+ '\r' => 'r',
+ '"' => 'q',
+ not '\0' when MetaCharacters.Contains(value) => value,
+ _ => '\0',
+ };
+
+ ///
+ /// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0: decodes
+ /// every two-character escape pair; all other characters (including a
+ /// backslash that does not start a recognized pair, and a trailing
+ /// backslash) copy through verbatim.
+ ///
+ public static string Unescape(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ // Fast path: a string with no backslash cannot contain an escape.
+ int first = value.IndexOf('\\');
+ if (first < 0)
+ return value;
+
+ var result = new StringBuilder(value.Length);
+ for (int i = 0; i < value.Length; i++)
+ {
+ char current = value[i];
+ // Retail reads the character AFTER the candidate backslash (the
+ // terminator — never an escape — when at the end of the buffer).
+ char next = i + 1 < value.Length ? value[i + 1] : '\0';
+ char unescaped = GetUnEscapedChar(next);
+ if (current == '\\' && unescaped != '\0')
+ {
+ result.Append(unescaped);
+ i++; // consume the pair
+ }
+ else if (current != '\0')
+ {
+ result.Append(current);
+ }
+ }
+ return result.ToString();
+ }
+
+ ///
+ /// StringTableMetaLanguage::EscapeString @ 0x0067BBC0: the exact
+ /// inverse — every character with a mapping
+ /// is stored as \ + that mapping; everything else verbatim.
+ /// Unescape(Escape(x)) == x for every —
+ /// the round-trip retail relies on for template variables.
+ ///
+ public static string Escape(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ StringBuilder? result = null;
+ for (int i = 0; i < value.Length; i++)
+ {
+ char current = value[i];
+ char escaped = GetEscapedChar(current);
+ if (escaped != '\0')
+ {
+ result ??= new StringBuilder(value.Length + 4)
+ .Append(value, 0, i);
+ result.Append('\\').Append(escaped);
+ }
+ else if (current != '\0')
+ {
+ result?.Append(current);
+ }
+ }
+ return result?.ToString() ?? value;
+ }
+}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index dd52aada..99390638 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -2973,10 +2973,10 @@ public sealed class RetailUiRuntime : IDisposable
});
if (text is null) return 0u; // no invented English
// The authored text stores its blank line as a literal
- // "\n\n" two-character escape (live-probed) — same
- // convention DatWidgetFactory/IndicatorDetailText already
- // unescape for other DAT-authored strings.
- text = text.Replace("\\n", "\n", StringComparison.Ordinal);
+ // "\n\n" two-character escape (live-probed), decoded at
+ // the string source (DatStringResolver →
+ // RetailStringEscapes, 2026-08-17 systemic round) —
+ // `text` arrives with real line breaks.
try
{
return DialogFactory.MakeWait(text, queueKey: 0x10000001u);
@@ -4331,13 +4331,13 @@ public sealed class RetailUiRuntime : IDisposable
{
lock (_bindings.Assets.DatLock)
{
- return NormalizeRetailNewlines(strings.ResolveTemplate(
- stringTableId,
- "ID_CharacterManagement_DeleteCharacterConfirmation",
- new Dictionary
- {
- [DatStringResolver.PlayerVariable] = characterName,
- })!);
+ return strings.ResolveTemplate(
+ stringTableId,
+ "ID_CharacterManagement_DeleteCharacterConfirmation",
+ new Dictionary
+ {
+ [DatStringResolver.PlayerVariable] = characterName,
+ })!;
}
}
@@ -4353,16 +4353,16 @@ public sealed class RetailUiRuntime : IDisposable
confirmExit));
}
+ // Escape decoding (the DAT's literal two-character "\n" and friends)
+ // happens at the string source since the 2026-08-17 systemic round —
+ // DatStringResolver → RetailStringEscapes, retail's own placement — so
+ // this is a plain key-hash resolve. The former NormalizeRetailNewlines
+ // consumer copy is retired (double-decoding corrupts an authored "\\n").
private static string? ResolveCharacterManagementString(
DatStringResolver strings,
uint tableId,
string key) =>
- strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value
- ? NormalizeRetailNewlines(value)
- : null;
-
- private static string NormalizeRetailNewlines(string value) =>
- value.Replace("\\n", "\n", StringComparison.Ordinal);
+ strings.Resolve(tableId, DatStringResolver.ComputeHash(key));
private void ConfigureCharacterCreation()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
index 30de665e..859ed34d 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
@@ -1904,18 +1904,21 @@ public sealed class CharacterCreationUiControllerTests
///
/// GF-2: the composed description routes through the shared rich-text
/// helper — header segments (palette index 1) render in a DIFFERENT
- /// color than body segments (index 0), and each segment's own escape
- /// sequence is normalized. The fixture's description element carries
- /// no authored FontColorPalette, so this also exercises
- /// 's fallback (green header /
- /// white body).
+ /// color than body segments (index 0), and an authored line break
+ /// splits into stacked lines. The harness resolver models
+ /// DatStringResolver's post-decode output (the DAT's literal
+ /// "\n" escape decodes AT THE SOURCE since the 2026-08-17 systemic
+ /// round), so the fixture feeds a REAL '\n'. The fixture's description
+ /// element carries no authored FontColorPalette, so this also
+ /// exercises 's fallback (green
+ /// header / white body).
///
[Fact]
public void HeritageDescription_ComposesGreenHeaderAndWhiteBodySegments()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills_Header"] = "Trained Starting Skills:";
- environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\\nLine two";
+ environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\nLine two";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
BumpRevisionAndTick(environment);
@@ -1924,7 +1927,7 @@ public sealed class CharacterCreationUiControllerTests
var lines = description.LinesProvider().ToList();
Assert.Contains(lines, l => l.Text == "Trained Starting Skills:" && l.Color == new Vector4(0f, 1f, 0f, 1f));
- // The literal "\n" escape in the body segment must become TWO
+ // The source-decoded line break in the body segment must become TWO
// separate lines, not render as a literal backslash-n.
Assert.Contains(lines, l => l.Text == "Line one" && l.Color == Vector4.One);
Assert.Contains(lines, l => l.Text == "Line two" && l.Color == Vector4.One);
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs
index 6c01c054..5b685043 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs
@@ -138,10 +138,12 @@ public sealed class CharacterManagementLiveDatTests
// Finding 1: MakeConfirmExitDialog@0x004ed250's text
// (compute_str_hash("ID_CharacterManagement_ConfirmExit"), table
// enum 0x10000002 -> 0x23000002). The raw DAT string carries a
- // literal two-character "\n" escape (this test's Resolve() helper
- // does not normalize it — RetailUiRuntime does, via
- // NormalizeRetailNewlines, before handing it to the controller).
- Assert.Equal("Are you sure you want to leave?\\n", Resolve(strings, table,
+ // literal two-character "\n" escape; since the 2026-08-17 systemic
+ // round DatStringResolver decodes it AT THE SOURCE (retail's own
+ // placement — StringInfo::InqString @ 0x0042E490's UnescapeString
+ // tail), so Resolve returns a REAL line break and no consumer
+ // normalizes again.
+ Assert.Equal("Are you sure you want to leave?\n", Resolve(strings, table,
"ID_CharacterManagement_ConfirmExit"));
string confirmation = Assert.IsType(strings.ResolveTemplate(
table,
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs
index 2ae0193e..bb7dc226 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs
@@ -5,8 +5,10 @@ namespace AcDream.App.Tests.UI.Layout;
///
/// Campaign CC gate round 1 Batch C: unit tests for the shared
-/// escape-normalize + word-wrap + per-segment-color helper feeding
-/// GF-2/GF-3/GF-11a and the Summary how-to text (Commit 3).
+/// word-wrap + per-segment-color helper feeding GF-2/GF-3/GF-11a and the
+/// Summary how-to text (Commit 3). Escape decoding moved to the string
+/// source in the 2026-08-17 systemic round (DatStringResolver →
+/// RetailStringEscapes) — segments reach Compose with real line breaks.
///
public class DatRichTextTests
{
@@ -16,17 +18,24 @@ public class DatRichTextTests
private static UiText MakeTarget(float width) =>
new() { Width = width, Height = 200f };
+ /// Segments arrive source-decoded (real '\n'); Compose keeps
+ /// the authored break as a line split. A literal backslash-n pair in a
+ /// segment must stay VERBATIM — re-decoding here is the double-decode
+ /// hazard the 2026-08-17 round retired.
[Fact]
- public void Compose_NormalizesLiteralBackslashNEscape()
+ public void Compose_SplitsOnRealNewlines_AndKeepsLiteralPairsVerbatim()
{
UiText target = MakeTarget(1000f); // wide enough that nothing wraps
- var segments = new[] { new DatRichText.Segment("line one\\nline two", White) };
+ var segments = new[]
+ {
+ new DatRichText.Segment("line one\nliteral \\n stays", White),
+ };
var lines = DatRichText.Compose(target, segments);
Assert.Equal(2, lines.Count);
Assert.Equal("line one", lines[0].Text);
- Assert.Equal("line two", lines[1].Text);
+ Assert.Equal("literal \\n stays", lines[1].Text);
}
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs
new file mode 100644
index 00000000..915c39e1
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs
@@ -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;
+
+///
+/// 2026-08-17 systemic escape round: installed-DAT sweep of EVERY string
+/// table for literal escape content, plus the source-normalization contract
+/// ( resolutions must equal
+/// of the raw stored text —
+/// retail's StringInfo::InqString @ 0x0042E490 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.
+///
+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();
+ var examples = new List();
+ string? userReportedExitText = null;
+
+ foreach (uint tableId in dats.GetAllIdsOfType().Order())
+ {
+ StringTable? table = dats.Get(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", "").Replace("\n", "");
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs
index 9aefda93..f590f962 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs
@@ -85,6 +85,72 @@ public sealed class DatStringResolverTemplateTests
new Dictionary()));
}
+ ///
+ /// The 2026-08-17 systemic escape round: resolution decodes the DAT's
+ /// literal two-character escapes AT THE SOURCE — retail's own placement
+ /// (StringInfo::InqString @ 0x0042E490's unconditional
+ /// UnescapeString tail). Consumers receive real line breaks; no
+ /// per-consumer normalize remains.
+ ///
+ [Fact]
+ public void ResolveDecodesEscapesAtTheSource()
+ {
+ var resolver = MakeResolver(
+ "ID_Confirm_Exit",
+ fragments: [
+ "This will exit your character from the game world.\\n\\nAre you sure?",
+ ],
+ variables: []);
+
+ Assert.Equal(
+ "This will exit your character from the game world.\n\nAre you sure?",
+ resolver.Resolve(
+ TableId, DatStringResolver.ComputeHash("ID_Confirm_Exit")));
+ }
+
+ [Fact]
+ public void ResolveAllDecodesEveryVariant()
+ {
+ var resolver = MakeResolver(
+ "ID_Variants",
+ fragments: ["one\\nline", "two\\tcol"],
+ variables: []);
+
+ Assert.Equal(
+ ["one\nline", "two\tcol"],
+ resolver.ResolveAll(
+ TableId, DatStringResolver.ComputeHash("ID_Variants")));
+ }
+
+ ///
+ /// Template composition decodes the authored fragments' escapes while
+ /// variable content round-trips VERBATIM — retail escapes each variable
+ /// on insert (AddVariable_String @ 0x0042E6C0 →
+ /// SetLiteralValue(escape=1) @ 0x0042C980) and unescapes the
+ /// composed whole once, so a player name containing escape-significant
+ /// characters can never be corrupted by the final decode.
+ ///
+ [Fact]
+ public void ResolveTemplateDecodesFragmentsAndKeepsVariablesVerbatim()
+ {
+ var resolver = MakeResolver(
+ "ID_Delete_Confirmation",
+ fragments: ["Delete ", "?\\nType 'DELETE' to confirm."],
+ variables: [DatStringResolver.PlayerVariable]);
+
+ Assert.Equal(
+ "Delete Odd\\nName?\nType 'DELETE' to confirm.",
+ resolver.ResolveTemplate(
+ TableId,
+ "ID_Delete_Confirmation",
+ new Dictionary
+ {
+ // A pathological name carrying a REAL backslash before
+ // an 'n' — must come out verbatim, not as a line break.
+ [DatStringResolver.PlayerVariable] = "Odd\\nName",
+ }));
+ }
+
[Fact]
public void UnknownKeyResolvesNull()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index e218d444..1ca6c0c2 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -1144,8 +1144,10 @@ public class DatWidgetFactoryTests
}
///
- /// 2026-08-13 social gate round 3: a MULTILINE authored string (literal
- /// backslash-n escapes in the DAT) word-wraps each authored line to the
+ /// 2026-08-13 social gate round 3: a MULTILINE authored string (a real
+ /// '\n' — the DAT's literal backslash-n escape decodes at the string
+ /// source since the 2026-08-17 systemic round, so the resolver seam
+ /// hands this factory decoded text) word-wraps each authored line to the
/// widget's live width — retail's GlyphList draw, the same wrap the
/// confirmation dialog view uses. The fellowship empty-state was
/// rendering its three authored lines as three clipped runs.
@@ -1165,7 +1167,7 @@ public class DatWidgetFactoryTests
// Width=100 fits 12 characters per wrapped line.
var text = Assert.IsType(DatWidgetFactory.Create(
info, NoTex, null,
- stringResolve: _ => "one two three four five\\nsix"));
+ stringResolve: _ => "one two three four five\nsix"));
var lines = text.LinesProvider!();
Assert.True(lines.Count >= 3);
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs
new file mode 100644
index 00000000..c30387ac
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs
@@ -0,0 +1,103 @@
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Conformance for — the exact port of
+/// retail's string-table escape codec
+/// (StringTableMetaLanguage::UnescapeString @ 0x0067BDC0 /
+/// EscapeString @ 0x0067BBC0, character tables
+/// GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0).
+/// The metalanguage character set is byte-verified against the PDB-paired
+/// 2013 binary (file offset 0x3FE178: []!{}#\|^$).
+///
+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));
+
+ ///
+ /// 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.
+ ///
+ [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));
+
+ ///
+ /// 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.
+ ///
+ [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));
+
+ /// No backslash → no allocation: the same instance returns.
+ [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));
+
+ ///
+ /// Retail's template-variable round trip
+ /// (AddVariable_String @ 0x0042E6C0 escapes on insert;
+ /// InqString @ 0x0042E490 unescapes the composed whole): variable
+ /// content must come out verbatim.
+ ///
+ [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)));
+}
diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
index 5ecb0cd1..48172af5 100644
--- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
@@ -553,16 +553,20 @@ public class UiButtonTests
Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button");
}
- // ── R2-2 escape-normalize ────────────────────────────────────────────
+ // ── R2-2 authored caption (source-decoded) ───────────────────────────
///
- /// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way
- /// BuildText's authored-string path always has — the DAT stores the
- /// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession
- /// credits button's own authored caption is exactly this shape.
+ /// R2-2's successor contract (2026-08-17 systemic round): the DAT's
+ /// LITERAL two-character escape "\n" (0x5C 0x6E — the Profession
+ /// credits button's own authored caption is exactly this shape) decodes
+ /// at the string SOURCE (DatStringResolver → RetailStringEscapes,
+ /// retail's own placement), so the resolver seam hands BuildButton a
+ /// caption with a REAL line break — and the factory passes it through
+ /// verbatim, with no second decode that would corrupt an authored
+ /// backslash pair.
///
[Fact]
- public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape()
+ public void BuildButton_OwnCaption_PassesSourceDecodedTextThrough()
{
uint stringId = 444u;
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
@@ -575,11 +579,14 @@ public class UiButtonTests
var button = Assert.IsType(DatWidgetFactory.Create(
info, NoTex, null,
- // The raw resolved string carries the LITERAL two characters
- // '\' and 'n', matching what the installed DAT actually stores.
- stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null));
+ // The resolver seam models DatStringResolver's post-decode
+ // output: a REAL '\n', plus a literal backslash pair that a
+ // stray second decode would corrupt into a line break.
+ stringResolve: value => value.StringId == stringId
+ ? "Attribute\n Credits \\not-an-escape"
+ : null));
- Assert.Equal("Attribute\n Credits", button.Label);
+ Assert.Equal("Attribute\n Credits \\not-an-escape", button.Label);
}
private static UiButton ButtonWithStates(params string[] states)