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>
162 lines
6.4 KiB
C#
162 lines
6.4 KiB
C#
using DatReaderWriter;
|
|
using AcDream.Content;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Resolves retail <c>StringInfo</c> values through local.dat string tables.
|
|
/// 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
|
|
{
|
|
private readonly IDatReaderWriter _dats;
|
|
private readonly Dictionary<uint, StringTable?> _tables = new();
|
|
|
|
public DatStringResolver(IDatReaderWriter dats)
|
|
=> _dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
|
|
|
public string? Resolve(UiStringInfoValue info)
|
|
=> Resolve(info.TableId, info.StringId, info.Token);
|
|
|
|
public string? Resolve(uint tableId, uint stringId, int token = 0)
|
|
{
|
|
if (tableId == 0u || stringId == 0u)
|
|
return null;
|
|
|
|
if (!_tables.TryGetValue(tableId, out StringTable? table))
|
|
{
|
|
table = _dats.Get<StringTable>(tableId);
|
|
_tables[tableId] = table;
|
|
}
|
|
|
|
if (table is null
|
|
|| !table.Strings.TryGetValue(stringId, out var entry)
|
|
|| entry.Strings.Count == 0)
|
|
return null;
|
|
|
|
int index = token >= 0 && token < entry.Strings.Count ? token : 0;
|
|
// 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>
|
|
public string[]? ResolveAll(uint tableId, uint stringId)
|
|
{
|
|
if (tableId == 0u || stringId == 0u)
|
|
return null;
|
|
if (!_tables.TryGetValue(tableId, out StringTable? table))
|
|
{
|
|
table = _dats.Get<StringTable>(tableId);
|
|
_tables[tableId] = table;
|
|
}
|
|
return table is not null
|
|
&& table.Strings.TryGetValue(stringId, out var entry)
|
|
&& entry.Strings.Count != 0
|
|
? entry.Strings
|
|
.Select(value => RetailStringEscapes.Unescape(value.Value))
|
|
.ToArray()
|
|
: null;
|
|
}
|
|
|
|
/// <summary>Retail's variable-name hash for <c>PLAYER</c> (0x05506DA2) —
|
|
/// the single substitution slot every social confirmation template uses.</summary>
|
|
public static readonly uint PlayerVariable = ComputeHash("PLAYER");
|
|
|
|
/// <summary>
|
|
/// Composes a templated StringTable entry: N (or N+1) literal fragments
|
|
/// interleaved with N named variables, keyed by the entry-key hash.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Exact port of <c>StringTable::GetString @ 0x004300D0</c>'s
|
|
/// no-metalanguage branch <c>@ 0x004303B7</c>: each fragment is appended,
|
|
/// then the variable in the same slot (resolved through
|
|
/// <paramref name="variables"/>, keyed by <see cref="ComputeHash"/> of the
|
|
/// authored variable name; a missing variable substitutes the empty
|
|
/// string, as retail does). This is NOT a
|
|
/// <c>StringTableMetaLanguage::RenderString</c> port — callers own
|
|
/// keeping it to token-free templates (register row AD-81's scope note).
|
|
/// </remarks>
|
|
public string? ResolveTemplate(
|
|
uint tableId,
|
|
string key,
|
|
IReadOnlyDictionary<uint, string> variables)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(key);
|
|
ArgumentNullException.ThrowIfNull(variables);
|
|
if (tableId == 0u)
|
|
return null;
|
|
|
|
if (!_tables.TryGetValue(tableId, out StringTable? table))
|
|
{
|
|
table = _dats.Get<StringTable>(tableId);
|
|
_tables[tableId] = table;
|
|
}
|
|
|
|
if (table is null
|
|
|| !table.Strings.TryGetValue(ComputeHash(key), out var entry)
|
|
|| entry.Strings.Count == 0)
|
|
return null;
|
|
|
|
var composed = new System.Text.StringBuilder();
|
|
for (int i = 0; i < entry.Strings.Count; i++)
|
|
{
|
|
composed.Append(entry.Strings[i].Value);
|
|
// Variables are stored as the pre-computed name hashes (the same
|
|
// 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(RetailStringEscapes.Escape(value));
|
|
}
|
|
}
|
|
// StringInfo::InqString @ 0x0042E490's unconditional tail, same as
|
|
// Resolve above: composed text decodes its escapes at the source.
|
|
return RetailStringEscapes.Unescape(composed.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact retail ELF-style string hash used for StringInfo keys.
|
|
/// Ported line-for-line from <c>compute_str_hash @ 0x00413110</c>.
|
|
/// </summary>
|
|
public static uint ComputeHash(string value)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(value);
|
|
|
|
uint result = 0u;
|
|
foreach (char c in value)
|
|
{
|
|
result = unchecked((result << 4) + (byte)c);
|
|
uint high = result & 0xF0000000u;
|
|
if (high != 0u)
|
|
result = ((high >> 24) ^ result) & 0x0FFFFFFFu;
|
|
}
|
|
|
|
return result == uint.MaxValue ? uint.MaxValue - 1u : result;
|
|
}
|
|
}
|