using DatReaderWriter; using AcDream.Content; using DatReaderWriter.DBObjs; namespace AcDream.App.UI.Layout; /// /// Resolves retail StringInfo values through local.dat string tables. /// The caller owns synchronization around reads. /// /// /// /// Retail reference: StringInfo::GetString and /// compute_str_hash @ 0x00413110. A StringInfo's token selects one /// localized string variant; ordinary UI labels use token zero. /// /// /// Every resolution decodes the DAT's two-character escapes /// (\n, \t, \r, \q, and the metalanguage /// self-escapes) HERE, at the source — retail's own placement: every public /// StringInfo resolution ends in /// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0 /// (StringInfo::InqString @ 0x0042E490, /// StringInfo::GetLiteralValue @ 0x0042CA50). Consumers receive /// already-decoded text and must not re-decode — see /// ' remarks for the double-decode hazard /// (the 2026-08-17 systemic round that retired the per-consumer copies). /// /// public sealed class DatStringResolver { private readonly IDatReaderWriter _dats; private readonly Dictionary _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(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); } /// Returns every literal token for one retail StringInfo entry. public string[]? ResolveAll(uint tableId, uint stringId) { if (tableId == 0u || stringId == 0u) return null; if (!_tables.TryGetValue(tableId, out StringTable? table)) { table = _dats.Get(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; } /// Retail's variable-name hash for PLAYER (0x05506DA2) — /// the single substitution slot every social confirmation template uses. public static readonly uint PlayerVariable = ComputeHash("PLAYER"); /// /// Composes a templated StringTable entry: N (or N+1) literal fragments /// interleaved with N named variables, keyed by the entry-key hash. /// /// /// Exact port of StringTable::GetString @ 0x004300D0's /// no-metalanguage branch @ 0x004303B7: each fragment is appended, /// then the variable in the same slot (resolved through /// , keyed by of the /// authored variable name; a missing variable substitutes the empty /// string, as retail does). This is NOT a /// StringTableMetaLanguage::RenderString port — callers own /// keeping it to token-free templates (register row AD-81's scope note). /// public string? ResolveTemplate( uint tableId, string key, IReadOnlyDictionary variables) { ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(variables); if (tableId == 0u) return null; if (!_tables.TryGetValue(tableId, out StringTable? table)) { table = _dats.Get(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()); } /// /// Exact retail ELF-style string hash used for StringInfo keys. /// Ported line-for-line from compute_str_hash @ 0x00413110. /// 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; } }