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
|
|
@ -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<uint, string>())[stateId] = text;
|
||||
}
|
||||
|
|
@ -1220,6 +1219,20 @@ public static class DatWidgetFactory
|
|||
.OrderBy(child => child.ReadOrder)
|
||||
.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(
|
||||
ElementInfo info,
|
||||
Func<UiStringInfoValue, string?>? 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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// #409 (client-wide retail tooltip system): resolves the already-
|
||||
/// extracted <see cref="ElementInfo.TooltipText"/> (dat property
|
||||
/// <c>0x49</c>) through <paramref name="stringResolve"/>, applying the
|
||||
/// SAME escape normalization every other authored <c>StringInfo</c>
|
||||
/// (captions, <c>0x17</c>) gets at this one choke point. Null when the
|
||||
/// element authors no tooltip text or no resolver is available.
|
||||
/// <c>0x49</c>) through <paramref name="stringResolve"/>. Arrives
|
||||
/// escape-decoded from the string source, like every authored
|
||||
/// <c>StringInfo</c> (see <see cref="ResolveAuthoredString"/>). Null
|
||||
/// when the element authors no tooltip text or no resolver is available.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue