From 7d6a7898f6c2c61f7a19e011a143c7300a9e77ce Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 17:19:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20round=20?= =?UTF-8?q?1=20re-test=202=20=E2=80=94=20R3-1/R3-2=20caption=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch E's UiButton.DrawBlockLabel/WrapBlockLines auto-wrapped any caption that didn't fit its box width — decomp-wrong. UIElement_Text:: CalcJustification @0x00467260 (shared by GlyphList::Recalculate's horizontal/vertical branches) shows retail's real per-glyph break decision (both the width-triggered wrap AND the explicit-newline break) sits behind ONE gate keyed on the OneLine flag; nothing in the decomp confines a caption's wrap width to a sibling element's rect (Batch E's own ValueBox confinement for the coexisting-value-label shape). Live-DAT evidence: the Coordination attribute-slider label (0x100002ed) authors OneLine=true (should never wrap); the Skills credits button's "Available Skill Credits" caption measures 193px against its own full 231px button width (fits comfortably) — the 113px confined width Batch E fed the wrap decision was never a real retail quantity. Fixed: WrapBlockLines now splits ONLY on the explicit (already-normalized) '\n' — never width-based. Strict superset of the pre-Batch-E single-line draw for every already-correct caption; "Attribute\n Credits" still works. The ValueBox confinement computation stays in OnDraw (still feeds the Center-alignment tx formula) but no longer gates the wrap decision. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/UiButton.cs | 197 ++++++++++++++------ tests/AcDream.App.Tests/UI/UiButtonTests.cs | 64 +++++-- 2 files changed, 189 insertions(+), 72 deletions(-) diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index bf574e4b..9b2dd988 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -166,6 +166,37 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// public Vector4 Tint { get; set; } = Vector4.One; + /// + /// R3-5 (Campaign CC gate round 1 re-test 2): optional resolver + /// returning a PRE-BAKED, already color-key-recolored texture handle + /// (from + /// or equivalent), drawn UNTINTED (1:1, no UV repeat) INSTEAD of the + /// ordinary /ActiveFile sprite. + /// Retail's own gmCGAppearancePage::DoColorSpots @0x0047d850 + /// does NOT multiply-tint the swatch's authored ring+spot sprite (a + /// multiply of a target color against BLACK — the spot template's own + /// placeholder fill, live-DAT-pixel-confirmed — stays black regardless + /// of the tint, and multiplying the ring's own non-black border pixels + /// shifts their hue/brightness, corrupting them). Retail instead calls + /// SurfaceWindow::ReplaceColor: build a fresh composited surface + /// once, blit the spot template onto it, then swap every EXACT-black + /// pixel for the swatch's real color — the ring border (never black) + /// is untouched. This property is that same mechanism's C# seam. + /// itself is left completely unchanged in meaning + /// and is STILL the value callers set to communicate "this button's + /// color is X" (existing callers/tests that only read + /// are unaffected) — this resolver is a SEPARATE + /// decision (deliberately not fed by : a caller may + /// need to distinguish more states — e.g. "beyond count, show the + /// blocked art" versus "no color data yet, show nothing" — than one + /// Vector4 can encode) that only changes what OnDraw does when + /// non-null: consult it for a texture instead of directly multiplying + /// the authored sprite. Null (default, every pre-existing button) + /// preserves the exact prior FaceFileOverride/ActiveFile + + /// multiply-Tint draw. + /// + public Func? ColorKeyFaceResolver { get; set; } + /// Additional left inset for left-aligned labels. public float LabelOffsetX { get; set; } = 3f; @@ -469,6 +500,22 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful foreach (FaceSegment segment in _faceSegments) DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height)); } + else if (ColorKeyFaceResolver is { } colorKeyResolver) + { + // R3-5: a pre-baked, already-recolored texture (see this + // property's own doc) — drawn UNTINTED and 1:1 (no UV repeat; + // the baked bitmap is uploaded at its own native size, which + // for the chargen swatches equals the button's own authored + // rect, live-DAT-measured). + uint bakedTexture = colorKeyResolver(); + if (bakedTexture != 0) + { + float faceWidth = FaceWidth > 0f ? FaceWidth : Width; + float faceHeight = FaceHeight > 0f ? FaceHeight : Height; + ctx.DrawSprite(bakedTexture, FaceLeft, FaceTop, faceWidth, faceHeight, + 0f, 0f, 1f, 1f, Vector4.One); + } + } else { uint file = FaceFileOverride ?? ActiveFile(_mediaInfo); @@ -497,18 +544,18 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful float boxWidth = LabelBox?.Width ?? Width; float boxHeight = LabelBox?.Height ?? Height; - // 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. + // R2-2/R2-3 (Campaign CC gate round 1 Batch E) + R3-2 correction + // (re-test 2): 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), boxWidth still narrows to stop before + // the value's authored rect for the (currently unused, since + // every known ValueBox button is Left-aligned) Center-tx + // formula and the explicit-newline clip rect below — see + // DrawBlockLabel's own doc for why this no longer gates + // WHETHER a single-line caption wraps or clips (R3-2: it never + // did in retail — live-DAT-measured, "Available Skill Credits" + // fits the button's own full 231px width with room to spare). if (ValueBox is { X: var valueBoxX } && valueBoxX > boxX) boxWidth = MathF.Min(boxWidth, valueBoxX - boxX); @@ -543,20 +590,58 @@ 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. + /// R2-2 (Campaign CC gate round 1 Batch E) + R3-1/R3-2 (re-test 2 + /// correction): retail's UIElement_Button IS a + /// UIElement_Text (struct UIElement_Button : UIElement_Text, + /// acclient.h) — a caption that carries an authored newline + /// (already normalized to a real '\n' by + /// 's shared + /// ResolveAuthoredString) lays out as multiple stacked lines. A + /// single line that already fits draws with byte-identical geometry to + /// the pre-Batch-E unconditional one-line math (same centered-block Y, + /// same tx formula). + /// + /// Batch E ALSO auto-wrapped a paragraph that doesn't fit + /// via — re- + /// derived at re-test 2 (R3-1 "Coordination"/R3-2 "Available Skill + /// Credits") as the wrong shape and REMOVED: live-DAT-probed, the + /// Coordination slider label (0x100002ed) authors OneLine= + /// true (dat property 0x20) and the Skills credits button + /// (0x100003f9) authors OneLine=false yet BOTH render one + /// line in retail. Tracing GlyphList::Recalculate + /// @0x00473800's per-glyph loop: the ENTIRE width-triggered break + /// decision (and, separately, the explicit-newline break) sits behind + /// one gate, if (arg3 == 0) where arg3 is the SAME + /// OneLine boolean passed in from + /// UIElement_Text::ResizeToPaper/InqSize — i.e. a + /// caption's width is measured against its own FULL element rect (minus + /// margins), never against a sibling/child element's geometry; nothing + /// in the decomp confines a caption's wrap width to stop before another + /// element's rect. The 193px "Available Skill Credits" caption fits the + /// button's own full 231px width (live-DAT-measured) with room to + /// spare — it never needed to wrap at all. So: split ONLY on the + /// explicit \n (never invoke ) — a + /// strict superset of the pre-Batch-E single-line draw for every + /// caption that was already correct, and the exact shape "Attribute\n + /// Credits" (an authored break) still needs. + /// + /// + /// R3-2 deliberately does NOT clip a single (unwrapped) line to + /// either, even when the caller narrowed it + /// via a coexisting — clipping would cut the + /// caption's own tail off mid-word, which contradicts "retail is ONE + /// line" just as much as wrapping does (a viewer would call that + /// truncated, not "one line"). The 193px-in-231px Skills-credits + /// geometry means the caption's rendered span (x≈3 to x≈196) does + /// overlap the value's own rect (x=116 to x=150, live-DAT-measured) in + /// principle — Batch E's own diagnosis of the ORIGINAL R2-2/R2-3 + /// "24dits"/"Credit0Credits" reports. That overlap is NOT re-solved + /// here: this fix only removes the false wrap this specific finding + /// (R3-2) reported, and inventing an unevidenced clip boundary to + /// pre-empt a DIFFERENT, not-currently-reported symptom would be + /// exactly the guessing this project's workflow forbids. Flagged in + /// the findings doc for the user's own re-check once the wrap is gone. + /// /// private void DrawBlockLabel( UiRenderContext ctx, @@ -574,13 +659,16 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful 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. + // A multi-line result (an authored '\n') 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, + // and (post-R3-2) EVERY caption with no authored newline — never + // pay this cost; see this method's own doc for why a single line + // is deliberately left unclipped even when boxWidth was narrowed. bool clip = lines.Count > 1; if (clip) ctx.PushClip(boxX, boxY, boxWidth, boxHeight); @@ -597,14 +685,24 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful } /// - /// Pure geometry half of — normalized - /// newline split + word-wrap () to - /// , then block-centered vertically within + /// Pure geometry half of — split ONLY on an + /// authored explicit '\n', 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 + /// (same shape as ) so the geometry + /// is unit-testable without a font atlas or draw context — + /// takes the place of /// . + /// + /// R3-1/R3-2 (re-test 2): deliberately does NOT width-wrap a paragraph + /// that overflows — see + /// 's own doc for the decomp citation + /// (GlyphList::Recalculate's width-triggered break sits behind + /// the SAME OneLine gate as the explicit-newline break, and + /// retail never confines a caption's wrap width to a sibling element's + /// rect). A paragraph that overflows still draws as one line, unclipped + /// by width — matching every plain (no authored \n) button + /// caption in retail, which is never observed to wrap. + /// /// internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines( string text, @@ -617,26 +715,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful LabelAlignment align, float leftOffset) { - float availableWidth = MathF.Max( - 1f, - boxWidth - (align == LabelAlignment.Left ? leftOffset : 0f)); + string[] lines = text.Split('\n'); - 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 totalHeight = lines.Length * 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++) + var result = new List<(string, float, float)>(lines.Length); + for (int i = 0; i < lines.Length; i++) { string line = lines[i]; float tx = align == LabelAlignment.Left diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 7ad930be..faeabee7 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -461,33 +461,65 @@ public class UiButtonTests } /// - /// 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). + /// R3-2 (re-test 2 correction, supersedes the retired Batch E + /// "WordWrapsToFitAvailableWidth" expectation): a single-paragraph + /// caption with NO authored newline stays ONE line even when it + /// overflows the available width — the exact live-DAT shape of the + /// Skills credits button's own "Available Skill Credits" caption + /// (measured 193px, live-DAT-probed against the button's own FULL + /// 231px width, which it fits comfortably — the 113px figure in the + /// old test was the WRONG width in the first place, since retail never + /// confines a caption's wrap width to a sibling value element's rect; + /// see 's own doc for the + /// GlyphList::Recalculate citation). Even forced into an artificially + /// narrow box (as here), the caption must NOT wrap — retail's + /// UIElement_Button captions only ever split on an authored \n. /// [Fact] - public void WrapBlockLines_LongSingleParagraph_WordWrapsToFitAvailableWidth() + public void WrapBlockLines_LongSingleParagraph_NeverWordWraps() { 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"); + Assert.Single(lines); + Assert.Equal("Available Skill Credits", lines[0].Text); } /// - /// 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. + /// R3-1 (re-test 2): the Profession/chargen attribute slider name label + /// (element 0x100002ed, e.g. "Coordination") authors OneLine= + /// true and a 115px-wide box — live-DAT-measured against the real + /// dat font, the caption itself is 113px wide, just 1px narrower than + /// the raw box but 1px WIDER than the box minus the class's own default + /// 3px LabelOffsetX (112px) — exactly the boundary the retired + /// Batch E width-check would have tripped on, wrapping a single WORD + /// (no space to break at) into a garbled two-line split. Pins that this + /// no longer happens for any box/text combination, narrow or not. + /// + [Fact] + public void WrapBlockLines_SingleWordNarrowerThanBoxButWiderThanOffsetAdjustedWidth_StaysOneLine() + { + var lines = UiButton.WrapBlockLines( + "Coordination", BitmapMeasure, lineHeight: 24f, + boxX: 0f, boxY: 0f, boxWidth: 115f, boxHeight: 24f, + UiButton.LabelAlignment.Left, leftOffset: 3f); + + Assert.Single(lines); + Assert.Equal("Coordination", lines[0].Text); + } + + /// + /// The ValueBox-vs-Label confinement math itself, exercised through + /// OnDraw's own computation: a button with BOTH Label and a coexisting + /// ValueBox still shrinks the caption's OWN boxWidth to stop before the + /// value box starts. As of R3-2 (re-test 2) this confined width no + /// longer changes whether or how the caption draws — a single + /// (unwrapped) line is never clipped to it (see + /// 's own doc) — so this test only + /// pins that the computation itself is unchanged, not that it gates + /// any rendering decision. /// [Fact] public void BuildButton_OwnCaptionWithCoexistingValueBox_ConfinesLabelWidthBeforeValueBox()