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
element's outer rect where retail insets to an interior text region
(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
captions never escape-normalize — only BuildText does), AND the value
("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**
("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):**
(a) rows are NOT selectable — retail selection turns the row brighter
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 /
Useable Untrained / Unuseable Untrained with headers) — the user's
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
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
remaining halves (real palette-color swatch rendering + gradient tint)
from partial-closed to must-port.
- **R2-6: Town description text misaligned** — R2-1 family.
from partial-closed to must-port. — OUT OF SCOPE for Batch E; still
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
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
retail's prefill. Batch A verified retail's CODE never writes it; the
UNCHECKED hypothesis is the field's AUTHORED initial text (`P0x17` on
`0x10000402`) — probe the DAT; if authored, render authored initial
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
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
most likely the field's own bracket-style empty-state chrome (GF-2/GF-12
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)

View file

@ -157,6 +157,24 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
if (_list is not null)
_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;
if (_nameField is not null)
{

View file

@ -58,7 +58,15 @@ internal static class DatRichText
ArgumentNullException.ThrowIfNull(segments);
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
? font.MeasureWidth
: 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
// elements that don't author it.
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);
@ -781,7 +789,12 @@ public static class DatWidgetFactory
cachedWidth = t.Width;
cachedFont = t.DatFont;
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
? font.MeasureWidth
: static value => value.Length * 8f;
@ -810,7 +823,8 @@ public static class DatWidgetFactory
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|| stateCaption.Kind != UiPropertyKind.StringInfo)
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;
}
if (stateStrings is not null)
@ -1028,6 +1042,32 @@ public static class DatWidgetFactory
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|| property.Kind != UiPropertyKind.StringInfo)
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>
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>
/// 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.
@ -421,6 +442,15 @@ public static class ElementReader
Outline = derived.Outline || base_.Outline,
// OutlineColor: same "non-null derived wins" rule as FontColor.
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 = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// 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
// 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

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)

View file

@ -146,6 +146,27 @@ public sealed class UiText : UiElement, IUiDatStateful
/// </summary>
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
/// justification; false permits the normal multi-line layout path.</summary>
public bool OneLine { get; set; }
@ -555,7 +576,10 @@ public sealed class UiText : UiElement, IUiDatStateful
if (lines.Count == 0) return;
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 contentH = lines.Count * lh;
@ -731,11 +755,37 @@ public sealed class UiText : UiElement, IUiDatStateful
float width = datFont is not null
? datFont.MeasureWidth(text)
: bitmapFont?.MeasureWidth(text) ?? 0f;
if (Centered)
return Math.Max(Padding, (Width - width) * 0.5f);
if (RightAligned)
return Math.Max(Padding, Width - Padding - width);
return Padding;
return ContentOffsetX(Width, Padding, MarginLeft, MarginRight, width, Centered, RightAligned);
}
/// <summary>
/// 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)

View file

@ -164,6 +164,52 @@ public sealed class CharacterCreationLiveDatTests
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
/// lock button + scrollbar + value text), and the four derived
/// displays (Profession page —
@ -854,6 +900,52 @@ public sealed class CharacterCreationLiveDatTests
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>
/// CC5 re-review residual round, R3 (2026-08-16): MEASURES the
/// installed global SkillTable's (portal.dat <c>0x0E000004</c>)
@ -1315,6 +1407,122 @@ public sealed class CharacterCreationLiveDatTests
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) =>
Assert.IsType<UiButton>(layout.FindElement(elementId));

View file

@ -105,6 +105,25 @@ public class DatRichTextTests
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]
public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent()
{

View file

@ -414,6 +414,142 @@ public class UiButtonTests
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)
{
var info = ButtonInfo(states);

View file

@ -278,6 +278,69 @@ public class UiTextTests
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]
public void LineIntersectsViewport_PartialLineRemainsDrawable()
{