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
|
|
@ -19,17 +19,16 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <para>
|
||||
/// 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"/>
|
||||
/// per composed string, with no escape-normalize and no word-wrap. Two
|
||||
/// concrete symptoms this caused: literal two-character <c>"\n"</c>
|
||||
/// escapes rendered as backslash-n instead of a real line break (the DAT
|
||||
/// stores that literal escape — <c>DatWidgetFactory.BuildText</c>'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 <c>"\n"</c>,
|
||||
/// Batch C's other symptom) has since moved to the string source
|
||||
/// (<see cref="DatStringResolver"/> → <see cref="RetailStringEscapes"/>,
|
||||
/// the 2026-08-17 systemic round) — segments reach this composer with real
|
||||
/// line breaks already in place.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class DatRichText
|
||||
|
|
@ -41,7 +40,7 @@ internal static class DatRichText
|
|||
public readonly record struct Segment(string? Text, Vector4 Color);
|
||||
|
||||
/// <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
|
||||
/// 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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,23 @@ namespace AcDream.App.UI.Layout;
|
|||
/// The caller owns synchronization around <see cref="DatCollection"/> reads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Retail reference: <c>StringInfo::GetString</c> and
|
||||
/// <c>compute_str_hash @ 0x00413110</c>. A StringInfo's token selects one
|
||||
/// 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>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Returns every literal token for one retail StringInfo entry.</summary>
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,13 @@ internal static class IndicatorDetailText
|
|||
?? value.Length * 8f;
|
||||
|
||||
var lines = new List<UiText.Line>();
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -3010,10 +3010,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);
|
||||
|
|
@ -4368,13 +4368,13 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
return NormalizeRetailNewlines(strings.ResolveTemplate(
|
||||
stringTableId,
|
||||
"ID_CharacterManagement_DeleteCharacterConfirmation",
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[DatStringResolver.PlayerVariable] = characterName,
|
||||
})!);
|
||||
return strings.ResolveTemplate(
|
||||
stringTableId,
|
||||
"ID_CharacterManagement_DeleteCharacterConfirmation",
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[DatStringResolver.PlayerVariable] = characterName,
|
||||
})!;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4390,16 +4390,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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue