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>
202 lines
6.4 KiB
C#
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;
|
|
}
|
|
}
|