acdream/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs
Erik 967b9c57cf 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>
2026-08-17 13:26:25 +02:00

249 lines
8.6 KiB
C#

using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using AcDream.App.UI.Layout;
using AcDream.Content;
using AcDream.Core.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Conformance for <see cref="DatStringResolver.ResolveTemplate"/> — the
/// <c>StringTable::GetString @ 0x004300D0</c> fragment/variable interleave
/// (no-metalanguage branch @ 0x004303B7) that composes the social
/// confirmation sentences.
/// </summary>
public sealed class DatStringResolverTemplateTests
{
private const uint TableId = 0x23000001u;
[Fact]
public void PlayerVariableIsTheRetailHash()
=> Assert.Equal(0x05506DA2u, DatStringResolver.PlayerVariable);
[Fact]
public void ComposesTrailingFragmentTemplate()
{
// ID_Allegiance_SwearConfirmation's shape:
// ["Do you wish to swear to ", "?"] + [PLAYER]
var resolver = MakeResolver(
"ID_Allegiance_SwearConfirmation",
fragments: ["Do you wish to swear to ", "?"],
variables: [DatStringResolver.PlayerVariable]);
Assert.Equal(
"Do you wish to swear to +Horan?",
resolver.ResolveTemplate(
TableId,
"ID_Allegiance_SwearConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = "+Horan",
}));
}
[Fact]
public void ComposesLeadingVariableTemplate()
{
// ID_Fellowship_FellowshipRequest's shape: an EMPTY first fragment,
// so the player name leads the sentence.
var resolver = MakeResolver(
"ID_Fellowship_FellowshipRequest",
fragments: [
"",
" has invited you to join their fellowship. Do you accept?",
],
variables: [DatStringResolver.PlayerVariable]);
Assert.Equal(
"+Acdream has invited you to join their fellowship. Do you accept?",
resolver.ResolveTemplate(
TableId,
"ID_Fellowship_FellowshipRequest",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = "+Acdream",
}));
}
[Fact]
public void MissingVariableSubstitutesEmpty()
{
var resolver = MakeResolver(
"ID_Allegiance_SwearConfirmation",
fragments: ["Do you wish to swear to ", "?"],
variables: [DatStringResolver.PlayerVariable]);
Assert.Equal(
"Do you wish to swear to ?",
resolver.ResolveTemplate(
TableId,
"ID_Allegiance_SwearConfirmation",
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]
public void UnknownKeyResolvesNull()
{
var resolver = MakeResolver(
"ID_Allegiance_SwearConfirmation",
fragments: ["Do you wish to swear to ", "?"],
variables: [DatStringResolver.PlayerVariable]);
Assert.Null(resolver.ResolveTemplate(
TableId, "ID_Not_A_Key", new Dictionary<uint, string>()));
}
private static DatStringResolver MakeResolver(
string key,
string[] fragments,
uint[] variables)
{
var entry = new StringTableString();
foreach (string fragment in fragments)
entry.Strings.Add(fragment);
foreach (uint variable in variables)
entry.Variables.Add(variable);
var table = new StringTable { Id = TableId };
table.Strings[DatStringResolver.ComputeHash(key)] = entry;
return new DatStringResolver(new SingleTableSource(table));
}
/// <summary>Serves exactly one constructed StringTable through the
/// production <see cref="IDatReaderWriter"/> seam.</summary>
private sealed class SingleTableSource : IDatReaderWriter
{
private readonly StringTable _table;
public SingleTableSource(StringTable table) => _table = table;
public string SourceDirectory => string.Empty;
public IDatDatabase Portal => throw new NotSupportedException();
public IDatDatabase Cell => throw new NotSupportedException();
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
new(new Dictionary<uint, IDatDatabase>());
public IDatDatabase HighRes => throw new NotSupportedException();
public IDatDatabase Language => throw new NotSupportedException();
public IDatDatabase Local => throw new NotSupportedException();
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
new(new Dictionary<uint, uint>());
public int PortalIteration => 0;
public int CellIteration => 0;
public int HighResIteration => 0;
public int LanguageIteration => 0;
public bool TryGetFileBytes(
uint regionId,
uint fileId,
ref byte[] bytes,
out int bytesRead)
{
bytesRead = 0;
return false;
}
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
Array.Empty<IDatReaderWriter.IdResolution>();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public bool TrySave<T>(
uint regionId,
T obj,
int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj =>
fileId == _table.Id && _table is T match ? match : default;
public bool TryGet<T>(
uint fileId,
[MaybeNullWhen(false)] out T value) where T : IDBObj
{
if (fileId == _table.Id && _table is T match)
{
value = match;
return true;
}
value = default;
return false;
}
public void Dispose() { }
}
}