Merge campaign-newline-fix: retail source-level escape normalization
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
70f7f72d62
16 changed files with 610 additions and 117 deletions
|
|
@ -1904,18 +1904,21 @@ public sealed class CharacterCreationUiControllerTests
|
|||
/// <summary>
|
||||
/// 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 <c>FontColorPalette</c>, so this also exercises
|
||||
/// <see cref="DatRichText.PaletteColor"/>'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
|
||||
/// <c>DatStringResolver</c>'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 <c>FontColorPalette</c>, so this also
|
||||
/// exercises <see cref="DatRichText.PaletteColor"/>'s fallback (green
|
||||
/// header / white body).
|
||||
/// </summary>
|
||||
[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);
|
||||
|
|
|
|||
|
|
@ -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<string>(strings.ResolveTemplate(
|
||||
table,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class DatRichTextTests
|
||||
{
|
||||
|
|
@ -16,17 +18,24 @@ public class DatRichTextTests
|
|||
private static UiText MakeTarget(float width) =>
|
||||
new() { Width = width, Height = 200f };
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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]
|
||||
|
|
|
|||
136
tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs
Normal file
136
tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System.IO;
|
||||
using System.Text;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter.Options;
|
||||
using StringTable = DatReaderWriter.DBObjs.StringTable;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-17 systemic escape round: installed-DAT sweep of EVERY string
|
||||
/// table for literal escape content, plus the source-normalization contract
|
||||
/// (<see cref="DatStringResolver"/> resolutions must equal
|
||||
/// <see cref="RetailStringEscapes.Unescape"/> of the raw stored text —
|
||||
/// retail's <c>StringInfo::InqString @ 0x0042E490</c> placement). This is
|
||||
/// the measurement companion to the per-consumer normalize retirement: it
|
||||
/// proves the escape class genuinely exists in shipping data and prints
|
||||
/// which tables carry it.
|
||||
/// </summary>
|
||||
public sealed class DatStringEscapeSweepTests
|
||||
{
|
||||
[InstalledDatFact]
|
||||
public void EveryInstalledStringResolvesSourceDecoded()
|
||||
{
|
||||
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
|
||||
var resolver = new DatStringResolver(dats);
|
||||
|
||||
int tables = 0;
|
||||
int strings = 0;
|
||||
int withNewlineEscape = 0;
|
||||
int withTabEscape = 0;
|
||||
int withCrEscape = 0;
|
||||
int withQuoteEscape = 0;
|
||||
int withMetaSelfEscape = 0;
|
||||
int withUnknownPair = 0;
|
||||
int withRealCr = 0;
|
||||
var perTableNewlines = new SortedDictionary<uint, int>();
|
||||
var examples = new List<string>();
|
||||
string? userReportedExitText = null;
|
||||
|
||||
foreach (uint tableId in dats.GetAllIdsOfType<StringTable>().Order())
|
||||
{
|
||||
StringTable? table = dats.Get<StringTable>(tableId);
|
||||
if (table is null)
|
||||
continue;
|
||||
tables++;
|
||||
|
||||
foreach ((uint stringId, var entry) in table.Strings)
|
||||
{
|
||||
for (int token = 0; token < entry.Strings.Count; token++)
|
||||
{
|
||||
string raw = entry.Strings[token].Value;
|
||||
strings++;
|
||||
|
||||
bool newline = false, unknown = false, meta = false;
|
||||
for (int i = 0; i < raw.Length - 1; i++)
|
||||
{
|
||||
if (raw[i] != '\\')
|
||||
continue;
|
||||
char next = raw[i + 1];
|
||||
char decoded = RetailStringEscapes.GetUnEscapedChar(next);
|
||||
switch (decoded)
|
||||
{
|
||||
case '\n': newline = true; break;
|
||||
case '\t': withTabEscape++; break;
|
||||
case '\r': withCrEscape++; break;
|
||||
case '"': withQuoteEscape++; break;
|
||||
case '\0': unknown = true; break;
|
||||
default: meta = true; break;
|
||||
}
|
||||
i++; // the pair is consumed either way it decodes
|
||||
}
|
||||
if (newline)
|
||||
{
|
||||
withNewlineEscape++;
|
||||
perTableNewlines[tableId] =
|
||||
perTableNewlines.GetValueOrDefault(tableId) + 1;
|
||||
if (examples.Count < 12)
|
||||
examples.Add(
|
||||
$"0x{tableId:X8}/0x{stringId:X8}: \"{Truncate(raw)}\"");
|
||||
}
|
||||
if (meta) withMetaSelfEscape++;
|
||||
if (unknown) withUnknownPair++;
|
||||
if (raw.Contains('\r')) withRealCr++;
|
||||
|
||||
// The source contract: what consumers receive from the
|
||||
// resolver is EXACTLY the retail unescape of the stored
|
||||
// text — nothing more (no consumer re-decode is owed),
|
||||
// nothing less (no escape survives to render literally).
|
||||
Assert.Equal(
|
||||
RetailStringEscapes.Unescape(raw),
|
||||
resolver.Resolve(tableId, stringId, token));
|
||||
|
||||
if (raw.Contains("exit your character", StringComparison.OrdinalIgnoreCase))
|
||||
userReportedExitText =
|
||||
$"0x{tableId:X8}/0x{stringId:X8}: \"{raw}\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var summary = new StringBuilder()
|
||||
.AppendLine("[escape-sweep] installed-DAT string-table inventory:")
|
||||
.AppendLine($" tables={tables} strings={strings}")
|
||||
.AppendLine($" strings with literal \\n escape: {withNewlineEscape}")
|
||||
.AppendLine($" \\t pairs: {withTabEscape}; \\r pairs: {withCrEscape}; \\q pairs: {withQuoteEscape}")
|
||||
.AppendLine($" strings with metalanguage self-escapes: {withMetaSelfEscape}")
|
||||
.AppendLine($" strings with unrecognized backslash pairs (kept verbatim): {withUnknownPair}")
|
||||
.AppendLine($" strings containing a REAL CR character: {withRealCr}")
|
||||
.AppendLine(" \\n-escape counts per table: "
|
||||
+ string.Join(", ", perTableNewlines.Select(
|
||||
static pair => $"0x{pair.Key:X8}={pair.Value}")))
|
||||
.AppendLine(" examples:");
|
||||
foreach (string example in examples)
|
||||
summary.AppendLine($" {example}");
|
||||
summary.AppendLine(userReportedExitText is null
|
||||
? " user-reported exit-world text: NOT found by content scan"
|
||||
: $" user-reported exit-world text: {userReportedExitText}");
|
||||
Console.WriteLine(summary.ToString());
|
||||
|
||||
// The escape class must genuinely exist in shipping data — if this
|
||||
// ever goes to zero the sweep (and the source decode) is measuring
|
||||
// nothing and needs re-examination, not silent success.
|
||||
Assert.True(
|
||||
withNewlineEscape > 0,
|
||||
"expected at least one installed string carrying the literal \\n escape");
|
||||
}
|
||||
|
||||
private static string Truncate(string value) =>
|
||||
(value.Length <= 90 ? value : value[..90] + "…")
|
||||
.Replace("\r", "<CR>").Replace("\n", "<LF>");
|
||||
}
|
||||
|
|
@ -85,6 +85,72 @@ public sealed class DatStringResolverTemplateTests
|
|||
new Dictionary<uint, string>()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The 2026-08-17 systemic escape round: resolution decodes the DAT's
|
||||
/// literal two-character escapes AT THE SOURCE — retail's own placement
|
||||
/// (<c>StringInfo::InqString @ 0x0042E490</c>'s unconditional
|
||||
/// <c>UnescapeString</c> tail). Consumers receive real line breaks; no
|
||||
/// per-consumer normalize remains.
|
||||
/// </summary>
|
||||
[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")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template composition decodes the authored fragments' escapes while
|
||||
/// variable content round-trips VERBATIM — retail escapes each variable
|
||||
/// on insert (<c>AddVariable_String @ 0x0042E6C0</c> →
|
||||
/// <c>SetLiteralValue(escape=1) @ 0x0042C980</c>) and unescapes the
|
||||
/// composed whole once, so a player name containing escape-significant
|
||||
/// characters can never be corrupted by the final decode.
|
||||
/// </summary>
|
||||
[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<uint, string>
|
||||
{
|
||||
// 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1144,8 +1144,10 @@ public class DatWidgetFactoryTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<UiText>(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);
|
||||
|
|
|
|||
103
tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs
Normal file
103
tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance for <see cref="RetailStringEscapes"/> — the exact port of
|
||||
/// retail's string-table escape codec
|
||||
/// (<c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c> /
|
||||
/// <c>EscapeString @ 0x0067BBC0</c>, character tables
|
||||
/// <c>GetUnEscapedChar @ 0x0067B750</c> / <c>GetEscapedChar @ 0x0067B6C0</c>).
|
||||
/// The metalanguage character set is byte-verified against the PDB-paired
|
||||
/// 2013 binary (file offset 0x3FE178: <c>[]!{}#\|^$</c>).
|
||||
/// </summary>
|
||||
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));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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));
|
||||
|
||||
/// <summary>No backslash → no allocation: the same instance returns.</summary>
|
||||
[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));
|
||||
|
||||
/// <summary>
|
||||
/// Retail's template-variable round trip
|
||||
/// (<c>AddVariable_String @ 0x0042E6C0</c> escapes on insert;
|
||||
/// <c>InqString @ 0x0042E490</c> unescapes the composed whole): variable
|
||||
/// content must come out verbatim.
|
||||
/// </summary>
|
||||
[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)));
|
||||
}
|
||||
|
|
@ -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) ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<UiButton>(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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue