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

@ -15,14 +15,14 @@ acdream GradCircle vs retail's color wheel).
rich-text/un-consume changes moved the text draw origin to the rich-text/un-consume changes moved the text draw origin to the
element's outer rect where retail insets to an interior text region element's outer rect where retail insets to an interior text region
(authored margins or interior-relative origin). PIN IT with a probe (authored margins or interior-relative origin). PIN IT with a probe
before fixing. before fixing. — **FIXED (Batch E), see below.**
- **R2-2: `Attribute\n Credits` renders the LITERAL `\n`** (UiButton - **R2-2: `Attribute\n Credits` renders the LITERAL `\n`** (UiButton
captions never escape-normalize — only BuildText does), AND the value captions never escape-normalize — only BuildText does), AND the value
("24") overlaps the caption text — the ValueLabel is not drawing in ("24") overlaps the caption text — the ValueLabel is not drawing in
its authored child rect. its authored child rect. — **FIXED (Batch E), see below.**
- **R2-3: Skills "Available Skill Credits" value overlaps mid-caption** - **R2-3: Skills "Available Skill Credits" value overlaps mid-caption**
("Available Skill Credit0Credits") — same ValueLabel-rect family as ("Available Skill Credit0Credits") — same ValueLabel-rect family as
R2-2. R2-2. — **FIXED (Batch E), see below.**
- **R2-4: Skills page functional gaps (retail screenshots 5-6):** - **R2-4: Skills page functional gaps (retail screenshots 5-6):**
(a) rows are NOT selectable — retail selection turns the row brighter (a) rows are NOT selectable — retail selection turns the row brighter
white AND writes the skill's info into the lower-left description box white AND writes the skill's info into the lower-left description box
@ -32,22 +32,182 @@ acdream GradCircle vs retail's color wheel).
(b) NOT divided into the four retail buckets (Specialized / Trained / (b) NOT divided into the four retail buckets (Specialized / Trained /
Useable Untrained / Unuseable Untrained with headers) — the user's Useable Untrained / Unuseable Untrained with headers) — the user's
gate OVERTURNS AP-213's remaining flat-list half: implement the gate OVERTURNS AP-213's remaining flat-list half: implement the
buckets; (c) the skill list's scrollbar is missing. buckets; (c) the skill list's scrollbar is missing. — OUT OF SCOPE for
Batch E (functional gap, not text layout); still open.
- **R2-5: the color wheel renders as static authored art** (mirror-like - **R2-5: the color wheel renders as static authored art** (mirror-like
disc) where retail shows the gradient wheel + gold swatch dots that disc) where retail shows the gradient wheel + gold swatch dots that
CHANGE per selected part — the user's gate PROMOTES AP-216/AP-217's CHANGE per selected part — the user's gate PROMOTES AP-216/AP-217's
remaining halves (real palette-color swatch rendering + gradient tint) remaining halves (real palette-color swatch rendering + gradient tint)
from partial-closed to must-port. from partial-closed to must-port. — OUT OF SCOPE for Batch E; still
- **R2-6: Town description text misaligned** — R2-1 family. open.
- **R2-6: Town description text misaligned** — R2-1 family. — **FIXED
(Batch E)**, same shared mechanism as R2-1.
- **R2-7: Summary — (a) text misaligned (R2-1); (b) the summary OVERVIEW - **R2-7: Summary — (a) text misaligned (R2-1); (b) the summary OVERVIEW
listbox is missing its scrollbar; (c) the how-to box's scrollbar listbox is missing its scrollbar; (c) the how-to box's scrollbar
renders but OVERLAPS the text area and has no thumb ("slider").** renders but OVERLAPS the text area and has no thumb ("slider").** —
**FIXED (Batch E), see below** — (a) via the shared R2-1 mechanism;
(b) the listbox's own `0x72` scrollbar linkage was simply never wired
(every other `UiTemplateListBox` owner in the codebase already does
this — this page was the one holdout); (c) traced to a DOWNSTREAM
symptom of R2-1, not an independent bug — see the Batch E write-up for
the full geometric argument.
- **R2-8: the name field should show `[ Name ]`** — the user re-asserts - **R2-8: the name field should show `[ Name ]`** — the user re-asserts
retail's prefill. Batch A verified retail's CODE never writes it; the retail's prefill. Batch A verified retail's CODE never writes it; the
UNCHECKED hypothesis is the field's AUTHORED initial text (`P0x17` on UNCHECKED hypothesis is the field's AUTHORED initial text (`P0x17` on
`0x10000402`) — probe the DAT; if authored, render authored initial `0x10000402`) — probe the DAT; if authored, render authored initial
text (display-only; the committed state name stays empty, retail's text (display-only; the committed state name stays empty, retail's
NameInputFilter forbids `[` so it can never be committed as a name). NameInputFilter forbids `[` so it can never be committed as a name). —
**RE-CHECKED (Batch E): NOT authored** — see below. Batch A's closure
stands; no code change.
**Batch E (gate round 1, text layout correctness) is CODE-COMPLETE
2026-08-16, pending the user's visual gate.** R2-1/R2-2/R2-3/R2-6/R2-7 are
fixed at the mechanism level (no per-page nudges); R2-8 was re-checked and
confirmed NOT a code change. R2-4/R2-5 are explicitly out of scope
(functional gaps, not text layout) and remain open for a later round.
- **R2-1/R2-6 root cause CONFIRMED, not the un-consume change itself:**
live-DAT-probed against the installed EoR dat, the Heritage/Profession/
Town/Summary description boxes (`0x100003C4`/`0x100003E0`/`0x10000409`/
`0x10000404`) all author retail's four independent text-inset margins
(dat properties `0x23`/`0x24`/`0x25`/`0x26` — `UIElement_Text::
OnSetAttribute @0x0046a640` cases `0xf`-`0x12`, i.e.
`BaseProperty::GetPropertyName(arg2) - 0x14`, writing `m_margL`/
`m_margR`/`m_margU`/`m_margD`): `margL=9, margR=26, margU=15, margD=15`
on every one of the four boxes (one shared authored template). This
codebase never read those four properties AT ALL, before OR after Batch
C — `UiText.Padding` (the only inset this port had) always defaults to
0 for DAT-built text, so every box's text drew flush against x=0
regardless of batch. The regression's actual TRIGGER was Batch C
un-consuming the gold-frame children (previously silently dropped): the
frame's own left border piece (`0x100002DE`/`0x100000E8`, live-DAT-
measured ~0-34px wide) now draws on top of/around the SAME x=0 origin
text has ALWAYS used, making the pre-existing missing-margin bug visible
for the first time. Fixed by adding the four margin properties end to
end: `ElementInfo.MarginLeft/Right/Top/Bottom` (read in
`ElementReader.ApplyCanonicalLegacyProjection`, propagated in `Merge`
with the same "non-zero derived wins" convention as `FontDid`), new
`UiText.MarginLeft/Right/Top/Bottom` properties (additive with the
pre-existing `Padding`, seeded by `DatWidgetFactory.BuildText`), and a
new pure `UiText.ContentOffsetX` static (mirrors `ContentBaseY`/
`VOffset`'s own shape) consumed by the multi-line scrollable draw path's
per-line horizontal placement. `DatRichText.Compose`'s and
`DatWidgetFactory.BuildText`'s own authored-multiline wrap-width
formulas both shrink by the same `Padding+MarginLeft`/
`Padding+MarginRight` inset — the wrap half of the regression (text
also overflowing the visible RIGHT edge, not just clipping on the left).
Deliberately scoped to the multi-line (non-`OneLine`) path only — the
static Centered/RightAligned/OneLine single-line branches keep their
pre-fix bare-`Padding` math, since every currently-broken box is
multi-line and touching those paths too would widen this fix's blast
radius with no known-broken target. R2-1's finding also named
"Appearance" — no Appearance-page description box exists in this
codebase (only Heritage/Profession/Town/Summary call
`DatRichText.Compose`); read as either a recollection slip or referring
to a page that will inherit this same fix automatically once/if it ever
grows one, since the fix lives in the shared `UiText`/`DatRichText`
mechanism, not per-page code.
- **R2-2/R2-3 root cause CONFIRMED, two stacked gaps:** (1) `UiButton`
captions never escape-normalized the DAT's literal two-character `\n`
escape — only `DatWidgetFactory.BuildText`'s own authored-string path
did. Centralized the normalize into the ONE choke point every P0x17
caption resolution in `DatWidgetFactory.cs` already shares
(`ResolveAuthoredString`, plus a `NormalizeEscapes` helper for the
per-STATE caption loop that resolves a state's own `0x17` directly) —
every caller (`BuildText`, `BuildButton`'s own caption AND its lifted-
child caption, `BuildButton`'s coexisting `ValueLabel`, `BuildCheckbox`,
the per-state caption swap) now normalizes identically, closing the
exact "some callers normalize, some don't" class of bug that caused
this regression in the first place. (2) `UiButton.Label` only ever drew
ONE line, unconditionally — but retail's `UIElement_Button` IS a
`UIElement_Text` (`struct UIElement_Button : UIElement_Text`,
`acclient.h`) and these captions author `OneLine=false`
(live-DAT-probe-confirmed on `0x100003e2-e5`/`0x100003f9`), so a
caption that carries a newline OR simply doesn't fit its available
width should lay out as multiple stacked lines, the same word-wrap
every other Type-12 text box already gets (`UiText.WrapWords`). Added
`UiButton.DrawBlockLabel`/the pure, unit-tested `UiButton.WrapBlockLines`
extraction. The VALUE-overlap half specifically (R2-2's "24dits", R2-3's
"Credit0Credits"): `ValueBox` itself was NEVER null/wrong — live-DAT-
measured, both buttons' value children (`0x100002F1`/`0x100002F3`
family) resolve correctly. The overlap was the CAPTION drawing
unconfined across the button's FULL width (`Available Skill Credits`
measures 193px in the Skills button's 231px-wide box whose value box
starts at local x=116 — the caption's own unwrapped single-line render
reached x≈196, well past the value's territory). Fixed by confining the
caption's OWN drawable width to stop before `ValueBox.X` whenever a
`ValueLabel` coexists (`LabelBox`/`ValueBox` are mutually exclusive by
construction, so this never fights GF-11c's own `LabelBox` confinement).
A single-line caption that already fits draws with byte-identical
geometry to the pre-fix math — the fix is a strict superset for every
already-correct button caption in the client.
- **R2-7a root cause CONFIRMED — pure wiring gap, same shape as every
other holdout in this codebase:** the Summary OVERVIEW listbox
(`0x10000400`) authors a linked scrollbar via dat property `0x72`
(live-DAT-probe-confirmed `ScrollbarElementId=0x10000401`, a SIBLING
element, not a descendant of the listbox). Every OTHER
`UiTemplateListBox` owner in this codebase (`SocialFriendsPageController`,
`ConfigOptionsPageController`, the Fellowship/Allegiance/Squelch pages)
already resolves `ScrollbarElementId` against its page root and wires
`.Model = listBox.Scroll``CharacterCreationSummaryPage`'s
constructor was the one holdout that only ever wired the HOW-TO box's
own scrollbar (Batch C Commit 3) and never resolved this one. Fixed by
adding the identical resolve-and-wire block to the constructor.
- **R2-7b root cause CONFIRMED as a DOWNSTREAM SYMPTOM of R2-1, not an
independent defect** — investigated, not assumed: `UiScrollbar`'s own
draw path only paints the thumb `if (m.HasOverflow)`
(`ContentHeight > ViewHeight` on the linked `UiScrollable`). Before the
R2-1 fix, the how-to box's wrap width used the box's raw, un-inset
Width (247px) instead of the authored margin-inset content width
(247-9-26=212px) — a WIDER wrap width produces FEWER/SHORTER lines,
which can leave `ContentHeight <= ViewHeight` (no overflow → the thumb
legitimately has nothing to gate on and correctly draws nothing). Pinned
directly against the real installed strings/font (Aluvian's how-to
text, the longest composed variant — `SummaryHowTo` + the male name-
suggestion list + `SummaryHowToEnd` — at the box's real font,
`0x40000009`): composed with the CORRECT margin-inset width, the
content (multiple lines × the font's line height) exceeds the
margin-inset view height, so `HasOverflow` is true and the thumb draws.
No `UiScrollbar` code changed — this is a full explanation, not a
guess: the "overlapping the text area" half of R2-7b's report likely
reflects a genuine but minor (~7-9px) crowding between the scrollbar's
own anchor-reflowed position (`UiLayoutPolicy`, retail's raw-edge
system — verified this reflow mechanism itself works correctly, both
via `UiElement.ApplyAnchor`'s per-frame call and hand-computed against
the box's real 100x100 design-time template) and the box's authored
26px right margin; this is within the authored geometry's own
tolerance and was NOT changed, since inventing a new pixel offset here
would be exactly the guessing this project's workflow forbids. Flagged
for the user's own re-check once the thumb is visible — it may no
longer be perceptible/relevant now that the box's own interior boundary
has moved too.
- **R2-8 RE-CHECKED, CONFIRMED NOT AUTHORED — Batch A's closure stands.**
Probed the installed EoR dat directly for `0x10000402`'s own `P0x17`
property (the SAME authored-caption mechanism `DatWidgetFactory`
already reads for every other element): absent on the default state
AND on every one of the field's named states. Batch A's GF-15 closure
already byte-verified retail's CODE never writes the prefill
(`CharGenState::RandomizeCharacter`, `gmCGSummaryPage::InitializePage`);
this batch closes the remaining unchecked half (the DAT-authored-
initial-text hypothesis) the same way — negative. No code change;
pinned as a live-DAT regression test
(`SummaryNameField_AuthorsNoP0x17OnAnyState`) so a future DAT re-extract
or a future guess can't silently reintroduce the wrong fix shape.
Fixture + live-DAT tests only this round (no graphical client launch).
App suite 5334/3 (was 5321/3, +13, zero regressions): +1 `DatRichText`
wrap-width-with-margins test, +3 `UiText.ContentOffsetX` tests, +5
`UiButton`/`DatWidgetFactory` tests (`WrapBlockLines` × 3, the value-box
confinement shape, the escape-normalize regression), +4 live-DAT tests
(the Heritage margin/first-line-origin pin, the Summary listbox scrollbar
wiring, the Aluvian how-to overflow proof, the name-field no-P0x17 pin).
Runtime 1735/0 unchanged. Full solution Release build green (0 errors).
Blast radius swept: `UiText.MarginLeft/Right/Top/Bottom` default to 0 and
are ADDITIVE with the pre-existing `Padding`, so every DAT-imported
multi-line text box that does NOT author properties `0x23`-`0x26` (the
overwhelming majority client-wide, including chat and the main game UI)
is byte-identical to before this fix — confirmed by the unchanged full
App suite pass count outside this batch's own new tests.
**MILESTONE (2026-08-16, post-Batch-A build `1.0.2-cc.g`): the user **MILESTONE (2026-08-16, post-Batch-A build `1.0.2-cc.g`): the user
completed the FIRST LIVE CHARACTER CREATE from acdream against local ACE — completed the FIRST LIVE CHARACTER CREATE from acdream against local ACE —
@ -250,6 +410,14 @@ ISSUES.md; this doc is the six-page batch.
acdream's existing (correct) behavior; the `[ Name` the user saw was acdream's existing (correct) behavior; the `[ Name` the user saw was
most likely the field's own bracket-style empty-state chrome (GF-2/GF-12 most likely the field's own bracket-style empty-state chrome (GF-2/GF-12
textbox-decoration family), not a missing name-prefill feature. textbox-decoration family), not a missing name-prefill feature.
**Re-checked at Batch E (R2-8) against the ONE hypothesis this note
left unchecked** — an authored initial-text string on the field's own
dat property `0x17`, the SAME mechanism `DatWidgetFactory` reads for
every other element's caption — and confirmed ABSENT on the field's
default state and every named state alike, live-DAT-probed against the
installed EoR dat. This closure now covers both the CODE half (this
paragraph) and the AUTHORED-DATA half (Batch E); no further hypothesis
remains unchecked.
## Presentation families (retail parity) ## Presentation families (retail parity)

View file

@ -157,6 +157,24 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
if (_list is not null) if (_list is not null)
_list.TemplateResolver = templateResolver; _list.TemplateResolver = templateResolver;
// R2-7a (Campaign CC gate round 1 Batch E): the listbox's own linked
// scrollbar (dat property 0x72, ScrollId 0x10000401 — a SIBLING
// element, not a descendant of the listbox itself) was never wired
// to UiTemplateListBox.Scroll. Every other UiTemplateListBox owner in
// this codebase (SocialFriendsPageController, ConfigOptionsPageController,
// the Fellowship/Allegiance/Squelch pages) resolves
// ScrollbarElementId against the page root the SAME way — this page
// was the one holdout that never did.
if (_list is not null)
{
uint scrollbarElementId = _list.ScrollbarElementId;
if (scrollbarElementId != 0
&& UiElement.FindDescendant(pageRoot, scrollbarElementId) is UiScrollbar overviewScroll)
{
overviewScroll.Model = _list.Scroll;
}
}
_nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField; _nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField;
if (_nameField is not null) if (_nameField is not null)
{ {

View file

@ -58,7 +58,15 @@ internal static class DatRichText
ArgumentNullException.ThrowIfNull(segments); ArgumentNullException.ThrowIfNull(segments);
var lines = new List<UiText.Line>(); var lines = new List<UiText.Line>();
float maximumWidth = MathF.Max(1f, target.Width - 2f * target.Padding); // R2-1 (Campaign CC gate round 1 Batch E): the wrap width must shrink
// by the SAME left+right inset the draw path now applies (Padding
// plus the four retail margins, UiText.MarginLeft's own doc) — the
// Batch-C regression's second half: text wasn't just drawing at the
// wrong X, it was also wrapping to the FULL box width instead of the
// authored interior width, overflowing the visible right edge too.
float maximumWidth = MathF.Max(
1f,
target.Width - (target.Padding + target.MarginLeft) - (target.Padding + target.MarginRight));
Func<string, float> measure = target.DatFont is { } font Func<string, float> measure = target.DatFont is { } font
? font.MeasureWidth ? font.MeasureWidth
: static value => value.Length * 8f; : static value => value.Length * 8f;

View file

@ -730,6 +730,14 @@ public static class DatWidgetFactory
// ElementInfo.Outline's own default, so this is a no-op for the ~99% of text // ElementInfo.Outline's own default, so this is a no-op for the ~99% of text
// elements that don't author it. // elements that don't author it.
Outline = info.Outline, Outline = info.Outline,
// R2-1 (Campaign CC gate round 1 Batch E): the four text-inset
// margins (dat properties 0x23-0x26 — MarginLeft's own doc
// comment on UiText). Default 0 — a no-op for every element that
// doesn't author them (only consumed by the multi-line path).
MarginLeft = info.MarginLeft,
MarginRight = info.MarginRight,
MarginTop = info.MarginTop,
MarginBottom = info.MarginBottom,
}; };
t.ConfigureDatState(info); t.ConfigureDatState(info);
@ -781,7 +789,12 @@ public static class DatWidgetFactory
cachedWidth = t.Width; cachedWidth = t.Width;
cachedFont = t.DatFont; cachedFont = t.DatFont;
cachedColor = t.DefaultColor; cachedColor = t.DefaultColor;
float maximumWidth = Math.Max(1f, t.Width - 2f * t.Padding); // R2-1: shrink by BOTH Padding and the four retail
// margins — see DatRichText.Compose's own comment on
// the same formula.
float maximumWidth = Math.Max(
1f,
t.Width - (t.Padding + t.MarginLeft) - (t.Padding + t.MarginRight));
Func<string, float> measure = t.DatFont is { } font Func<string, float> measure = t.DatFont is { } font
? font.MeasureWidth ? font.MeasureWidth
: static value => value.Length * 8f; : static value => value.Length * 8f;
@ -810,7 +823,8 @@ public static class DatWidgetFactory
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption) || !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|| stateCaption.Kind != UiPropertyKind.StringInfo) || stateCaption.Kind != UiPropertyKind.StringInfo)
continue; continue;
if (stringResolve?.Invoke(stateCaption.StringInfoValue) is { Length: > 0 } text) if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue))
is { Length: > 0 } text)
(stateStrings ??= new Dictionary<uint, string>())[stateId] = text; (stateStrings ??= new Dictionary<uint, string>())[stateId] = text;
} }
if (stateStrings is not null) if (stateStrings is not null)
@ -1028,6 +1042,32 @@ public static class DatWidgetFactory
|| !info.TryGetEffectiveProperty(0x17u, out var property) || !info.TryGetEffectiveProperty(0x17u, out var property)
|| property.Kind != UiPropertyKind.StringInfo) || property.Kind != UiPropertyKind.StringInfo)
return null; return null;
return stringResolve(property.StringInfoValue); string? resolved = stringResolve(property.StringInfoValue);
// R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL
// two-character escape "\n" (0x5C 0x6E), not a real line break — same
// fact BuildText's own authored-string path already normalized for
// (see that call site's own comment). Centralizing the normalize
// HERE, at the single choke point every P0x17 caption resolution in
// this file goes through (BuildText, BuildButton's own caption AND
// its lifted-child caption, BuildButton's coexisting ValueLabel,
// BuildCheckbox), closes the exact class of bug R2-2 found: a caption
// like the Profession credits button's own "Attribute\n Credits"
// rendered the literal backslash-n because BuildButton never
// normalized while BuildText did. BuildText's own subsequent
// Replace("\\n","\n") is now a harmless no-op (idempotent) — left in
// place rather than removed, since it costs nothing and documents the
// same fact locally.
return NormalizeEscapes(resolved);
} }
/// <summary>
/// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize
/// <see cref="ResolveAuthoredString"/> applies, pulled out so the
/// per-STATE authored-caption loop below (which resolves a state's own
/// <c>0x17</c> directly, bypassing the effective-property resolution
/// <see cref="ResolveAuthoredString"/> wraps) gets the SAME normalize
/// instead of a second, easily-forgotten copy.
/// </summary>
private static string? NormalizeEscapes(string? raw) =>
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
} }

View file

@ -245,6 +245,27 @@ public sealed class ElementInfo
/// </summary> /// </summary>
public bool Invisible; public bool Invisible;
/// <summary>
/// Campaign CC gate round 1 Batch E (R2-1): the four independent
/// <c>UIElement_Text</c> text-inset margins, dat properties
/// <c>0x23</c>/<c>0x24</c>/<c>0x25</c>/<c>0x26</c> (IntegerBaseProperty
/// — <c>UIElement_Text::OnSetAttribute @0x0046a640</c> cases
/// <c>0xf</c>/<c>0x10</c>/<c>0x11</c>/<c>0x12</c>, i.e.
/// <c>BaseProperty::GetPropertyName(arg2) - 0x14</c>, writing
/// <c>m_margL</c>/<c>m_margR</c>/<c>m_margU</c>/<c>m_margD</c>). Ctor
/// default is 0 on all four (<c>UIElement_Text::UIElement_Text
/// @0x004686d1-0046872d</c> clears them before any authored value
/// applies). The chargen description boxes author <c>margL=9,
/// margR=26, margU=15, margD=15</c> (live-DAT-probe-confirmed on
/// <c>0x100003C4</c>/<c>0x100003E0</c>/<c>0x10000409</c>/
/// <c>0x10000404</c>) — this codebase never read these four
/// properties before this fix, so every DAT-imported <c>UiText</c>
/// drew flush against its own outer rect (<c>Padding</c> alone,
/// always 0 for DAT-built text) regardless of what the DAT actually
/// authored.
/// </summary>
public int MarginLeft, MarginRight, MarginTop, MarginBottom;
/// <summary> /// <summary>
/// Resolves a property for a state using retail's DirectState-as-base rule. A /// Resolves a property for a state using retail's DirectState-as-base rule. A
/// named state's key overrides DirectState by presence, including false/zero. /// named state's key overrides DirectState by presence, including false/zero.
@ -421,6 +442,15 @@ public static class ElementReader
Outline = derived.Outline || base_.Outline, Outline = derived.Outline || base_.Outline,
// OutlineColor: same "non-null derived wins" rule as FontColor. // OutlineColor: same "non-null derived wins" rule as FontColor.
OutlineColor = derived.OutlineColor ?? base_.OutlineColor, OutlineColor = derived.OutlineColor ?? base_.OutlineColor,
// R2-1: margins follow the same "non-zero derived wins" convention as
// FontDid/ZLevel above — a derived element that authors no margin
// property (0 is ApplyCanonicalLegacyProjection's own unset default,
// matching retail's ctor-cleared default too) inherits the base
// prototype's margin instead of silently zeroing it out.
MarginLeft = derived.MarginLeft != 0 ? derived.MarginLeft : base_.MarginLeft,
MarginRight = derived.MarginRight != 0 ? derived.MarginRight : base_.MarginRight,
MarginTop = derived.MarginTop != 0 ? derived.MarginTop : base_.MarginTop,
MarginBottom = derived.MarginBottom != 0 ? derived.MarginBottom : base_.MarginBottom,
// DefaultStateName: derived wins if set; otherwise inherit the base's default. // DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// This helper merges one element snapshot only. LayoutImporter separately // This helper merges one element snapshot only. LayoutImporter separately
@ -526,6 +556,20 @@ public static class ElementReader
} }
} }
// R2-1 (Campaign CC gate round 1 Batch E): the four text-inset margins
// (0x23 Left / 0x24 Right / 0x25 Up / 0x26 Down, IntegerBaseProperty —
// see MarginLeft's own doc comment for the decomp anchor). Absent
// properties leave the ElementInfo default of 0, matching retail's
// ctor-cleared default.
if (info.TryGetEffectiveInteger(0x23u, out int marginLeft))
info.MarginLeft = marginLeft;
if (info.TryGetEffectiveInteger(0x24u, out int marginRight))
info.MarginRight = marginRight;
if (info.TryGetEffectiveInteger(0x25u, out int marginTop))
info.MarginTop = marginTop;
if (info.TryGetEffectiveInteger(0x26u, out int marginBottom))
info.MarginBottom = marginBottom;
// Tab table (0x2E): array of StructBaseProperty (MasterPropertyId 0x2F) — the // Tab table (0x2E): array of StructBaseProperty (MasterPropertyId 0x2F) — the
// Type-8 tab control's authored {button element, page element, isDefault} rows // Type-8 tab control's authored {button element, page element, isDefault} rows
// (docs/research/2026-08-10-options-panel-structure.md §1.3). Recomputed fresh // (docs/research/2026-08-10-options-panel-structure.md §1.3). Recomputed fresh

View file

@ -483,11 +483,23 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
float boxY = LabelBox?.Y ?? 0f; float boxY = LabelBox?.Y ?? 0f;
float boxWidth = LabelBox?.Width ?? Width; float boxWidth = LabelBox?.Width ?? Width;
float boxHeight = LabelBox?.Height ?? Height; float boxHeight = LabelBox?.Height ?? Height;
float tx = LabelAlign == LabelAlignment.Left
? boxX + LabelOffsetX // R2-2/R2-3 (Campaign CC gate round 1 Batch E): when this button
: boxX + (boxWidth - lf.MeasureWidth(label)) * 0.5f; // centered (default) // ALSO carries a coexisting ValueLabel (GF-4a's own-caption +
float ty = boxY + (boxHeight - lf.LineHeight) * 0.5f; // separate value slot — the Profession attribute/health/stamina/
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor); // 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) 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) private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
{ {
if (file == 0 || rect.Width <= 0 || rect.Height <= 0) if (file == 0 || rect.Width <= 0 || rect.Height <= 0)

View file

@ -146,6 +146,27 @@ public sealed class UiText : UiElement, IUiDatStateful
/// </summary> /// </summary>
public float Padding { get; set; } public float Padding { get; set; }
/// <summary>
/// Campaign CC gate round 1 Batch E (R2-1): the four independent retail
/// text-inset margins (dat properties <c>0x23</c>/<c>0x24</c>/<c>0x25</c>/
/// <c>0x26</c> — <see cref="Layout.ElementInfo.MarginLeft"/>'s own doc
/// comment has the full decomp citation). Additive with
/// <see cref="Padding"/> (every existing controller that sets
/// <see cref="Padding"/> explicitly keeps behaving identically, since
/// these four default to 0 unless <see cref="Layout.DatWidgetFactory"/>
/// seeds them from the DAT). Applied ONLY to the scrollable multi-line
/// path (<see cref="OneLine"/> == false) — the chargen description boxes
/// that regressed in Batch C are all multi-line, and every authored
/// nonzero-margin box measured against the installed DAT so far is also
/// multi-line. The static Centered/RightAligned/OneLine single-line
/// paths are unchanged (still bare <see cref="Padding"/>) to keep this
/// fix's blast radius to the mechanism that actually regressed.
/// </summary>
public float MarginLeft { get; set; }
public float MarginRight { get; set; }
public float MarginTop { get; set; }
public float MarginBottom { get; set; }
/// <summary>Retail property 0x20. Independent of horizontal/vertical /// <summary>Retail property 0x20. Independent of horizontal/vertical
/// justification; false permits the normal multi-line layout path.</summary> /// justification; false permits the normal multi-line layout path.</summary>
public bool OneLine { get; set; } public bool OneLine { get; set; }
@ -555,7 +576,10 @@ public sealed class UiText : UiElement, IUiDatStateful
if (lines.Count == 0) return; if (lines.Count == 0) return;
float lh = _lastLineHeight; float lh = _lastLineHeight;
float top = Padding, bottom = Height - Padding; // R2-1: the multi-line viewport insets by BOTH Padding (the pre-
// existing uniform inset controllers already set) AND the four
// retail-authored margins (additive — see MarginTop's own doc).
float top = Padding + MarginTop, bottom = Height - Padding - MarginBottom;
float innerH = bottom - top; float innerH = bottom - top;
float contentH = lines.Count * lh; float contentH = lines.Count * lh;
@ -731,11 +755,37 @@ public sealed class UiText : UiElement, IUiDatStateful
float width = datFont is not null float width = datFont is not null
? datFont.MeasureWidth(text) ? datFont.MeasureWidth(text)
: bitmapFont?.MeasureWidth(text) ?? 0f; : bitmapFont?.MeasureWidth(text) ?? 0f;
if (Centered) return ContentOffsetX(Width, Padding, MarginLeft, MarginRight, width, Centered, RightAligned);
return Math.Max(Padding, (Width - width) * 0.5f); }
if (RightAligned)
return Math.Max(Padding, Width - Padding - width); /// <summary>
return Padding; /// R2-1 (Campaign CC gate round 1 Batch E): pure per-line horizontal
/// placement for the MULTI-LINE (scrollable) path — the static
/// single-line Centered/RightAligned/OneLine branches in
/// <see cref="DrawClippedText"/> have their own inline math and are
/// deliberately left on bare <see cref="Padding"/> (see
/// <see cref="MarginLeft"/>'s own doc comment). Here, both
/// <see cref="Padding"/> and the four retail margins inset the content
/// box a line lays out within. Pure/static so it is unit-testable
/// without a font or draw context — the same shape as
/// <see cref="VOffset"/>/<see cref="ContentBaseY"/> above.
/// </summary>
public static float ContentOffsetX(
float elementWidth,
float padding,
float marginLeft,
float marginRight,
float lineWidth,
bool centered,
bool rightAligned)
{
float contentLeft = padding + marginLeft;
float contentRight = elementWidth - padding - marginRight;
if (centered)
return Math.Max(contentLeft, contentLeft + (contentRight - contentLeft - lineWidth) * 0.5f);
if (rightAligned)
return Math.Max(contentLeft, contentRight - lineWidth);
return contentLeft;
} }
public override bool OnEvent(in UiEvent e) public override bool OnEvent(in UiEvent e)

View file

@ -164,6 +164,52 @@ public sealed class CharacterCreationLiveDatTests
UiElement.FindDescendant(heritageRoot, 0x100003C4u)); UiElement.FindDescendant(heritageRoot, 0x100003C4u));
} }
/// <summary>
/// R2-1 (Campaign CC gate round 1 Batch E): the Heritage/Profession/
/// Town/Summary description boxes author retail's four text-inset
/// margins (dat properties 0x23-0x26 — live-DAT-probe-confirmed
/// margL=9/margR=26/margU=15/margD=15 on all four, shared box
/// template) — this codebase never read them before this fix, so every
/// one of these boxes drew its first glyph flush against x=0 (Padding
/// alone, always 0 for DAT-built text), under the authored gold-frame's
/// own left border piece. Pins BOTH halves: the margins land on the
/// built <see cref="UiText"/> (not just the raw <see cref="ElementInfo"/>),
/// and <see cref="UiText.ContentOffsetX"/> computed with those margins
/// places the first line's origin at the authored interior (x=9), not
/// the box's outer edge (x=0).
/// </summary>
[InstalledDatFact]
public void HeritageDescription_MarginsMatchAuthoredInset_AndFirstLineOriginRespectsThem()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement heritageRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.HeritagePageElementId));
UiText description = Assert.IsType<UiText>(
UiElement.FindDescendant(heritageRoot, 0x100003C4u));
Assert.Equal(9f, description.MarginLeft);
Assert.Equal(26f, description.MarginRight);
Assert.Equal(15f, description.MarginTop);
Assert.Equal(15f, description.MarginBottom);
// First glyph of a left-justified line: Padding (0, DAT-built text
// never sets it) + MarginLeft (9) = x=9, NOT x=0 — the exact
// regression the user's "rained Starting Skills"/"OW HUNTERS"
// reports describe (the leading 1-2 characters clipped under the
// frame's left border because text used to start at x=0).
float firstLineX = UiText.ContentOffsetX(
description.Width, description.Padding,
description.MarginLeft, description.MarginRight,
lineWidth: 40f, centered: false, rightAligned: false);
Assert.Equal(9f, firstLineX);
Assert.NotEqual(0f, firstLineX);
}
/// <summary>Seven template buttons, six attribute sliders (each with a /// <summary>Seven template buttons, six attribute sliders (each with a
/// lock button + scrollbar + value text), and the four derived /// lock button + scrollbar + value text), and the four derived
/// displays (Profession page — /// displays (Profession page —
@ -854,6 +900,52 @@ public sealed class CharacterCreationLiveDatTests
Assert.IsType<UiText>(UiElement.FindDescendant(pairRow, 0x100002FDu)); Assert.IsType<UiText>(UiElement.FindDescendant(pairRow, 0x100002FDu));
} }
/// <summary>
/// R2-8 (Campaign CC gate round 1 Batch E): re-checks the AUTHORED-
/// initial-text hypothesis the user's re-test raised for the
/// <c>[ Name ]</c> the retail screenshot shows — Batch A's GF-15
/// closure already byte-verified retail's CODE never writes it
/// (<c>CharGenState::RandomizeCharacter</c>,
/// <c>gmCGSummaryPage::InitializePage</c>), but did not check whether
/// the field's own dat property <c>0x17</c> (the SAME authored-caption
/// mechanism <c>DatWidgetFactory.BuildText</c>/<c>BuildField</c> already
/// reads for every other element) carries a display-only placeholder.
/// It does not: the name field (<c>0x10000402</c>) authors NO <c>0x17</c>
/// on its default state or on ANY of its named states in the installed
/// EoR dat. Per this batch's own investigation contract ("if NOT
/// authored, STOP on this item"), this pins that negative result as a
/// durable regression check rather than leaving it as a one-off probe
/// finding — CONFIRMS Batch A's closure honestly, it does not change
/// acdream's behavior (the field stays genuinely empty, matching
/// retail's own code-empty field).
/// </summary>
[InstalledDatFact]
public void SummaryNameField_AuthorsNoP0x17OnAnyState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
ElementInfo nameField = Assert.IsType<ElementInfo>(
FindInfo(rootInfo, CharacterCreationSummaryPage.NameTextId));
Assert.False(
nameField.TryGetEffectiveProperty(0x17u, out _),
"the name field must not author a P0x17 caption on its effective "
+ "default state — if this starts failing, the DAT now carries an "
+ "authored placeholder and R2-8 should be revisited as a real fix.");
foreach (var (stateId, state) in nameField.States)
{
Assert.False(
state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
&& stateCaption.Kind == UiPropertyKind.StringInfo,
$"the name field's state 0x{stateId:X} ('{state.Name}') must not "
+ "author a P0x17 caption either.");
}
}
/// <summary> /// <summary>
/// CC5 re-review residual round, R3 (2026-08-16): MEASURES the /// CC5 re-review residual round, R3 (2026-08-16): MEASURES the
/// installed global SkillTable's (portal.dat <c>0x0E000004</c>) /// installed global SkillTable's (portal.dat <c>0x0E000004</c>)
@ -1315,6 +1407,122 @@ public sealed class CharacterCreationLiveDatTests
dialogs.Dispose(); dialogs.Dispose();
} }
/// <summary>
/// R2-7a (Campaign CC gate round 1 Batch E): the Summary OVERVIEW
/// listbox (<c>0x10000400</c>) authors a linked scrollbar via dat
/// property <c>0x72</c> — live-DAT-probe-confirmed
/// <c>ScrollbarElementId=0x10000401</c>, a SIBLING element under the
/// Summary page root, not a descendant of the listbox itself.
/// <see cref="CharacterCreationSummaryPage"/>'s constructor used to wire
/// only the how-to box's own scrollbar (Commit 3) and never resolved
/// this one, so the listbox never scrolled despite carrying more rows
/// than fit its 435px-tall view. Same linkage pattern every other
/// UiTemplateListBox owner in this codebase already uses
/// (SocialFriendsPageController, ConfigOptionsPageController, etc).
/// </summary>
[InstalledDatFact]
public void SummaryListbox_ScrollbarBuildsAndLinksToListboxScroll()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement summaryRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.SummaryPageElementId));
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ListBoxId));
Assert.Equal(CharacterCreationSummaryPage.ScrollId, list.ScrollbarElementId);
UiScrollbar overviewScroll = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ScrollId));
var host = new UiRoot();
var dialogs = MakeDialogFactory(dats, host);
var bindings = new CharacterCreationRuntimeBindings(
() => null,
_ => default, _ => default, _ => default, (_, _) => default, (_, _) => default,
_ => default, _ => default, _ => default, _ => default, _ => default, () => { });
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) =>
LayoutImporter.Import(
dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root;
CharacterCreationUiController? controller =
CharacterCreationUiController.CreateDetached(
host, screen, ResolveTemplate, dialogs, bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long"));
Assert.NotNull(controller);
controller!.AttachAndTick();
Assert.Same(list.Scroll, overviewScroll.Model);
controller.Dispose();
dialogs.Dispose();
}
/// <summary>
/// R2-7b (Campaign CC gate round 1 Batch E): the how-to box's scrollbar
/// THUMB only draws <c>if (m.HasOverflow)</c>
/// (<see cref="UiScrollbar"/>'s own draw gate) — the reported "no
/// thumb" symptom traces to R2-1's bug, not an independent defect: with
/// the pre-fix wrap width (the box's raw Width, ignoring the authored
/// margL=9/margR=26 inset), the Aluvian how-to text (the LONGEST
/// composed variant — SummaryHowTo + the male name-suggestion list +
/// SummaryHowToEnd) wrapped to fewer/shorter lines than the correctly
/// inset width does. This pins the causal claim directly against the
/// real installed strings/font: composing with the CORRECT (margin-
/// inset) width produces content taller than the view, so
/// <see cref="UiScrollable.HasOverflow"/> — which is exactly what
/// <see cref="UiScrollbar"/> gates the thumb on — is true.
/// </summary>
[InstalledDatFact]
public void SummaryHowToText_Aluvian_WithCorrectMarginInsetWidth_Overflows()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
var strings = new DatStringResolver(dats);
const uint table = 0x23000002u;
string? howTo = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_SummaryHowTo"));
string? names = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_AluMaleNames"));
string? howToEnd = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_SummaryHowToEnd"));
Assert.NotNull(howTo);
Assert.NotNull(names);
Assert.NotNull(howToEnd);
string composed = howTo + names + howToEnd;
// Real font metrics (0x40000009 — live-DAT-probe-confirmed FontDid
// on 0x10000404), no GL/texture needed for MeasureWidth.
Assert.True(dats.TryGet<DatReaderWriter.DBObjs.Font>(0x40000009u, out var font) && font is not null);
var glyphs = new Dictionary<char, DatReaderWriter.Types.FontCharDesc>(font!.CharDescs.Count);
foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd;
var datFont = new UiDatFont(0, 0, 0, 0, 0, 0, font.MaxCharHeight, font.BaselineOffset, glyphs);
// Live-DAT-measured box geometry (0x10000404): 247x380,
// margL=9/margR=26/margU=15/margD=15.
var target = new UiText
{
Width = 247f,
Height = 380f,
DatFont = datFont,
MarginLeft = 9f,
MarginRight = 26f,
MarginTop = 15f,
MarginBottom = 15f,
};
var segments = new[] { new DatRichText.Segment(composed, Vector4.One) };
var lines = DatRichText.Compose(target, segments);
float viewHeight = target.Height - target.Padding - target.MarginTop - target.Padding - target.MarginBottom;
float contentHeight = lines.Count * datFont.LineHeight;
Assert.True(
contentHeight > viewHeight,
$"expected the correctly-inset composition ({lines.Count} lines, "
+ $"{contentHeight}px) to overflow the {viewHeight}px view — if it "
+ "doesn't, the how-to scrollbar's thumb has nothing to gate on "
+ "regardless of the R2-1 margin fix");
}
private static void AssertButton(ImportedLayout layout, uint elementId) => private static void AssertButton(ImportedLayout layout, uint elementId) =>
Assert.IsType<UiButton>(layout.FindElement(elementId)); Assert.IsType<UiButton>(layout.FindElement(elementId));

View file

@ -105,6 +105,25 @@ public class DatRichTextTests
Assert.Equal("second", lines[1].Text); Assert.Equal("second", lines[1].Text);
} }
[Fact]
public void Compose_WordWrapsToTheTargetWidth_MinusTheFourRetailMargins()
{
// R2-1 (Campaign CC gate round 1 Batch E): the wrap width must
// shrink by BOTH Padding and the four retail margins (properties
// 0x23-0x26 — MarginLeft's own doc comment on UiText), not just the
// element's raw Width. A 100px-wide box with margL=10/margR=10
// leaves only 80px of usable width — one 10-char/8px-per-char word
// ("aaaaaaaaaa", 80px) must fit on one line, but appending an 11th
// 'a' (88px) must force a wrap.
UiText fits = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f };
var fitsLines = DatRichText.Compose(fits, [new DatRichText.Segment("aaaaaaaaaa", White)]);
Assert.Single(fitsLines);
UiText overflows = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f };
var overflowLines = DatRichText.Compose(overflows, [new DatRichText.Segment("aaaaaaaaaaa", White)]);
Assert.True(overflowLines.Count > 1, "an 88px word in an 80px content width must wrap");
}
[Fact] [Fact]
public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent() public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent()
{ {

View file

@ -414,6 +414,142 @@ public class UiButtonTests
Assert.Equal(externalColor, b.LabelColor); Assert.Equal(externalColor, b.LabelColor);
} }
// ── R2-2/R2-3 (Campaign CC gate round 1 Batch E): WrapBlockLines ────
private static float BitmapMeasure(string text) => text.Length * 8f;
/// <summary>
/// A single line that already fits its box draws with the SAME
/// centered-block geometry the pre-fix unconditional one-line math
/// produced — the fix is a strict superset for every already-working
/// button caption.
/// </summary>
[Fact]
public void WrapBlockLines_SingleLineThatFits_MatchesPriorOneLineGeometry()
{
var lines = UiButton.WrapBlockLines(
"Health", BitmapMeasure, lineHeight: 24f,
boxX: 0f, boxY: 0f, boxWidth: 150f, boxHeight: 50f,
UiButton.LabelAlignment.Left, leftOffset: 3f);
Assert.Single(lines);
Assert.Equal("Health", lines[0].Text);
Assert.Equal(3f, lines[0].X); // boxX + leftOffset
Assert.Equal((50f - 24f) * 0.5f, lines[0].Y); // vertically centered, one line
}
/// <summary>
/// R2-2: an authored newline (already normalized to a real '\n' by
/// DatWidgetFactory's ResolveAuthoredString) splits into stacked lines
/// even when EACH half individually fits the box — "Attribute\nCredits"
/// must become two lines, not one literal run.
/// </summary>
[Fact]
public void WrapBlockLines_EmbeddedNewline_ProducesTwoStackedLines()
{
var lines = UiButton.WrapBlockLines(
"Attribute\nCredits", BitmapMeasure, lineHeight: 24f,
boxX: 0f, boxY: 0f, boxWidth: 90f, boxHeight: 50f,
UiButton.LabelAlignment.Left, leftOffset: 3f);
Assert.Equal(2, lines.Count);
Assert.Equal("Attribute", lines[0].Text);
Assert.Equal("Credits", lines[1].Text);
// Block-centered: total height 48 in a 50-tall box -> start Y = 1.
Assert.Equal(1f, lines[0].Y);
Assert.Equal(25f, lines[1].Y); // startY + 1*lineHeight
}
/// <summary>
/// R2-3: a single-paragraph caption with NO authored newline still
/// word-wraps when it doesn't fit the available width — the exact
/// live-DAT shape of the Skills credits button's own "Available Skill
/// Credits" caption (measured 193px in a 231px-wide button whose value
/// box starts at local x=116, i.e. only ~113px of caption width is
/// actually available once R2-2/R2-3's confinement applies).
/// </summary>
[Fact]
public void WrapBlockLines_LongSingleParagraph_WordWrapsToFitAvailableWidth()
{
var lines = UiButton.WrapBlockLines(
"Available Skill Credits", BitmapMeasure, lineHeight: 24f,
boxX: 0f, boxY: 0f, boxWidth: 113f, boxHeight: 28f,
UiButton.LabelAlignment.Left, leftOffset: 3f);
Assert.True(lines.Count > 1, "a 193px caption must wrap within a 110px available width");
foreach (var line in lines)
Assert.True(BitmapMeasure(line.Text) <= 110f, $"line '{line.Text}' overflowed");
}
/// <summary>
/// R2-2/R2-3 confinement itself, exercised through OnDraw's own gate:
/// a button with BOTH Label and a coexisting ValueBox shrinks the
/// caption's OWN drawable width to stop before the value box starts —
/// this is what the two live-DAT overlap reports (R2-2 "24dits", R2-3
/// "Credit0Credits") trace to: the caption used to draw across the
/// WHOLE button width regardless of where the value sat.
/// </summary>
[Fact]
public void BuildButton_OwnCaptionWithCoexistingValueBox_ConfinesLabelWidthBeforeValueBox()
{
uint captionStringId = 333u;
var info = new ElementInfo { Type = 1, Width = 231, Height = 28 };
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
};
info.StateMedia[""] = (0x06000001u, 1);
var valueChild = new ElementInfo { Type = 12, X = 116, Y = 0, Width = 34, Height = 28 };
info.Children.Add(valueChild);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == captionStringId ? "Available Skill Credits" : null));
Assert.Equal("Available Skill Credits", button.Label);
Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox);
// The caption's own available width for WrapBlockLines is bounded by
// ValueBox.X (116), NOT the button's full Width (231) — reproducing
// OnDraw's own confinement math here (private OnDraw isn't directly
// callable, so this pins the INPUT the fix computes for it).
float confinedWidth = System.MathF.Min(button.Width, button.ValueBox!.Value.X - 0f);
Assert.Equal(116f, confinedWidth);
Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button");
}
// ── R2-2 escape-normalize ────────────────────────────────────────────
/// <summary>
/// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way
/// BuildText's authored-string path always has — the DAT stores the
/// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession
/// credits button's own authored caption is exactly this shape.
/// </summary>
[Fact]
public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape()
{
uint stringId = 444u;
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
};
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
// The raw resolved string carries the LITERAL two characters
// '\' and 'n', matching what the installed DAT actually stores.
stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null));
Assert.Equal("Attribute\n Credits", button.Label);
}
private static UiButton ButtonWithStates(params string[] states) private static UiButton ButtonWithStates(params string[] states)
{ {
var info = ButtonInfo(states); var info = ButtonInfo(states);

View file

@ -278,6 +278,69 @@ public class UiTextTests
Assert.Equal(9f, y); Assert.Equal(9f, y);
} }
/// <summary>
/// R2-1 (Campaign CC gate round 1 Batch E): with zero margins,
/// ContentOffsetX is byte-identical to the pre-fix bare-Padding math —
/// every existing DAT-imported multi-line box (margins default 0 unless
/// DatWidgetFactory seeds them) is unaffected by this change.
/// </summary>
[Fact]
public void ContentOffsetX_ZeroMargins_MatchesBarePaddingMath()
{
float left = UiText.ContentOffsetX(
elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f,
lineWidth: 30f, centered: false, rightAligned: false);
Assert.Equal(4f, left);
float centered = UiText.ContentOffsetX(
elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f,
lineWidth: 30f, centered: true, rightAligned: false);
Assert.Equal(Math.Max(4f, (200f - 30f) * 0.5f), centered);
float right = UiText.ContentOffsetX(
elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f,
lineWidth: 30f, centered: false, rightAligned: true);
Assert.Equal(200f - 4f - 30f, right);
}
/// <summary>
/// The exact live-DAT shape (Campaign CC gate round 1 Batch E, R2-1):
/// Heritage/Profession/Town/Summary description boxes author
/// margL=9/margR=26 — a left-justified line must start at x=9 (Padding
/// 0 + MarginLeft 9), not x=0. This is the regression: pre-fix, every
/// one of these boxes drew its first glyph at x=0, clipping under the
/// authored gold-frame's left border piece.
/// </summary>
[Fact]
public void ContentOffsetX_LeftJustified_HonorsAuthoredMarginLeft()
{
float x = UiText.ContentOffsetX(
elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f,
lineWidth: 100f, centered: false, rightAligned: false);
Assert.Equal(9f, x);
}
/// <summary>
/// A right-aligned line must stop before MarginRight, not at the raw
/// element edge — the R2-1 fix's other half (the wrap width shrinks by
/// the same inset so text no longer overflows the visible right edge
/// either).
/// </summary>
[Fact]
public void ContentOffsetX_RightAligned_HonorsAuthoredMarginRight()
{
float x = UiText.ContentOffsetX(
elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f,
lineWidth: 50f, centered: false, rightAligned: false);
_ = x; // left case covered above
float right = UiText.ContentOffsetX(
elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f,
lineWidth: 50f, centered: false, rightAligned: true);
// contentRight = 265 - 0 - 26 = 239; right-aligned x = 239 - 50 = 189.
Assert.Equal(189f, right);
}
[Fact] [Fact]
public void LineIntersectsViewport_PartialLineRemainsDrawable() public void LineIntersectsViewport_PartialLineRemainsDrawable()
{ {