fix(chargen): Campaign CC gate round 1 Batch C — rich text + labels + backdrops

Commit 1/3: chargen-scoped, low blast-radius fixes.

- New DatRichText helper: escape-normalize + word-wrap + per-segment
  palette color, porting UIElement_Text::SetStringInfoWithFont /
  AppendStringInfoWithFont's composition model. Routes the Heritage
  (GF-2), Town (GF-11a), and Profession (GF-3) description boxes
  through it instead of a raw unwrapped single-Line LinesProvider.
  Heritage headers use font-color palette index 1 (green), bodies
  index 0 (white), matching AppendStringInfoWithFont's own font-index
  argument. Town's diagnosed GF-11a root cause: a single un-wrapped
  line meant the town-specific suffix rendered past the clipped
  viewport, so switching towns looked like "text never changes" even
  though the underlying composed string genuinely differed.

- GF-3: bind the Profession page's description textbox (0x100003e0,
  gmCGProfessionPage::InitializePage @0x00483068) and compose its
  per-template text (UpdateProfession @0x004821b0's CustomText/
  BowText/SwashText/LifeText/WarText/WayText/SoldierText, plain
  SetStringInfo — no palette).

- GF-4: UiButton gains a coexisting ValueLabel/ValueBox/ValueFont/
  ValueColor slot alongside Label. Retail's chargen display buttons
  (avail/health/stamina/mana credits, 0x100003e2-e5/0x100003f9)
  author their caption directly on P0x17 AND carry a separate,
  media-less Type-12 value child that UiButton.ConsumesDatChildren
  used to drop entirely — pages substituted the button's own Label,
  destroying the caption. DatWidgetFactory.BuildButton now surfaces
  that child (gated on ReferenceEquals(labelInfo, info) — own-caption
  buttons only) instead. The six Profession slider name labels
  (0x100002ed, CharGenState::GetAttributeName @0x005C3A20's six
  hardcoded literals) resolve as UiButton in this port (live-DAT-
  measured Type 1 — retail's UIElement_Button is DynamicCast(0xc)-
  compatible with UIElement_Text) and are written once at
  construction, matching retail's own single InitializePage write.

- GF-6/AP-218: gmCGAppearancePage::Update writes a heritage-flavored
  STATIC caption to the Hair/Eyes/Skin spins (plain / GearText_* /
  OlthoiText_* variants) — never an index. Removed the prior 1-based-
  ordinal/gear-name substitution entirely; the other six spins keep
  their DAT-authored caption untouched, matching retail exactly.

- Root 1d: wire the Heritage (0x100003be, 13 states) and Profession
  (0x100003d8, 7 states) backdrop SetState cascades
  (gmCGHeritagePage::Update / gmCGProfessionPage::UpdateProfession).

- AP-216/AP-217 (partial, register updated honestly): swatches beyond
  the current part's real color count now hide (DoColorSpots' blank-
  blit half); the GradCircle now blanks for Eyes (DoGradDisk's blank-
  plug half). The "paint with the actual represented/current color"
  halves stay open — they need a PalSet/Palette-id -> RGB pipeline no
  chargen page reads at runtime yet, judged disproportionate to add
  alongside this batch's other ~10 fixes.

Register: AP-215 rewritten (item 2's "ordinal" framing is stale after
GF-6; restated as the icon-thumbnail gap), AP-216/AP-217 rewritten
(partially closed), AP-218 retired, AD-103 retired (the swallowed-
child Label substitution AD-103 tracked is replaced by ValueLabel's
own-geometry surfacing).

22 new tests (DatRichText unit tests, UiButton/DatWidgetFactory
ValueLabel tests, live-DAT structural pins, controller behavioral
tests) — all green. Full App suite (Release, live-DAT):
5300 passed / 1 pre-existing unrelated flake (PortalProjectionTests
allocation test, passes in isolation) / 3 skipped, up from the
baseline 5282/3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 12:18:53 +02:00
parent 7d09821fdc
commit 0591b9a026
13 changed files with 1018 additions and 66 deletions

View file

@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared multi-segment rich-text composer for the chargen description
/// boxes (Campaign CC gate round 1 Batch C — GF-2/GF-3/GF-11a, and the
/// Summary how-to text). Ports retail's
/// <c>UIElement_Text::SetStringInfoWithFont</c> /
/// <c>AppendStringInfoWithFont @ 0x00469D70</c> composition model: a text
/// box is built from an ORDERED list of string segments, each carrying its
/// OWN font-color palette index
/// (<c>UIElement_Text::AppendStringInfoWithFont</c>'s
/// <c>SetFontColorHelper</c> -&gt; <c>InqProperty(0x1B)</c> array lookup —
/// see <see cref="AcDream.App.UI.UiText.FontColorPalette"/>).
///
/// <para>
/// The description pages used to bypass this entirely: they assigned a raw
/// <c>LinesProvider</c> lambda returning ONE unwrapped <see cref="AcDream.App.UI.UiText.Line"/>
/// per composed string, with no escape-normalize and no word-wrap. Two
/// concrete symptoms this caused: literal two-character <c>"\n"</c>
/// escapes rendered as backslash-n instead of a real line break (the DAT
/// stores that literal escape — <c>DatWidgetFactory.BuildText</c>'s own
/// authored-string path already normalizes it for single-element authored
/// captions; this helper reproduces the SAME normalize for
/// runtime-composed multi-segment text), and — for the Town page
/// specifically — an unwrapped single line meant the town-specific SUFFIX
/// of the composed string rendered far outside the box's clipped viewport,
/// so switching towns looked like "the text never changes" even though the
/// underlying string genuinely did (only its INVISIBLE tail differed).
/// </para>
/// </summary>
internal static class DatRichText
{
/// <summary>One composed segment: text plus the color it should render
/// in. A null or empty <see cref="Text"/> is silently skipped (mirrors
/// retail's own null-string-info no-op guards throughout this text
/// composition family).</summary>
public readonly record struct Segment(string? Text, Vector4 Color);
/// <summary>
/// Escape-normalizes and word-wraps every segment (independently, so
/// each segment's wrapped lines keep ITS OWN color), then concatenates
/// the results in order. No separator is inserted between segments —
/// retail's own composition calls concatenate directly
/// (<c>AppendStringInfoWithFont</c>/<c>append_n_chars</c> with no
/// interposed literal), so any blank-line spacing between sections
/// comes from the authored DAT string content itself, not from code
/// here.
/// </summary>
public static IReadOnlyList<UiText.Line> Compose(
UiText target,
IReadOnlyList<Segment> segments)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(segments);
var lines = new List<UiText.Line>();
float maximumWidth = MathF.Max(1f, target.Width - 2f * target.Padding);
Func<string, float> measure = target.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
foreach (Segment segment in segments)
{
if (string.IsNullOrEmpty(segment.Text))
continue;
// The installed DAT stores the LITERAL two-character escape
// "\n" (0x5C 0x6E), not a real line break — same normalize
// DatWidgetFactory.BuildText's authored-string path already
// applies for single-element authored captions.
string normalized = segment.Text
.Replace("\\n", "\n")
.Replace("\r", string.Empty);
foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth))
lines.Add(new UiText.Line(wrapped, segment.Color));
}
return lines;
}
/// <summary>
/// Resolves <paramref name="target"/>'s own authored font-color
/// palette (dat property <c>0x1B</c>) entry at <paramref name="index"/>,
/// falling back to <paramref name="fallback"/> when the palette is
/// absent or too short. Mirrors the same fallback shape
/// <c>CharacterStatController.BuildSelectedTitleRuns</c> already uses
/// for its own palette-indexed colors.
/// </summary>
public static Vector4 PaletteColor(UiText target, int index, Vector4 fallback) =>
index >= 0 && index < target.FontColorPalette.Count
? target.FontColorPalette[index]
: fallback;
}