diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 83156c6f..bf0e521c 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -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) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs index c90a3943..9b9cdb39 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs @@ -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) { diff --git a/src/AcDream.App/UI/Layout/DatRichText.cs b/src/AcDream.App/UI/Layout/DatRichText.cs index c55f1476..60b10bc3 100644 --- a/src/AcDream.App/UI/Layout/DatRichText.cs +++ b/src/AcDream.App/UI/Layout/DatRichText.cs @@ -58,7 +58,15 @@ internal static class DatRichText ArgumentNullException.ThrowIfNull(segments); var lines = new List(); - 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 measure = target.DatFont is { } font ? font.MeasureWidth : static value => value.Length * 8f; diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index a2e4b1ea..4773e5dd 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -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 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())[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); } + + /// + /// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize + /// applies, pulled out so the + /// per-STATE authored-caption loop below (which resolves a state's own + /// 0x17 directly, bypassing the effective-property resolution + /// wraps) gets the SAME normalize + /// instead of a second, easily-forgotten copy. + /// + private static string? NormalizeEscapes(string? raw) => + raw?.Replace("\\n", "\n").Replace("\r", string.Empty); } diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 606e19e1..6f5edbd5 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -245,6 +245,27 @@ public sealed class ElementInfo /// public bool Invisible; + /// + /// Campaign CC gate round 1 Batch E (R2-1): the four independent + /// UIElement_Text text-inset margins, dat properties + /// 0x23/0x24/0x25/0x26 (IntegerBaseProperty + /// — UIElement_Text::OnSetAttribute @0x0046a640 cases + /// 0xf/0x10/0x11/0x12, i.e. + /// BaseProperty::GetPropertyName(arg2) - 0x14, writing + /// m_margL/m_margR/m_margU/m_margD). Ctor + /// default is 0 on all four (UIElement_Text::UIElement_Text + /// @0x004686d1-0046872d clears them before any authored value + /// applies). The chargen description boxes author margL=9, + /// margR=26, margU=15, margD=15 (live-DAT-probe-confirmed on + /// 0x100003C4/0x100003E0/0x10000409/ + /// 0x10000404) — this codebase never read these four + /// properties before this fix, so every DAT-imported UiText + /// drew flush against its own outer rect (Padding alone, + /// always 0 for DAT-built text) regardless of what the DAT actually + /// authored. + /// + public int MarginLeft, MarginRight, MarginTop, MarginBottom; + /// /// 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 diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 2cd171ab..e266710d 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -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 } } + /// + /// R2-2 (Campaign CC gate round 1 Batch E): retail's UIElement_Button + /// IS a UIElement_Text (struct UIElement_Button : UIElement_Text, + /// acclient.h) — these captions author OneLine=false + /// (live-DAT-probe-confirmed on 0x100003e2-e5/0x100003f9), so a caption + /// that carries an authored newline (already normalized to a real + /// '\n' by 's shared + /// ResolveAuthoredString) OR simply doesn't fit + /// lays out as multiple stacked lines, using + /// the SAME word-wrap 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. + /// + 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(); + } + } + + /// + /// Pure geometry half of — normalized + /// newline split + word-wrap () to + /// , then block-centered vertically within + /// . Pulled out as a static/pure method + /// (same shape as ) so the wrap/ + /// confinement math is unit-testable without a font atlas or draw + /// context — takes the place of + /// . + /// + internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines( + string text, + Func 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(); + 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) diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 0f7ba89f..5bd2ab0e 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -146,6 +146,27 @@ public sealed class UiText : UiElement, IUiDatStateful /// public float Padding { get; set; } + /// + /// Campaign CC gate round 1 Batch E (R2-1): the four independent retail + /// text-inset margins (dat properties 0x23/0x24/0x25/ + /// 0x26's own doc + /// comment has the full decomp citation). Additive with + /// (every existing controller that sets + /// explicitly keeps behaving identically, since + /// these four default to 0 unless + /// seeds them from the DAT). Applied ONLY to the scrollable multi-line + /// path ( == 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 ) to keep this + /// fix's blast radius to the mechanism that actually regressed. + /// + public float MarginLeft { get; set; } + public float MarginRight { get; set; } + public float MarginTop { get; set; } + public float MarginBottom { get; set; } + /// Retail property 0x20. Independent of horizontal/vertical /// justification; false permits the normal multi-line layout path. 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); + } + + /// + /// 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 + /// have their own inline math and are + /// deliberately left on bare (see + /// 's own doc comment). Here, both + /// 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 + /// / above. + /// + 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) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 892439fe..b1ea5e84 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -164,6 +164,52 @@ public sealed class CharacterCreationLiveDatTests UiElement.FindDescendant(heritageRoot, 0x100003C4u)); } + /// + /// 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 (not just the raw ), + /// and computed with those margins + /// places the first line's origin at the authored interior (x=9), not + /// the box's outer edge (x=0). + /// + [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( + screen.FindElement(CharacterCreationUiController.HeritagePageElementId)); + UiText description = Assert.IsType( + 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); + } + /// 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(UiElement.FindDescendant(pairRow, 0x100002FDu)); } + /// + /// R2-8 (Campaign CC gate round 1 Batch E): re-checks the AUTHORED- + /// initial-text hypothesis the user's re-test raised for the + /// [ Name ] the retail screenshot shows — Batch A's GF-15 + /// closure already byte-verified retail's CODE never writes it + /// (CharGenState::RandomizeCharacter, + /// gmCGSummaryPage::InitializePage), but did not check whether + /// the field's own dat property 0x17 (the SAME authored-caption + /// mechanism DatWidgetFactory.BuildText/BuildField already + /// reads for every other element) carries a display-only placeholder. + /// It does not: the name field (0x10000402) authors NO 0x17 + /// 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). + /// + [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( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + ElementInfo nameField = Assert.IsType( + 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."); + } + } + /// /// CC5 re-review residual round, R3 (2026-08-16): MEASURES the /// installed global SkillTable's (portal.dat 0x0E000004) @@ -1315,6 +1407,122 @@ public sealed class CharacterCreationLiveDatTests dialogs.Dispose(); } + /// + /// R2-7a (Campaign CC gate round 1 Batch E): the Summary OVERVIEW + /// listbox (0x10000400) authors a linked scrollbar via dat + /// property 0x72 — live-DAT-probe-confirmed + /// ScrollbarElementId=0x10000401, a SIBLING element under the + /// Summary page root, not a descendant of the listbox itself. + /// '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). + /// + [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( + screen.FindElement(CharacterCreationUiController.SummaryPageElementId)); + UiTemplateListBox list = Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ListBoxId)); + Assert.Equal(CharacterCreationSummaryPage.ScrollId, list.ScrollbarElementId); + UiScrollbar overviewScroll = Assert.IsType( + 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(); + } + + /// + /// R2-7b (Campaign CC gate round 1 Batch E): the how-to box's scrollbar + /// THUMB only draws if (m.HasOverflow) + /// ('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 + /// — which is exactly what + /// gates the thumb on — is true. + /// + [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(0x40000009u, out var font) && font is not null); + var glyphs = new Dictionary(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(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs index 4d94a819..2ae0193e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index e21c5849..7ad930be 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -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; + + /// + /// 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. + /// + [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 + } + + /// + /// 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. + /// + [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 + } + + /// + /// 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). + /// + [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"); + } + + /// + /// 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. + /// + [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(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 ──────────────────────────────────────────── + + /// + /// 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. + /// + [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(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); diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs index 4ad1a966..1ddf4feb 100644 --- a/tests/AcDream.App.Tests/UI/UiTextTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -278,6 +278,69 @@ public class UiTextTests Assert.Equal(9f, y); } + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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). + /// + [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() {