fix(ui): systemic escape normalization at the string source
The exit-world confirmation (ID_Client_EndCharacterSessionConfirm, table
0x23000001 key 0x0EB1C41D) rendered its literal two-character "\n" escapes
because escape decoding lived in individual consumers — Batch E centralized
it for authored captions only (DatWidgetFactory.ResolveAuthoredString), and
each new string surface had to remember its own copy. The installed DAT
carries the escape in 4,365 of 7,050 strings; per-consumer normalization
was structurally guaranteed to keep leaking.
Retail's placement is the SOURCE, not the widget: every public StringInfo
resolution ends in StringTableMetaLanguage::UnescapeString @ 0x0067BDC0
(StringInfo::InqString @ 0x0042E490, GetLiteralValue @ 0x0042CA50), the
write side escapes (SetLiteralValue @ 0x0042C980; AddVariable_String
@ 0x0042E6C0 for template variables), and widgets receive decoded text.
Ported exactly:
- NEW RetailStringEscapes: UnescapeString/EscapeString + the
GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0 tables
(\n \t \r \q + the ten metalanguage self-escapes []!{}#\|^$,
byte-verified against the PDB-paired 2013 binary at 0x3FE178;
unrecognized pairs stay verbatim).
- DatStringResolver.Resolve/ResolveAll unescape at the source;
ResolveTemplate escapes each variable on insert and unescapes the
composed whole — retail's round trip, so variable content (player
names) can never be corrupted by the final decode.
- RETIRED the consumer copies (double paths would corrupt an authored
"\n" into a line break): DatWidgetFactory.NormalizeEscapes + BuildText's
inline replace, RetailUiRuntime.NormalizeRetailNewlines + the
OpenCaptureInstructions inline replace, DatRichText.Compose's replace,
IndicatorDetailText.Shape's replace. ItemAppraisalTextLayout's replace
stays — WIRE-domain (server strings never pass the DAT source; retail's
ItemExamineUI::AddItemInfo @ 0x004AC050 appends wire text verbatim), now
documented as such.
- Consumer CR-strips retired with them: the installed DATs contain ZERO
real CR characters (sweep-measured) and UiText.WrapWords already drops
strays.
Tests: RetailStringEscapes conformance (escape set, unknown pairs,
round trip), DatStringResolver source-decode pins (including the exact
user-reported exit-world text shape and a backslash-carrying variable),
the installed-DAT escape sweep (7,050 strings; every resolution must equal
the retail unescape of the raw entry; inventory printed), and the existing
caption/rich-text/live-DAT pins relocated to the source contract.
App 5550/3 (live-DAT), Runtime 1747/0, complete Release solution green
across all suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fdc4fd496d
commit
967b9c57cf
15 changed files with 605 additions and 116 deletions
|
|
@ -19,17 +19,16 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The description pages used to bypass this entirely: they assigned a raw
|
/// The description pages used to bypass this entirely: they assigned a raw
|
||||||
/// <c>LinesProvider</c> lambda returning ONE unwrapped <see cref="AcDream.App.UI.UiText.Line"/>
|
/// <c>LinesProvider</c> lambda returning ONE unwrapped <see cref="AcDream.App.UI.UiText.Line"/>
|
||||||
/// per composed string, with no escape-normalize and no word-wrap. Two
|
/// per composed string, with no word-wrap. Historical symptom (Batch C):
|
||||||
/// concrete symptoms this caused: literal two-character <c>"\n"</c>
|
/// for the Town page an unwrapped single line meant the town-specific
|
||||||
/// escapes rendered as backslash-n instead of a real line break (the DAT
|
/// SUFFIX of the composed string rendered far outside the box's clipped
|
||||||
/// stores that literal escape — <c>DatWidgetFactory.BuildText</c>'s own
|
/// viewport, so switching towns looked like "the text never changes" even
|
||||||
/// authored-string path already normalizes it for single-element authored
|
/// though the underlying string genuinely did (only its INVISIBLE tail
|
||||||
/// captions; this helper reproduces the SAME normalize for
|
/// differed). Escape decoding (the DAT's literal two-character <c>"\n"</c>,
|
||||||
/// runtime-composed multi-segment text), and — for the Town page
|
/// Batch C's other symptom) has since moved to the string source
|
||||||
/// specifically — an unwrapped single line meant the town-specific SUFFIX
|
/// (<see cref="DatStringResolver"/> → <see cref="RetailStringEscapes"/>,
|
||||||
/// of the composed string rendered far outside the box's clipped viewport,
|
/// the 2026-08-17 systemic round) — segments reach this composer with real
|
||||||
/// so switching towns looked like "the text never changes" even though the
|
/// line breaks already in place.
|
||||||
/// underlying string genuinely did (only its INVISIBLE tail differed).
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class DatRichText
|
internal static class DatRichText
|
||||||
|
|
@ -41,7 +40,7 @@ internal static class DatRichText
|
||||||
public readonly record struct Segment(string? Text, Vector4 Color);
|
public readonly record struct Segment(string? Text, Vector4 Color);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
/// each segment's wrapped lines keep ITS OWN color), then concatenates
|
||||||
/// the results in order. No separator is inserted between segments —
|
/// the results in order. No separator is inserted between segments —
|
||||||
/// retail's own composition calls concatenate directly
|
/// retail's own composition calls concatenate directly
|
||||||
|
|
@ -76,15 +75,12 @@ internal static class DatRichText
|
||||||
if (string.IsNullOrEmpty(segment.Text))
|
if (string.IsNullOrEmpty(segment.Text))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// The installed DAT stores the LITERAL two-character escape
|
// Escape decoding (the DAT's literal two-character "\n") happens
|
||||||
// "\n" (0x5C 0x6E), not a real line break — same normalize
|
// at the string source (DatStringResolver → RetailStringEscapes,
|
||||||
// DatWidgetFactory.BuildText's authored-string path already
|
// 2026-08-17 systemic round — retail's own placement), so
|
||||||
// applies for single-element authored captions.
|
// segments arrive with real line breaks; WrapWords preserves
|
||||||
string normalized = segment.Text
|
// them and drops any stray CR itself.
|
||||||
.Replace("\\n", "\n")
|
foreach (string wrapped in UiText.WrapWords(segment.Text, measure, maximumWidth))
|
||||||
.Replace("\r", string.Empty);
|
|
||||||
|
|
||||||
foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth))
|
|
||||||
lines.Add(new UiText.Line(wrapped, segment.Color));
|
lines.Add(new UiText.Line(wrapped, segment.Color));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,23 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// The caller owns synchronization around <see cref="DatCollection"/> reads.
|
/// The caller owns synchronization around <see cref="DatCollection"/> reads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Retail reference: <c>StringInfo::GetString</c> and
|
/// Retail reference: <c>StringInfo::GetString</c> and
|
||||||
/// <c>compute_str_hash @ 0x00413110</c>. A StringInfo's token selects one
|
/// <c>compute_str_hash @ 0x00413110</c>. A StringInfo's token selects one
|
||||||
/// localized string variant; ordinary UI labels use token zero.
|
/// localized string variant; ordinary UI labels use token zero.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every resolution decodes the DAT's two-character escapes
|
||||||
|
/// (<c>\n</c>, <c>\t</c>, <c>\r</c>, <c>\q</c>, and the metalanguage
|
||||||
|
/// self-escapes) HERE, at the source — retail's own placement: every public
|
||||||
|
/// <c>StringInfo</c> resolution ends in
|
||||||
|
/// <c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c>
|
||||||
|
/// (<c>StringInfo::InqString @ 0x0042E490</c>,
|
||||||
|
/// <c>StringInfo::GetLiteralValue @ 0x0042CA50</c>). Consumers receive
|
||||||
|
/// already-decoded text and must not re-decode — see
|
||||||
|
/// <see cref="RetailStringEscapes"/>' remarks for the double-decode hazard
|
||||||
|
/// (the 2026-08-17 systemic round that retired the per-consumer copies).
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class DatStringResolver
|
public sealed class DatStringResolver
|
||||||
{
|
{
|
||||||
|
|
@ -41,7 +55,9 @@ public sealed class DatStringResolver
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
int index = token >= 0 && token < entry.Strings.Count ? token : 0;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Returns every literal token for one retail StringInfo entry.</summary>
|
/// <summary>Returns every literal token for one retail StringInfo entry.</summary>
|
||||||
|
|
@ -57,7 +73,9 @@ public sealed class DatStringResolver
|
||||||
return table is not null
|
return table is not null
|
||||||
&& table.Strings.TryGetValue(stringId, out var entry)
|
&& table.Strings.TryGetValue(stringId, out var entry)
|
||||||
&& entry.Strings.Count != 0
|
&& entry.Strings.Count != 0
|
||||||
? entry.Strings.Select(value => value.Value).ToArray()
|
? entry.Strings
|
||||||
|
.Select(value => RetailStringEscapes.Unescape(value.Value))
|
||||||
|
.ToArray()
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,14 +123,21 @@ public sealed class DatStringResolver
|
||||||
{
|
{
|
||||||
composed.Append(entry.Strings[i].Value);
|
composed.Append(entry.Strings[i].Value);
|
||||||
// Variables are stored as the pre-computed name hashes (the same
|
// 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
|
if (i < entry.Variables.Count
|
||||||
&& variables.TryGetValue(entry.Variables[i], out string? value))
|
&& 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -856,9 +856,11 @@ public static class DatWidgetFactory
|
||||||
{
|
{
|
||||||
// 2026-08-13 social gate: authored strings can carry embedded
|
// 2026-08-13 social gate: authored strings can carry embedded
|
||||||
// newlines (the fellowship empty-state is three sentences over
|
// newlines (the fellowship empty-state is three sentences over
|
||||||
// '\n's). Gate round 2: the DAT stores the LITERAL two-character
|
// '\n's). The DAT stores those as the LITERAL two-character
|
||||||
// escape "\n" (0x5C 0x6E — probe-verified: the dump printed
|
// escape "\n" (0x5C 0x6E — probe-verified), decoded at the
|
||||||
// backslash-n, not a line break), so normalize the escape first.
|
// 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
|
// Gate round 3: retail additionally WORD-WRAPS each authored line
|
||||||
// within the element extent (its GlyphList draw — the same wrap
|
// within the element extent (its GlyphList draw — the same wrap
|
||||||
// the confirmation dialog view already uses), so a multiline
|
// 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
|
// re-wrapping them is a client-wide behavior change no gate has
|
||||||
// asked for). Providers re-read DefaultColor/width/font per call
|
// asked for). Providers re-read DefaultColor/width/font per call
|
||||||
// (NOT captured eagerly) so state-driven changes keep tracking.
|
// (NOT captured eagerly) so state-driven changes keep tracking.
|
||||||
string normalized = authored
|
if (authored.Contains('\n'))
|
||||||
.Replace("\\n", "\n")
|
|
||||||
.Replace("\r", string.Empty);
|
|
||||||
if (normalized.Contains('\n'))
|
|
||||||
{
|
{
|
||||||
float cachedWidth = float.NaN;
|
float cachedWidth = float.NaN;
|
||||||
UiDatFont? cachedFont = null;
|
UiDatFont? cachedFont = null;
|
||||||
|
|
@ -897,7 +896,7 @@ public static class DatWidgetFactory
|
||||||
? font.MeasureWidth
|
? font.MeasureWidth
|
||||||
: static value => value.Length * 8f;
|
: static value => value.Length * 8f;
|
||||||
cachedLines = [.. UiText
|
cachedLines = [.. UiText
|
||||||
.WrapWords(normalized, measure, maximumWidth)
|
.WrapWords(authored, measure, maximumWidth)
|
||||||
.Select(line => new UiText.Line(line, t.DefaultColor))];
|
.Select(line => new UiText.Line(line, t.DefaultColor))];
|
||||||
}
|
}
|
||||||
return cachedLines;
|
return cachedLines;
|
||||||
|
|
@ -906,7 +905,7 @@ public static class DatWidgetFactory
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
t.LinesProvider = () =>
|
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)
|
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|
||||||
|| stateCaption.Kind != UiPropertyKind.StringInfo)
|
|| stateCaption.Kind != UiPropertyKind.StringInfo)
|
||||||
continue;
|
continue;
|
||||||
if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue))
|
if (stringResolve?.Invoke(stateCaption.StringInfoValue)
|
||||||
is { Length: > 0 } text)
|
is { Length: > 0 } text)
|
||||||
(stateStrings ??= new Dictionary<uint, string>())[stateId] = text;
|
(stateStrings ??= new Dictionary<uint, string>())[stateId] = text;
|
||||||
}
|
}
|
||||||
|
|
@ -1220,6 +1219,20 @@ public static class DatWidgetFactory
|
||||||
.OrderBy(child => child.ReadOrder)
|
.OrderBy(child => child.ReadOrder)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the effective authored caption (dat property <c>0x17</c>)
|
||||||
|
/// for a widget. Escape decoding is NOT done here: since the 2026-08-17
|
||||||
|
/// systemic round the string SOURCE (<see cref="DatStringResolver"/> →
|
||||||
|
/// <see cref="RetailStringEscapes"/>, retail's own placement — every
|
||||||
|
/// <c>StringInfo</c> resolution ends in
|
||||||
|
/// <c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c>) 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 <c>\\n</c> (escaped backslash then 'n') into a line break.
|
||||||
|
/// </summary>
|
||||||
private static string? ResolveAuthoredString(
|
private static string? ResolveAuthoredString(
|
||||||
ElementInfo info,
|
ElementInfo info,
|
||||||
Func<UiStringInfoValue, string?>? stringResolve)
|
Func<UiStringInfoValue, string?>? stringResolve)
|
||||||
|
|
@ -1228,42 +1241,16 @@ public static class DatWidgetFactory
|
||||||
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|
||||||
|| property.Kind != UiPropertyKind.StringInfo)
|
|| property.Kind != UiPropertyKind.StringInfo)
|
||||||
return null;
|
return null;
|
||||||
string? resolved = stringResolve(property.StringInfoValue);
|
return 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize
|
|
||||||
/// <see cref="ResolveAuthoredString"/> applies, pulled out so the
|
|
||||||
/// per-STATE authored-caption loop below (which resolves a state's own
|
|
||||||
/// <c>0x17</c> directly, bypassing the effective-property resolution
|
|
||||||
/// <see cref="ResolveAuthoredString"/> wraps) gets the SAME normalize
|
|
||||||
/// instead of a second, easily-forgotten copy.
|
|
||||||
/// </summary>
|
|
||||||
private static string? NormalizeEscapes(string? raw) =>
|
|
||||||
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #409 (client-wide retail tooltip system): resolves the already-
|
/// #409 (client-wide retail tooltip system): resolves the already-
|
||||||
/// extracted <see cref="ElementInfo.TooltipText"/> (dat property
|
/// extracted <see cref="ElementInfo.TooltipText"/> (dat property
|
||||||
/// <c>0x49</c>) through <paramref name="stringResolve"/>, applying the
|
/// <c>0x49</c>) through <paramref name="stringResolve"/>. Arrives
|
||||||
/// SAME escape normalization every other authored <c>StringInfo</c>
|
/// escape-decoded from the string source, like every authored
|
||||||
/// (captions, <c>0x17</c>) gets at this one choke point. Null when the
|
/// <c>StringInfo</c> (see <see cref="ResolveAuthoredString"/>). Null
|
||||||
/// element authors no tooltip text or no resolver is available.
|
/// when the element authors no tooltip text or no resolver is available.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static string? ResolveTooltipText(
|
internal static string? ResolveTooltipText(
|
||||||
ElementInfo info,
|
ElementInfo info,
|
||||||
|
|
@ -1271,6 +1258,6 @@ public static class DatWidgetFactory
|
||||||
{
|
{
|
||||||
if (stringResolve is null || info.TooltipText is not { } tooltipText)
|
if (stringResolve is null || info.TooltipText is not { } tooltipText)
|
||||||
return null;
|
return null;
|
||||||
return NormalizeEscapes(stringResolve(tooltipText));
|
return stringResolve(tooltipText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,13 @@ internal static class IndicatorDetailText
|
||||||
?? value.Length * 8f;
|
?? value.Length * 8f;
|
||||||
|
|
||||||
var lines = new List<UiText.Line>();
|
var lines = new List<UiText.Line>();
|
||||||
string normalized = text.Replace("\\n", "\n", StringComparison.Ordinal);
|
// DAT-resolved bodies (vitae, link status, effects) arrive with
|
||||||
foreach (string paragraph in normalized.Split('\n'))
|
// 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)
|
if (paragraph.Length == 0)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,14 @@ internal static class ItemAppraisalTextLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
Vector4 color = ResolveColor(target, fragment.Style);
|
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(
|
string normalized = fragment.Text.Replace(
|
||||||
"\\n",
|
"\\n",
|
||||||
"\n",
|
"\n",
|
||||||
|
|
|
||||||
140
src/AcDream.App/UI/Layout/RetailStringEscapes.cs
Normal file
140
src/AcDream.App/UI/Layout/RetailStringEscapes.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact port of retail's string-table escape codec
|
||||||
|
/// (<c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c> /
|
||||||
|
/// <c>EscapeString @ 0x0067BBC0</c> and their character tables
|
||||||
|
/// <c>GetUnEscapedChar @ 0x0067B750</c> / <c>GetEscapedChar @ 0x0067B6C0</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// PLACEMENT (the systemic 2026-08-17 normalization round): retail decodes
|
||||||
|
/// escapes at the string SOURCE, not per-widget. Every public
|
||||||
|
/// <c>StringInfo</c> resolution runs the unescape unconditionally before any
|
||||||
|
/// consumer sees the text — <c>StringInfo::InqString @ 0x0042E490</c> tail
|
||||||
|
/// and <c>StringInfo::GetLiteralValue @ 0x0042CA50</c> both end in
|
||||||
|
/// <c>UnescapeString</c>. The write side is the inverse:
|
||||||
|
/// <c>StringInfo::SetLiteralValue @ 0x0042C980</c> runs <c>EscapeString</c>
|
||||||
|
/// when storing plain text (and <c>StringInfo::AddVariable_String
|
||||||
|
/// @ 0x0042E6C0</c> 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 <see cref="DatStringResolver"/>;
|
||||||
|
/// widgets and controllers receive already-decoded strings and must not
|
||||||
|
/// re-decode (a second pass corrupts an authored <c>\\n</c> — escaped
|
||||||
|
/// backslash then 'n' — into a line break).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The escape set (byte-verified against the PDB-paired 2013 binary; the
|
||||||
|
/// metalanguage character-set literal at file offset 0x3FE178 is the ten
|
||||||
|
/// characters <c>[]!{}#\|^$</c>):
|
||||||
|
/// <c>\n</c> → LF (0x0A), <c>\t</c> → TAB (0x09), <c>\r</c> → CR (0x0D),
|
||||||
|
/// <c>\q</c> → '"' (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
|
||||||
|
/// (<c>GetUnEscapedChar</c> returns 0 and <c>UnescapeString</c>'s
|
||||||
|
/// else-branch keeps the current character).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class RetailStringEscapes
|
||||||
|
{
|
||||||
|
/// <summary>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 <c>wcschr</c>.</summary>
|
||||||
|
private const string MetaCharacters = "[]!{}#\\|^$";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>StringTableMetaLanguage::GetUnEscapedChar @ 0x0067B750</c>: the
|
||||||
|
/// character an escape pair <c>\</c>+<paramref name="value"/> decodes
|
||||||
|
/// to, or <c>'\0'</c> when the pair is not an escape.
|
||||||
|
/// </summary>
|
||||||
|
internal static char GetUnEscapedChar(char value) => value switch
|
||||||
|
{
|
||||||
|
'n' => '\n',
|
||||||
|
'q' => '"',
|
||||||
|
'r' => '\r',
|
||||||
|
't' => '\t',
|
||||||
|
not '\0' when MetaCharacters.Contains(value) => value,
|
||||||
|
_ => '\0',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>StringTableMetaLanguage::GetEscapedChar @ 0x0067B6C0</c>: the
|
||||||
|
/// character that follows the backslash when <paramref name="value"/>
|
||||||
|
/// must be stored escaped, or <c>'\0'</c> when it is stored verbatim.
|
||||||
|
/// </summary>
|
||||||
|
internal static char GetEscapedChar(char value) => value switch
|
||||||
|
{
|
||||||
|
'\t' => 't',
|
||||||
|
'\n' => 'n',
|
||||||
|
'\r' => 'r',
|
||||||
|
'"' => 'q',
|
||||||
|
not '\0' when MetaCharacters.Contains(value) => value,
|
||||||
|
_ => '\0',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>StringTableMetaLanguage::UnescapeString @ 0x0067BDC0</c>: 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>StringTableMetaLanguage::EscapeString @ 0x0067BBC0</c>: the exact
|
||||||
|
/// inverse — every character with a <see cref="GetEscapedChar"/> mapping
|
||||||
|
/// is stored as <c>\</c> + that mapping; everything else verbatim.
|
||||||
|
/// <c>Unescape(Escape(x)) == x</c> for every <paramref name="value"/> —
|
||||||
|
/// the round-trip retail relies on for template variables.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2973,10 +2973,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
});
|
});
|
||||||
if (text is null) return 0u; // no invented English
|
if (text is null) return 0u; // no invented English
|
||||||
// The authored text stores its blank line as a literal
|
// The authored text stores its blank line as a literal
|
||||||
// "\n\n" two-character escape (live-probed) — same
|
// "\n\n" two-character escape (live-probed), decoded at
|
||||||
// convention DatWidgetFactory/IndicatorDetailText already
|
// the string source (DatStringResolver →
|
||||||
// unescape for other DAT-authored strings.
|
// RetailStringEscapes, 2026-08-17 systemic round) —
|
||||||
text = text.Replace("\\n", "\n", StringComparison.Ordinal);
|
// `text` arrives with real line breaks.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return DialogFactory.MakeWait(text, queueKey: 0x10000001u);
|
return DialogFactory.MakeWait(text, queueKey: 0x10000001u);
|
||||||
|
|
@ -4331,13 +4331,13 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
lock (_bindings.Assets.DatLock)
|
lock (_bindings.Assets.DatLock)
|
||||||
{
|
{
|
||||||
return NormalizeRetailNewlines(strings.ResolveTemplate(
|
return strings.ResolveTemplate(
|
||||||
stringTableId,
|
stringTableId,
|
||||||
"ID_CharacterManagement_DeleteCharacterConfirmation",
|
"ID_CharacterManagement_DeleteCharacterConfirmation",
|
||||||
new Dictionary<uint, string>
|
new Dictionary<uint, string>
|
||||||
{
|
{
|
||||||
[DatStringResolver.PlayerVariable] = characterName,
|
[DatStringResolver.PlayerVariable] = characterName,
|
||||||
})!);
|
})!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4353,16 +4353,16 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
confirmExit));
|
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(
|
private static string? ResolveCharacterManagementString(
|
||||||
DatStringResolver strings,
|
DatStringResolver strings,
|
||||||
uint tableId,
|
uint tableId,
|
||||||
string key) =>
|
string key) =>
|
||||||
strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value
|
strings.Resolve(tableId, DatStringResolver.ComputeHash(key));
|
||||||
? NormalizeRetailNewlines(value)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
private static string NormalizeRetailNewlines(string value) =>
|
|
||||||
value.Replace("\\n", "\n", StringComparison.Ordinal);
|
|
||||||
|
|
||||||
private void ConfigureCharacterCreation()
|
private void ConfigureCharacterCreation()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1904,18 +1904,21 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// GF-2: the composed description routes through the shared rich-text
|
/// GF-2: the composed description routes through the shared rich-text
|
||||||
/// helper — header segments (palette index 1) render in a DIFFERENT
|
/// helper — header segments (palette index 1) render in a DIFFERENT
|
||||||
/// color than body segments (index 0), and each segment's own escape
|
/// color than body segments (index 0), and an authored line break
|
||||||
/// sequence is normalized. The fixture's description element carries
|
/// splits into stacked lines. The harness resolver models
|
||||||
/// no authored <c>FontColorPalette</c>, so this also exercises
|
/// <c>DatStringResolver</c>'s post-decode output (the DAT's literal
|
||||||
/// <see cref="DatRichText.PaletteColor"/>'s fallback (green header /
|
/// "\n" escape decodes AT THE SOURCE since the 2026-08-17 systemic
|
||||||
/// white body).
|
/// 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>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HeritageDescription_ComposesGreenHeaderAndWhiteBodySegments()
|
public void HeritageDescription_ComposesGreenHeaderAndWhiteBodySegments()
|
||||||
{
|
{
|
||||||
using var environment = new EnvironmentHarness();
|
using var environment = new EnvironmentHarness();
|
||||||
environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills_Header"] = "Trained Starting Skills:";
|
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.Controller.Open();
|
||||||
environment.Runtime.SelectHeritageDirect(AluvianId);
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||||
BumpRevisionAndTick(environment);
|
BumpRevisionAndTick(environment);
|
||||||
|
|
@ -1924,7 +1927,7 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
var lines = description.LinesProvider().ToList();
|
var lines = description.LinesProvider().ToList();
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Text == "Trained Starting Skills:" && l.Color == new Vector4(0f, 1f, 0f, 1f));
|
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.
|
// 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 one" && l.Color == Vector4.One);
|
||||||
Assert.Contains(lines, l => l.Text == "Line two" && 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
|
// Finding 1: MakeConfirmExitDialog@0x004ed250's text
|
||||||
// (compute_str_hash("ID_CharacterManagement_ConfirmExit"), table
|
// (compute_str_hash("ID_CharacterManagement_ConfirmExit"), table
|
||||||
// enum 0x10000002 -> 0x23000002). The raw DAT string carries a
|
// enum 0x10000002 -> 0x23000002). The raw DAT string carries a
|
||||||
// literal two-character "\n" escape (this test's Resolve() helper
|
// literal two-character "\n" escape; since the 2026-08-17 systemic
|
||||||
// does not normalize it — RetailUiRuntime does, via
|
// round DatStringResolver decodes it AT THE SOURCE (retail's own
|
||||||
// NormalizeRetailNewlines, before handing it to the controller).
|
// placement — StringInfo::InqString @ 0x0042E490's UnescapeString
|
||||||
Assert.Equal("Are you sure you want to leave?\\n", Resolve(strings, table,
|
// 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"));
|
"ID_CharacterManagement_ConfirmExit"));
|
||||||
string confirmation = Assert.IsType<string>(strings.ResolveTemplate(
|
string confirmation = Assert.IsType<string>(strings.ResolveTemplate(
|
||||||
table,
|
table,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Campaign CC gate round 1 Batch C: unit tests for the shared
|
/// Campaign CC gate round 1 Batch C: unit tests for the shared
|
||||||
/// escape-normalize + word-wrap + per-segment-color helper feeding
|
/// word-wrap + per-segment-color helper feeding GF-2/GF-3/GF-11a and the
|
||||||
/// GF-2/GF-3/GF-11a and the Summary how-to text (Commit 3).
|
/// 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>
|
/// </summary>
|
||||||
public class DatRichTextTests
|
public class DatRichTextTests
|
||||||
{
|
{
|
||||||
|
|
@ -16,17 +18,24 @@ public class DatRichTextTests
|
||||||
private static UiText MakeTarget(float width) =>
|
private static UiText MakeTarget(float width) =>
|
||||||
new() { Width = width, Height = 200f };
|
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]
|
[Fact]
|
||||||
public void Compose_NormalizesLiteralBackslashNEscape()
|
public void Compose_SplitsOnRealNewlines_AndKeepsLiteralPairsVerbatim()
|
||||||
{
|
{
|
||||||
UiText target = MakeTarget(1000f); // wide enough that nothing wraps
|
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);
|
var lines = DatRichText.Compose(target, segments);
|
||||||
|
|
||||||
Assert.Equal(2, lines.Count);
|
Assert.Equal(2, lines.Count);
|
||||||
Assert.Equal("line one", lines[0].Text);
|
Assert.Equal("line one", lines[0].Text);
|
||||||
Assert.Equal("line two", lines[1].Text);
|
Assert.Equal("literal \\n stays", lines[1].Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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>()));
|
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]
|
[Fact]
|
||||||
public void UnknownKeyResolvesNull()
|
public void UnknownKeyResolvesNull()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1144,8 +1144,10 @@ public class DatWidgetFactoryTests
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 2026-08-13 social gate round 3: a MULTILINE authored string (literal
|
/// 2026-08-13 social gate round 3: a MULTILINE authored string (a real
|
||||||
/// backslash-n escapes in the DAT) word-wraps each authored line to the
|
/// '\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
|
/// widget's live width — retail's GlyphList draw, the same wrap the
|
||||||
/// confirmation dialog view uses. The fellowship empty-state was
|
/// confirmation dialog view uses. The fellowship empty-state was
|
||||||
/// rendering its three authored lines as three clipped runs.
|
/// rendering its three authored lines as three clipped runs.
|
||||||
|
|
@ -1165,7 +1167,7 @@ public class DatWidgetFactoryTests
|
||||||
// Width=100 fits 12 characters per wrapped line.
|
// Width=100 fits 12 characters per wrapped line.
|
||||||
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
||||||
info, NoTex, null,
|
info, NoTex, null,
|
||||||
stringResolve: _ => "one two three four five\\nsix"));
|
stringResolve: _ => "one two three four five\nsix"));
|
||||||
|
|
||||||
var lines = text.LinesProvider!();
|
var lines = text.LinesProvider!();
|
||||||
Assert.True(lines.Count >= 3);
|
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");
|
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>
|
/// <summary>
|
||||||
/// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way
|
/// R2-2's successor contract (2026-08-17 systemic round): the DAT's
|
||||||
/// BuildText's authored-string path always has — the DAT stores the
|
/// LITERAL two-character escape "\n" (0x5C 0x6E — the Profession
|
||||||
/// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession
|
/// credits button's own authored caption is exactly this shape) decodes
|
||||||
/// credits button's own authored caption is exactly this shape.
|
/// 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>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape()
|
public void BuildButton_OwnCaption_PassesSourceDecodedTextThrough()
|
||||||
{
|
{
|
||||||
uint stringId = 444u;
|
uint stringId = 444u;
|
||||||
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
|
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
|
||||||
|
|
@ -575,11 +579,14 @@ public class UiButtonTests
|
||||||
|
|
||||||
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
||||||
info, NoTex, null,
|
info, NoTex, null,
|
||||||
// The raw resolved string carries the LITERAL two characters
|
// The resolver seam models DatStringResolver's post-decode
|
||||||
// '\' and 'n', matching what the installed DAT actually stores.
|
// output: a REAL '\n', plus a literal backslash pair that a
|
||||||
stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null));
|
// 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)
|
private static UiButton ButtonWithStates(params string[] states)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue