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)