acdream/src/AcDream.App/UI/Layout/ItemAppraisalReport.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

202 lines
6.4 KiB
C#

using System.Numerics;
using System.Text;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Font-color indices selected by retail <c>ItemExamineUI</c>. The concrete
/// colors come from the item report's LayoutDesc property <c>0x1B</c>.
/// </summary>
public enum ItemAppraisalFontStyle
{
Normal = 0,
Beneficial = 1,
Detrimental = 2,
}
/// <summary>
/// Separator inserted before an appraisal fragment. This is the retained
/// equivalent of <c>ItemExamineUI::AddItemInfo</c>'s final argument.
/// </summary>
public enum ItemAppraisalSeparator
{
None,
Line,
Paragraph,
}
/// <summary>
/// One retail appraisal append operation: prose, separator, and authored
/// font-color index. Keeping these separate until shaping preserves style
/// when a long fragment wraps.
/// </summary>
public readonly record struct ItemAppraisalFragment(
string Text,
ItemAppraisalSeparator Separator,
ItemAppraisalFontStyle Style);
/// <summary>
/// Immutable item appraisal report in retail append order.
/// </summary>
public sealed class ItemAppraisalReport
{
public static ItemAppraisalReport Empty { get; } = new([]);
public ItemAppraisalReport(IReadOnlyList<ItemAppraisalFragment> fragments)
{
ArgumentNullException.ThrowIfNull(fragments);
Fragments = fragments;
}
public IReadOnlyList<ItemAppraisalFragment> Fragments { get; }
public bool IsEmpty => Fragments.Count == 0;
public override string ToString()
{
var text = new StringBuilder();
foreach (ItemAppraisalFragment fragment in Fragments)
{
if (text.Length != 0)
{
text.Append(fragment.Separator == ItemAppraisalSeparator.Paragraph
? "\n\n"
: "\n");
}
text.Append(fragment.Text);
}
return text.ToString();
}
}
/// <summary>
/// Port of <c>ItemExamineUI::AddItemInfo @ 0x004AC050/0x004ADCA0</c>.
/// Retail appends one newline when <paramref name="sameParagraph"/> is true,
/// two otherwise, then applies font DID index zero and the supplied color index.
/// </summary>
internal sealed class ItemAppraisalReportBuilder
{
private readonly List<ItemAppraisalFragment> _fragments = [];
public void Line(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> Add(value, sameParagraph: true, style);
public void Paragraph(
string value,
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
=> Add(value, sameParagraph: false, style);
/// <summary>
/// Preserve an empty retail <c>AddItemInfo("", ..., true)</c> append.
/// Once text exists this contributes one physical empty row; on an empty
/// glyph list retail has no preceding separator to append.
/// </summary>
public void BlankLine(
ItemAppraisalFontStyle style = ItemAppraisalFontStyle.Normal)
{
if (_fragments.Count == 0)
return;
_fragments.Add(new ItemAppraisalFragment(
string.Empty,
ItemAppraisalSeparator.Line,
style));
}
public ItemAppraisalReport Build()
=> _fragments.Count == 0
? ItemAppraisalReport.Empty
: new ItemAppraisalReport(_fragments.ToArray());
private void Add(
string value,
bool sameParagraph,
ItemAppraisalFontStyle style)
{
if (string.IsNullOrWhiteSpace(value))
return;
_fragments.Add(new ItemAppraisalFragment(
value,
_fragments.Count == 0
? ItemAppraisalSeparator.None
: sameParagraph
? ItemAppraisalSeparator.Line
: ItemAppraisalSeparator.Paragraph,
style));
}
}
/// <summary>
/// Width-aware projection of retail appraisal fragments into the retained
/// <see cref="UiText"/> line model.
/// </summary>
internal static class ItemAppraisalTextLayout
{
public static IReadOnlyList<UiText.Line> Shape(
UiText target,
ItemAppraisalReport report)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(report);
float maxWidth = Math.Max(1f, target.Width - (2f * target.Padding));
float Measure(string value)
=> target.DatFont?.MeasureWidth(value)
?? target.Font?.MeasureWidth(value)
?? value.Length * 8f;
var lines = new List<UiText.Line>();
foreach (ItemAppraisalFragment fragment in report.Fragments)
{
if (lines.Count != 0
&& fragment.Separator == ItemAppraisalSeparator.Paragraph)
{
lines.Add(new UiText.Line(string.Empty, ResolveColor(target, fragment.Style)));
}
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",
StringComparison.Ordinal);
foreach (string logicalLine in normalized.Split('\n'))
{
if (logicalLine.Length == 0)
{
lines.Add(new UiText.Line(string.Empty, color));
continue;
}
foreach (string wrapped in ChatTranscriptRenderer.WrapText(
logicalLine,
maxWidth,
Measure))
{
lines.Add(new UiText.Line(wrapped, color));
}
}
}
return lines;
}
private static Vector4 ResolveColor(
UiText target,
ItemAppraisalFontStyle style)
{
int index = (int)style;
return index >= 0 && index < target.FontColorPalette.Count
? target.FontColorPalette[index]
: target.DefaultColor;
}
}