fix(chargen): Campaign CC gate round 1 Batch E — text origin, caption escapes, value rects, scrollbars, name prefill

R2-1/R2-6 (description-box text clipped left of the frame, regressed from
Batch C's frame un-consume): root cause was never the un-consume change
itself — the Heritage/Profession/Town/Summary description boxes
(0x100003C4/0x100003E0/0x10000409/0x10000404) all author retail's four
independent text-inset margins (dat properties 0x23-0x26,
UIElement_Text::OnSetAttribute cases 0xf-0x12: margL=9/margR=26/margU=15/
margD=15), which this codebase never read at all, before or after Batch C.
Un-consuming the gold-frame children just made the pre-existing missing-
margin bug visible for the first time (the frame's own left border now
draws around the same x=0 origin text always used). Fixed end to end:
ElementInfo.MarginLeft/Right/Top/Bottom (read in
ApplyCanonicalLegacyProjection, propagated in Merge), UiText.MarginLeft/
Right/Top/Bottom (additive with the pre-existing Padding), a new pure
UiText.ContentOffsetX static consumed by the multi-line draw path's
per-line placement, and matching wrap-width shrinkage in
DatRichText.Compose and BuildText's own authored-multiline path. Scoped to
the multi-line (non-OneLine) path only.

R2-2/R2-3 (Attribute\n Credits renders the literal backslash-n; the live
credit value overlaps mid-caption): two stacked gaps. (1) UiButton
captions never escape-normalized the DAT's literal "\n" — centralized the
normalize into DatWidgetFactory's ResolveAuthoredString (the one choke
point every P0x17 resolution already shares) plus a NormalizeEscapes
helper for the per-state caption loop, so every caller normalizes
identically. (2) UiButton.Label only ever drew one line — retail's
UIElement_Button IS a UIElement_Text with OneLine=false on these buttons,
so a caption should word-wrap/stack like any other Type-12 box. Added
UiButton.DrawBlockLabel + the pure, unit-tested WrapBlockLines. The
value-overlap itself: ValueBox was never wrong (live-DAT-measured correct
child rects) — the caption was drawing unconfined across the button's
full width ("Available Skill Credits" measures 193px in a 231px button
whose value box starts at x=116). Fixed by confining the caption's own
drawable width to stop before ValueBox.X whenever a ValueLabel coexists.

R2-7a (Summary overview listbox missing its scrollbar): pure wiring gap —
the listbox authors a linked scrollbar via dat property 0x72
(ScrollbarElementId=0x10000401) that CharacterCreationSummaryPage's
constructor never resolved, unlike every other UiTemplateListBox owner in
the codebase. Fixed with the same resolve-and-wire pattern.

R2-7b (how-to box scrollbar overlaps text, no thumb): traced to a
downstream symptom of R2-1, not an independent bug — UiScrollbar only
paints its thumb when the linked model has overflow, and the pre-fix wrap
width (un-inset) produced fewer/shorter lines than fit the view. Pinned
directly against the real installed strings/font (Aluvian's how-to text)
that the margin-correct width overflows. No UiScrollbar code changed.

R2-8 (name field should show "[ Name ]"): re-checked the one hypothesis
Batch A's GF-15 closure left open — an authored initial-text string on
the field's own P0x17. Confirmed absent on every state in the installed
DAT. No code change; Batch A's closure stands, now pinned as a live-DAT
regression test.

App suite 5334/3 (was 5321/3, +13, zero regressions). Runtime 1735/0
unchanged. Full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 14:07:19 +02:00
parent 2ad805469d
commit e24ec20882
11 changed files with 895 additions and 23 deletions

View file

@ -483,11 +483,23 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
float boxY = LabelBox?.Y ?? 0f;
float boxWidth = LabelBox?.Width ?? Width;
float boxHeight = LabelBox?.Height ?? Height;
float tx = LabelAlign == LabelAlignment.Left
? boxX + LabelOffsetX
: boxX + (boxWidth - lf.MeasureWidth(label)) * 0.5f; // centered (default)
float ty = boxY + (boxHeight - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
// R2-2/R2-3 (Campaign CC gate round 1 Batch E): when this button
// ALSO carries a coexisting ValueLabel (GF-4a's own-caption +
// separate value slot — the Profession attribute/health/stamina/
// mana credits buttons, the Skills credits button), the caption's
// own drawable region stops before the value's authored rect
// starts. LabelBox and ValueBox are mutually exclusive by
// construction (DatWidgetFactory.BuildButton only ever sets one
// or the other), so this never fights GF-11c's own LabelBox
// confinement above. Live-DAT-measured: "Available Skill Credits"
// is 193px wide in the Skills credits button's 231px-wide box
// whose value box starts at local x=116 — without this, the live
// credits number draws on top of the caption's own tail.
if (ValueBox is { X: var valueBoxX } && valueBoxX > boxX)
boxWidth = MathF.Min(boxWidth, valueBoxX - boxX);
DrawBlockLabel(ctx, label, lf, LabelColor, boxX, boxY, boxWidth, boxHeight, LabelAlign, LabelOffsetX);
}
if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf)
@ -517,6 +529,112 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
}
/// <summary>
/// R2-2 (Campaign CC gate round 1 Batch E): retail's <c>UIElement_Button</c>
/// IS a <c>UIElement_Text</c> (<c>struct UIElement_Button : UIElement_Text</c>,
/// <c>acclient.h</c>) — these captions author <c>OneLine=false</c>
/// (live-DAT-probe-confirmed on 0x100003e2-e5/0x100003f9), so a caption
/// that carries an authored newline (already normalized to a real
/// <c>'\n'</c> by <see cref="Layout.DatWidgetFactory"/>'s shared
/// <c>ResolveAuthoredString</c>) OR simply doesn't fit
/// <paramref name="boxWidth"/> lays out as multiple stacked lines, using
/// the SAME word-wrap <see cref="UiText.WrapWords"/> any other Type-12
/// text box uses. A single line that already fits draws with byte-
/// identical geometry to the pre-fix unconditional one-line math (same
/// centered-block Y, same tx formula) — this is a strict superset, not a
/// behavior change, for every button whose caption was already short
/// enough to fit on one line.
/// </summary>
private void DrawBlockLabel(
UiRenderContext ctx,
string text,
UiDatFont font,
Vector4 color,
float boxX,
float boxY,
float boxWidth,
float boxHeight,
LabelAlignment align,
float leftOffset)
{
IReadOnlyList<(string Text, float X, float Y)> lines = WrapBlockLines(
text, font.MeasureWidth, font.LineHeight,
boxX, boxY, boxWidth, boxHeight, align, leftOffset);
// A multi-line result clips to its own box — the button's normal
// draw has no ambient clip, and an oversized wrapped caption (e.g.
// the Skills credits button's own tight 28px height) should be cut
// off at the box edge rather than spill into whatever sits below the
// button, matching every other clipped Type-12 text box in this
// codebase (UiText.DrawText's own PushClip). Single-line captions —
// the overwhelming majority — never pay this cost.
bool clip = lines.Count > 1;
if (clip)
ctx.PushClip(boxX, boxY, boxWidth, boxHeight);
try
{
foreach ((string line, float tx, float ty) in lines)
ctx.DrawStringDat(font, line, tx, ty, color, Outline, OutlineColor);
}
finally
{
if (clip)
ctx.PopClip();
}
}
/// <summary>
/// Pure geometry half of <see cref="DrawBlockLabel"/> — normalized
/// newline split + word-wrap (<see cref="UiText.WrapWords"/>) to
/// <paramref name="boxWidth"/>, then block-centered vertically within
/// <paramref name="boxHeight"/>. Pulled out as a static/pure method
/// (same shape as <see cref="UiText.ContentOffsetX"/>) so the wrap/
/// confinement math is unit-testable without a font atlas or draw
/// context — <paramref name="measureWidth"/> takes the place of
/// <see cref="UiDatFont.MeasureWidth(string)"/>.
/// </summary>
internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines(
string text,
Func<string, float> measureWidth,
float lineHeight,
float boxX,
float boxY,
float boxWidth,
float boxHeight,
LabelAlignment align,
float leftOffset)
{
float availableWidth = MathF.Max(
1f,
boxWidth - (align == LabelAlignment.Left ? leftOffset : 0f));
var lines = new List<string>();
foreach (string paragraph in text.Split('\n'))
{
if (measureWidth(paragraph) <= availableWidth)
{
lines.Add(paragraph);
continue;
}
lines.AddRange(UiText.WrapWords(paragraph, measureWidth, availableWidth));
}
float totalHeight = lines.Count * lineHeight;
float startY = boxY + (boxHeight - totalHeight) * 0.5f;
var result = new List<(string, float, float)>(lines.Count);
for (int i = 0; i < lines.Count; i++)
{
string line = lines[i];
float tx = align == LabelAlignment.Left
? boxX + leftOffset
: boxX + (boxWidth - measureWidth(line)) * 0.5f;
float ty = startY + i * lineHeight;
result.Add((line, tx, ty));
}
return result;
}
private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
{
if (file == 0 || rect.Width <= 0 || rect.Height <= 0)