fix(chargen): Campaign CC gate round 1 re-test 3 — R4-1..R4-4
Four visual residuals from the lead's own live-client captures of 1.0.2-cc.m, all root-caused via decomp + live-DAT evidence: - R4-1: Skills credits value overlapped mid-caption again. Root cause was a missing UiLayoutPolicy raw-edge reflow on UiButton's value-child rect (the child is base-inherited across four sibling buttons of differing widths, so its baked-in OriginalParentWidth diverges from the actual 231px-wide Skills credits button) plus an HJustify.Right value child mapped to Center instead of a real far-edge Right. - R4-2: the single-sprite scrollbar thumb tiled (GL_REPEAT) instead of drawing once — DrawTiled was reused for a small fixed marker graphic whose native size is far smaller than the track-proportional thumb rect. New DrawThumbMarker draws exactly one native-size instance. - R4-3: the skills info-box formula line clipped past the surrounding gold frame's own authored bottom edge (the pane's own raw box is 20px taller than the frame that visually contains it) — clamp the pane's Height to the frame's bottom (register AD-105, since retail's ShowSkillsText has no code relationship to the frame to cite). - R4-4: the Appearance help text started mid-sentence — the box was never touched by its page controller, so it kept UiText's chat-style PreserveEndOnLayout=true default; the scroll model's wasAtEnd check is vacuously true on its first-ever overflow transition, pinning the first render to the bottom. Set PreserveEndOnLayout=false (a static top-oriented report, not a transcript) and wired the box's own nested authored scrollbar, never wired before. App suite live-DAT env 5372/3 -> 5379/3 (+7, zero regressions). Runtime 1735/0 unchanged. Full solution 14585/4 skips/1 failure (the documented Core.Net NakEmission full-solution-only flake, confirmed standalone-pass). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
28704db4bf
commit
e6acb800cc
11 changed files with 678 additions and 12 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1028,3 +1028,127 @@ render retail-shaped; four residuals visible in the captures:
|
||||||
next to the article of clothing…") — the opening paragraph is either
|
next to the article of clothing…") — the opening paragraph is either
|
||||||
scrolled off (box has no visible scrollbar) or missing from the
|
scrolled off (box has no visible scrollbar) or missing from the
|
||||||
composition; check what retail authors/composes for that box.
|
composition; check what retail authors/composes for that box.
|
||||||
|
|
||||||
|
**RE-TEST 3 fix batch (2026-08-16, R4-1..R4-4) is CODE-COMPLETE, pending the
|
||||||
|
user's visual gate.** All four findings root-caused and fixed via decomp +
|
||||||
|
live-DAT evidence, no invented pixel offsets. App suite live-DAT env
|
||||||
|
5372/3 → 5379/3 (+7, zero regressions); Runtime 1735/0 unchanged; full
|
||||||
|
solution 14585 tests / 4 skips / 1 failure (Core.Net
|
||||||
|
`NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge`,
|
||||||
|
the documented full-solution-only flake — confirmed passing standalone,
|
||||||
|
unrelated to this batch's files). No client launches.
|
||||||
|
|
||||||
|
- **R4-1 FIXED — root cause was a MISSING raw-edge reflow, not a wrap/clip
|
||||||
|
gap.** Live-DAT probe: the Skills credits value child (`0x100002f3`) is
|
||||||
|
BASE-INHERITED across four sibling buttons of DIFFERING widths —
|
||||||
|
Health/Stamina/Mana at 150px share the exact same child id/rect (local
|
||||||
|
X=116) as the wider, 231px Skills credits button — and the child's own
|
||||||
|
`OriginalParentWidth` (150, baked in wherever it was first resolved,
|
||||||
|
matching Health's own actual width) diverges from Skills credits' real
|
||||||
|
231px parent. `UiButton.ValueBox` was built from the child's RAW
|
||||||
|
(un-reflowed) rect, never running it through `UiLayoutPolicy` — the SAME
|
||||||
|
retail raw-edge system (`UIElement::UpdateForParentSizeChange
|
||||||
|
@0x00462640`) already used for every LIVE mounted `UiElement` via
|
||||||
|
`UiElement.ApplyAnchor`. The child's own edge modes (Left=2/Right=1,
|
||||||
|
live-DAT-confirmed "track the far edge as the parent grows") shift the
|
||||||
|
value box from X=116 to X=197 for Skills credits specifically — landing
|
||||||
|
immediately after the caption's own measured 193px span (ends ≈x=196)
|
||||||
|
instead of colliding mid-caption. Separately, `ValueAlign` mapped
|
||||||
|
`HJustify.Right` (raw dat 3/5, live-DAT-confirmed authored on ALL four
|
||||||
|
value children) to Center — `UIElement_Text::CalcJustification
|
||||||
|
@0x00467260`'s own `ecx_5==3||5` branch is a DISTINCT far-edge formula,
|
||||||
|
not Center's halved offset; added a `LabelAlignment.Right` case.
|
||||||
|
Health/Stamina/Mana (whose `OriginalParentWidth` already matches their
|
||||||
|
own actual width) reflow to their byte-identical raw rect — the fix is
|
||||||
|
additive, not a per-button special case.
|
||||||
|
Files: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildButton`'s
|
||||||
|
value-child block, new `ReflowValueChildRect`), `src/AcDream.App/UI/UiButton.cs`
|
||||||
|
(`LabelAlignment.Right`, `OnDraw`'s value-draw `vx` switch). Tests:
|
||||||
|
`DatWidgetFactoryTests.BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton`
|
||||||
|
(+ its `..._OriginalParentMatchesActual_RectUnchanged` negative
|
||||||
|
companion); live-DAT
|
||||||
|
`CharacterCreationLiveDatTests.SkillsCreditsButton_ValueBoxReflowsPastCaption_HealthValueBoxUnchanged`
|
||||||
|
(pins the real installed DAT's `197,0,34,28` vs `116,0,34,28`).
|
||||||
|
- **R4-2 FIXED — the single-sprite-thumb fallback was TILING (UV-repeat)
|
||||||
|
a small marker graphic instead of drawing it once.** The re-test-2 fix
|
||||||
|
(R3-4/R3-7) correctly identified the thumb sprite but fed it to
|
||||||
|
`DrawTiled` (GL_REPEAT UV wrap) — for a small fixed "diamond" marker
|
||||||
|
drawn into a track-proportional thumb rect far taller than its own
|
||||||
|
native size (`UIElement_Scrollbar::UpdateLayout @0x4710d0`'s
|
||||||
|
`max(MinThumb, trackLen*ThumbRatio)` formula, unchanged/still correct
|
||||||
|
for the rect's SIZE), the texture sampler repeated the marker several
|
||||||
|
times down the track (~9 on Summary's overview bar, ~2 on Skills,
|
||||||
|
matching the live capture). New `DrawThumbMarker` draws exactly ONE
|
||||||
|
instance at the sprite's own native size, centered within the SAME
|
||||||
|
computed rect — neither tiled (the bug) nor stretched into an elongated
|
||||||
|
bar (a naive `DrawSprite` fix would have distorted the diamond shape).
|
||||||
|
The shade slider's own scalar-mode draw path (`DrawVerticalScalar`) was
|
||||||
|
never touched — it already used the correct native-size `DrawSprite`
|
||||||
|
pattern this fix now mirrors for model-mode bars.
|
||||||
|
File: `src/AcDream.App/UI/UiScrollbar.cs` (`DrawVerticalModel`/
|
||||||
|
`DrawHorizontalModel`'s fallback branch, new `DrawThumbMarker`). Test:
|
||||||
|
`UiScrollbarTests.SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack`
|
||||||
|
— reads back the actual emitted quad's UV V-coordinate via
|
||||||
|
`TextRenderer.DebugSpriteSegmentVerts` and asserts it never exceeds 1.0
|
||||||
|
(native); confirmed this test FAILS (V=7.875) against the pre-fix
|
||||||
|
`DrawTiled` call by temporarily reverting and re-running.
|
||||||
|
- **R4-3 FIXED — the description pane's own authored box is genuinely
|
||||||
|
taller than the decorative frame that visually contains it.** Live-DAT
|
||||||
|
geometry walk: the gold frame (`0x100003fa`, the SAME GF-12 corner/edge
|
||||||
|
sprite family as the Appearance help box) spans Y=430 H=110 (bottom
|
||||||
|
Y=540), but the description pane (`0x100003fc`) spans Y=460 H=100
|
||||||
|
(bottom Y=560) — 20px PAST the frame's own bottom border. Composition-
|
||||||
|
height simulation against every one of the 38 skills carrying detail
|
||||||
|
data (real `ChargenTableReader` descriptions + the worst-case
|
||||||
|
description+bonus+formula line count) confirmed the pane's OWN raw
|
||||||
|
100px interior comfortably fits every case (worst: 5 lines / 80px < 90px
|
||||||
|
interior) — ruling out a wrap-width or line-spacing bug. The real
|
||||||
|
mismatch is the SIBLING frame's smaller authored bottom, which the pane
|
||||||
|
was never clamped to, letting a tall composition's last line(s) draw
|
||||||
|
past the frame's own visible border into blank page space. Retail's
|
||||||
|
`ShowSkillsText @0x00481250` has no code linking the panes to the frame
|
||||||
|
(plain `SetText`, no size/clip handoff) — the frame's own authored Y+H
|
||||||
|
is the only available ground truth, not a decomp-confirmed clip
|
||||||
|
mechanism, so this is filed as register **AD-105** (a genuine
|
||||||
|
inference, flagged rather than silently assumed, same shape as R3-3's
|
||||||
|
own AD-104 scoped correction). `CharacterCreationSkillsPage`'s
|
||||||
|
constructor now clamps `_infoText.Height` to the frame's bottom edge
|
||||||
|
whenever it would otherwise be taller (additive; never grows it).
|
||||||
|
File: `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs`
|
||||||
|
(constructor, new `InfoBoxFrameElementId` clamp block). Tests: fixture
|
||||||
|
`CharacterCreationUiControllerTests.SkillsPage_InfoBoxDescriptionPane_HeightClampedToFrameBottom`
|
||||||
|
(the shared `BuildSkillsPage` fixture gained a deliberately-shorter
|
||||||
|
frame element); live-DAT
|
||||||
|
`CharacterCreationLiveDatTests.SkillsInfoBoxFrame_ShorterThanDescriptionPane`
|
||||||
|
(pins the 20px real-DAT mismatch itself).
|
||||||
|
- **R4-4 FIXED — two stacked gaps, the same "page never touched this
|
||||||
|
element" shape as prior holdouts.** The help box (`0x100003ab`) is a
|
||||||
|
purely DAT-authored static paragraph (no `gmCGAppearancePage` runtime
|
||||||
|
composition function exists for it, unlike Town/Summary's
|
||||||
|
`SetTownString`/`SetHowToText` — confirmed absent from the named
|
||||||
|
decomp) that `CharacterCreationAppearancePage` never referenced at all,
|
||||||
|
so it kept `UiText`'s own chat-style default
|
||||||
|
(`PreserveEndOnLayout=true`, "keep a view that is already at the end
|
||||||
|
pinned there"). Its content overflows a 292px-tall frame, and
|
||||||
|
`UiScrollable.SetExtents`'s own `wasAtEnd` check is vacuously true the
|
||||||
|
FIRST time a Scroll model transitions from its zero-initialized state
|
||||||
|
(`ContentHeight=0/ViewHeight=0/ScrollY=0` → `MaxScroll=0` →
|
||||||
|
`AtEnd=(0>=0)=true`) to real overflowing content — with
|
||||||
|
`PreserveEndOnLayout` still true, that spuriously pins the very first
|
||||||
|
render to the BOTTOM, hiding the opening paragraphs exactly as reported
|
||||||
|
(the visible text is mid-way through the third paragraph). This is a
|
||||||
|
static instructions box, not a chat transcript — `PreserveEndOnLayout`'s
|
||||||
|
own doc already carves out exactly this shape ("top-oriented reports
|
||||||
|
such as Character Information disable it"). Also wired the box's own
|
||||||
|
NESTED authored scrollbar (property `0x72`, live-DAT-confirmed a direct
|
||||||
|
Type-11 child of the text box — the SAME nesting shape
|
||||||
|
`CharacterCreationSummaryPage.HowToScrollRelativeId` already uses) —
|
||||||
|
never wired by this page before, so a user can reach the rest of the
|
||||||
|
text even where the box's own height still doesn't fit everything.
|
||||||
|
File: `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs`
|
||||||
|
(constructor, new `HelpTextId`/`HelpScrollRelativeId` block). Tests:
|
||||||
|
fixture
|
||||||
|
`CharacterCreationUiControllerTests.AppearancePage_HelpText_TopOriented_AndOwnScrollbarIsWired`
|
||||||
|
(the shared `BuildAppearancePage` fixture gained the help box + its
|
||||||
|
nested scrollbar child, StateMedia-bearing so `UiText`'s own dat-
|
||||||
|
children carve-out actually builds it).
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,26 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
internal const uint ShadeScrollId = 0x10000321u;
|
internal const uint ShadeScrollId = 0x10000321u;
|
||||||
internal const uint ViewportId = 0x100003BBu;
|
internal const uint ViewportId = 0x100003BBu;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-4 (Campaign CC gate round 1 re-test 3): the framed instructions
|
||||||
|
/// box (the SAME gold corner/edge sprite family the Skills info-box
|
||||||
|
/// frame uses, <c>0x100002de-e3</c>/<c>0x100000e8</c>/<c>0xea</c> —
|
||||||
|
/// live-DAT-confirmed identical children). Its own <c>P0x17</c> caption
|
||||||
|
/// is the FULL static help paragraph (no <c>gmCGAppearancePage</c>
|
||||||
|
/// runtime composition exists for it — unlike Town/Summary's
|
||||||
|
/// <c>SetTownString</c>/<c>SetHowToText</c>, this text is purely
|
||||||
|
/// DAT-authored, confirmed by the absence of any matching function in
|
||||||
|
/// the named decomp).
|
||||||
|
/// </summary>
|
||||||
|
internal const uint HelpTextId = 0x100003ABu;
|
||||||
|
|
||||||
|
/// <summary>The help box's own NESTED scrollbar child (live-DAT-
|
||||||
|
/// confirmed a direct child of <see cref="HelpTextId"/>, the SAME
|
||||||
|
/// structural id/nesting shape as
|
||||||
|
/// <c>CharacterCreationSummaryPage.HowToScrollRelativeId</c>'s own
|
||||||
|
/// how-to box scrollbar).</summary>
|
||||||
|
private const uint HelpScrollRelativeId = 0x100002E7u;
|
||||||
|
|
||||||
/// <summary>Retail's nine <c>SetColor(0..8)</c> swatch buttons, in
|
/// <summary>Retail's nine <c>SetColor(0..8)</c> swatch buttons, in
|
||||||
/// index order — verbatim off <c>ListenToElementMessage</c>'s cases
|
/// index order — verbatim off <c>ListenToElementMessage</c>'s cases
|
||||||
/// <c>5</c>-<c>0xd</c> (<c>elementId - 0x1000030a</c>).</summary>
|
/// <c>5</c>-<c>0xd</c> (<c>elementId - 0x1000030a</c>).</summary>
|
||||||
|
|
@ -202,6 +222,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
private readonly UiButton? _rotateCounterClockwise;
|
private readonly UiButton? _rotateCounterClockwise;
|
||||||
private readonly UiButton? _zoomIn;
|
private readonly UiButton? _zoomIn;
|
||||||
private readonly UiButton? _zoomOut;
|
private readonly UiButton? _zoomOut;
|
||||||
|
private readonly UiText? _helpText;
|
||||||
|
|
||||||
/// <summary>The gradient disc (<c>0x1000030e</c>) — Type 3 in the
|
/// <summary>The gradient disc (<c>0x1000030e</c>) — Type 3 in the
|
||||||
/// authored dat, so <see cref="UiDatElement"/> (not the base
|
/// authored dat, so <see cref="UiDatElement"/> (not the base
|
||||||
|
|
@ -354,6 +375,39 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
_zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal);
|
_zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// R4-4 (Campaign CC gate round 1 re-test 3): the help box's static
|
||||||
|
// paragraph starts mid-sentence because this port never touched
|
||||||
|
// this element at all — it built through the plain DatWidgetFactory
|
||||||
|
// import path with UiText's own chat-style default
|
||||||
|
// (PreserveEndOnLayout=true, "keep a view that is already at the
|
||||||
|
// end pinned there" — see that property's own doc: "Chat uses the
|
||||||
|
// default; top-oriented reports such as Character Information
|
||||||
|
// disable it"). This box's content overflows its own view (a full
|
||||||
|
// multi-paragraph instructions block in a 292px-tall frame), and
|
||||||
|
// UiScrollable.SetExtents's own wasAtEnd check is vacuously true
|
||||||
|
// the very first time a Scroll model transitions from its
|
||||||
|
// zero-initialized state (ContentHeight=0/ViewHeight=0/ScrollY=0 ->
|
||||||
|
// MaxScroll=0 -> AtEnd=(0>=0)=true) to real overflowing content —
|
||||||
|
// with PreserveEndOnLayout still true, that spuriously pins the
|
||||||
|
// FIRST-EVER render to the bottom, hiding the opening paragraph
|
||||||
|
// exactly as reported ("right arrows next to the article of
|
||||||
|
// clothing..." is mid-way through the third paragraph, not the
|
||||||
|
// first). This is a static instructions box, not a chat transcript
|
||||||
|
// — the SAME top-oriented-report shape PreserveEndOnLayout's own
|
||||||
|
// doc already carves out. Also wires the box's own nested authored
|
||||||
|
// scrollbar (property 0x72, live-DAT-confirmed a direct child) —
|
||||||
|
// NEVER wired by this page before — so a user can still reach the
|
||||||
|
// rest of the text if it doesn't fully fit, the SAME
|
||||||
|
// scrollbar.Model = text.Scroll linkage
|
||||||
|
// CharacterCreationSummaryPage's how-to box already uses.
|
||||||
|
_helpText = Find<UiText>(pageRoot, HelpTextId);
|
||||||
|
if (_helpText is not null)
|
||||||
|
{
|
||||||
|
_helpText.PreserveEndOnLayout = false;
|
||||||
|
if (Find<UiScrollbar>(_helpText, HelpScrollRelativeId) is { } helpScroll)
|
||||||
|
helpScroll.Model = _helpText.Scroll;
|
||||||
|
}
|
||||||
|
|
||||||
ApplyChoiceVisibility();
|
ApplyChoiceVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,45 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
||||||
infoTitle.VerticalJustify = VJustify.Top;
|
infoTitle.VerticalJustify = VJustify.Top;
|
||||||
if (_infoText is { } infoText)
|
if (_infoText is { } infoText)
|
||||||
infoText.VerticalJustify = VJustify.Top;
|
infoText.VerticalJustify = VJustify.Top;
|
||||||
|
|
||||||
|
// R4-3 (Campaign CC gate round 1 re-test 3): the description pane's
|
||||||
|
// own raw box (0x100003fc, Y=460 H=100 -> bottom Y=560, live-DAT-
|
||||||
|
// measured) extends 20px PAST the bottom of the gold decorative
|
||||||
|
// frame that visually contains BOTH info panes (0x100003fa, Y=430
|
||||||
|
// H=110 -> bottom Y=540, the SAME GF-12 corner/edge sprite family
|
||||||
|
// Batch C un-consumed — 0x100002de-e3/0x100000e8/0xea). Retail's own
|
||||||
|
// ShowSkillsText @0x00481250 has NO code relationship between the
|
||||||
|
// text panes and this frame (SetText only; no clip/size handoff),
|
||||||
|
// and the frame's 8 children carry no dat property linking them to
|
||||||
|
// 0x100003fc either — so the frame's own geometry is the only
|
||||||
|
// authored ground truth for "the visible box," and this port's
|
||||||
|
// multi-line clip (UiText.DrawText's own PushClip(0,0,Width,Height))
|
||||||
|
// was using the WRONG (larger, unbounded) Height, letting a long
|
||||||
|
// skill's formula line draw into blank page space below the frame's
|
||||||
|
// own border instead of being contained by it. Clamped to the
|
||||||
|
// frame's own bottom edge (never grows it — additive, defensive if a
|
||||||
|
// future dat re-extract makes the frame taller than the pane).
|
||||||
|
// Scoped exactly like the VJustify.Top correction above: this is
|
||||||
|
// NOT the client-wide "does a text pane's clip account for a
|
||||||
|
// sibling decorative frame" mechanism (no evidence any other pane in
|
||||||
|
// this codebase has the SAME independently-authored-taller-than-its-
|
||||||
|
// frame shape), so a general import-time fix is unwarranted here.
|
||||||
|
if (_infoText is { } clampedInfoText
|
||||||
|
&& UiElement.FindDescendant(pageRoot, InfoBoxFrameElementId) is { } frame)
|
||||||
|
{
|
||||||
|
float frameBottom = frame.Top + frame.Height;
|
||||||
|
float paneBottom = clampedInfoText.Top + clampedInfoText.Height;
|
||||||
|
if (frameBottom < paneBottom)
|
||||||
|
clampedInfoText.Height = frameBottom - clampedInfoText.Top;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The gold decorative frame (Type 12, 8 sprite children — the SAME
|
||||||
|
/// GF-12 corner/edge family) that visually contains BOTH info panes
|
||||||
|
/// (<c>0x100003fb</c>/<c>0x100003fc</c>) — see the R4-3 clamp above.
|
||||||
|
/// </summary>
|
||||||
|
private const uint InfoBoxFrameElementId = 0x100003FAu;
|
||||||
|
|
||||||
internal void Refresh(
|
internal void Refresh(
|
||||||
IRuntimeCharacterCreationView view,
|
IRuntimeCharacterCreationView view,
|
||||||
|
|
|
||||||
|
|
@ -1009,14 +1009,29 @@ public static class DatWidgetFactory
|
||||||
child => child.Type == 12u && child.StateMedia.Count == 0);
|
child => child.Type == 12u && child.StateMedia.Count == 0);
|
||||||
if (valueChild is not null)
|
if (valueChild is not null)
|
||||||
{
|
{
|
||||||
button.ValueBox = (valueChild.X, valueChild.Y, valueChild.Width, valueChild.Height);
|
// R4-1 (Campaign CC gate round 1 re-test 3): reflow the value
|
||||||
|
// child's authored rect through retail's own raw-edge policy
|
||||||
|
// (UIElement::UpdateForParentSizeChange @0x00462640, ported
|
||||||
|
// as UiLayoutPolicy) before it becomes ValueBox — see
|
||||||
|
// ReflowValueChildRect's own doc for why this is needed and
|
||||||
|
// decomp-cited.
|
||||||
|
button.ValueBox = ReflowValueChildRect(valueChild, info);
|
||||||
button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null
|
button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null
|
||||||
? fontResolve(valueChild.FontDid) ?? elementFont
|
? fontResolve(valueChild.FontDid) ?? elementFont
|
||||||
: elementFont;
|
: elementFont;
|
||||||
button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One;
|
button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One;
|
||||||
button.ValueAlign = valueChild.HJustify == HJustify.Left
|
button.ValueAlign = valueChild.HJustify switch
|
||||||
? UiButton.LabelAlignment.Left
|
{
|
||||||
: UiButton.LabelAlignment.Center;
|
HJustify.Left => UiButton.LabelAlignment.Left,
|
||||||
|
// R4-1: HJustify.Right (raw dat 3/5) previously fell into
|
||||||
|
// this ternary's Center branch — CalcJustification's own
|
||||||
|
// ecx_5==3||5 case is a DISTINCT far-edge formula (see
|
||||||
|
// UiButton.LabelAlignment.Right's own doc), and every
|
||||||
|
// value child in this family (0x100002f1/0x100002f3)
|
||||||
|
// authors HJustify Right, live-DAT-confirmed.
|
||||||
|
HJustify.Right => UiButton.LabelAlignment.Right,
|
||||||
|
_ => UiButton.LabelAlignment.Center,
|
||||||
|
};
|
||||||
// Seed with whatever the child itself authors (typically
|
// Seed with whatever the child itself authors (typically
|
||||||
// blank) so an unbound button doesn't draw stray leftover
|
// blank) so an unbound button doesn't draw stray leftover
|
||||||
// text before a controller writes a real value.
|
// text before a controller writes a real value.
|
||||||
|
|
@ -1027,6 +1042,60 @@ public static class DatWidgetFactory
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-1 (Campaign CC gate round 1 re-test 3): the "Available Skill
|
||||||
|
/// Credits" value overlapped mid-caption ("Available Skill0Credits")
|
||||||
|
/// because <see cref="UiButton.ValueBox"/> was built from the value
|
||||||
|
/// child's RAW authored rect, un-reflowed. Live-DAT probe: the value
|
||||||
|
/// child (<c>0x100002f3</c>) is BASE-INHERITED across four sibling
|
||||||
|
/// buttons of DIFFERING widths — Health/Stamina/Mana at 150px share the
|
||||||
|
/// exact same child id/rect (local X=116) as the wider, 231px Skills
|
||||||
|
/// credits button, and the child's own <c>OriginalParentWidth</c> (the
|
||||||
|
/// design-time parent size baked in at whichever button FIRST resolved
|
||||||
|
/// it — 150, matching Health's own actual width) diverges from Skills
|
||||||
|
/// credits' actual current parent width (231) — exactly the shape
|
||||||
|
/// <see cref="UiLayoutPolicy"/> (retail
|
||||||
|
/// <c>UIElement::UpdateForParentSizeChange @0x00462640</c>, already the
|
||||||
|
/// production raw-edge reflow for live mounted elements via
|
||||||
|
/// <see cref="UiElement.ApplyAnchor"/>) exists to correct. The child's
|
||||||
|
/// own edge modes (Left=2/Right=1, live-DAT-confirmed) are retail's
|
||||||
|
/// "track the far edge as the parent grows" reflow: applying them moves
|
||||||
|
/// the value box from local X=116 to X=197 for Skills credits — landing
|
||||||
|
/// immediately after the caption's own measured end (~x=196,
|
||||||
|
/// <c>SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint</c>)
|
||||||
|
/// instead of colliding mid-caption. Health/Stamina/Mana and the
|
||||||
|
/// Attribute/Credits value child (whose OWN OriginalParentWidth already
|
||||||
|
/// matches their actual parent, or whose edge modes are all 0/fixed)
|
||||||
|
/// reflow to their byte-identical raw rect (deltaX=0 or mode-0 passthrough)
|
||||||
|
/// — this is additive for every already-correct button, not a per-button
|
||||||
|
/// special case.
|
||||||
|
/// </summary>
|
||||||
|
private static (float X, float Y, float Width, float Height) ReflowValueChildRect(
|
||||||
|
ElementInfo child, ElementInfo parent)
|
||||||
|
{
|
||||||
|
float originalParentWidth = child.HasOriginalParentSize ? child.OriginalParentWidth : parent.Width;
|
||||||
|
float originalParentHeight = child.HasOriginalParentSize ? child.OriginalParentHeight : parent.Height;
|
||||||
|
|
||||||
|
var originalChild = UiPixelRect.FromPositionAndSize(
|
||||||
|
(int)child.X, (int)child.Y, (int)child.Width, (int)child.Height);
|
||||||
|
var originalParent = UiPixelRect.FromPositionAndSize(
|
||||||
|
0, 0, (int)originalParentWidth, (int)originalParentHeight);
|
||||||
|
var currentParent = UiPixelRect.FromPositionAndSize(
|
||||||
|
0, 0, (int)parent.Width, (int)parent.Height);
|
||||||
|
// Empty (Width=0/Height=0) "current child" so the static Apply's
|
||||||
|
// currentChild-preservation branch never engages — every axis comes
|
||||||
|
// from the Near/Far formula, matching mode 0's own "keep the raw
|
||||||
|
// authored edge" default for the (frequent) no-anchor case.
|
||||||
|
var noCurrentChild = new UiPixelRect(0, 0, -1, -1);
|
||||||
|
|
||||||
|
UiPixelRect reflowed = UiLayoutPolicy.Apply(
|
||||||
|
child.Left, child.Top, child.Right, child.Bottom,
|
||||||
|
originalChild, originalParent,
|
||||||
|
noCurrentChild, currentParent);
|
||||||
|
|
||||||
|
return (reflowed.X0, reflowed.Y0, reflowed.Width, reflowed.Height);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
|
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
|
||||||
/// authored indicator child. Its label lives on the option object rather than
|
/// authored indicator child. Its label lives on the option object rather than
|
||||||
|
|
|
||||||
|
|
@ -260,8 +260,18 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
/// <see cref="ValueBox"/> — the lifted child's own authored justify.</summary>
|
/// <see cref="ValueBox"/> — the lifted child's own authored justify.</summary>
|
||||||
public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center;
|
public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center;
|
||||||
|
|
||||||
/// <summary>Label horizontal alignment options.</summary>
|
/// <summary>
|
||||||
public enum LabelAlignment { Center, Left }
|
/// Label horizontal alignment options. <see cref="Right"/> (R4-1, Campaign
|
||||||
|
/// CC gate round 1 re-test 3) is ValueLabel-only today — every value
|
||||||
|
/// child on the chargen credit-display family (0x100002f1/0x100002f3)
|
||||||
|
/// authors dat HJustify Right (raw 3/5), decomp-confirmed by
|
||||||
|
/// <c>UIElement_Text::CalcJustification @0x00467260</c>'s
|
||||||
|
/// <c>ecx_5==3||5</c> branch (<c>edi = availWidth - textWidth</c>, i.e.
|
||||||
|
/// flush to the box's own far edge) — distinct from Center's halved
|
||||||
|
/// offset. <see cref="LabelAlign"/> never authors Right today so no
|
||||||
|
/// existing switch over it needs a new arm.
|
||||||
|
/// </summary>
|
||||||
|
public enum LabelAlignment { Center, Left, Right }
|
||||||
|
|
||||||
public bool ToggleBehavior { get; }
|
public bool ToggleBehavior { get; }
|
||||||
public bool RolloverEnabled { get; }
|
public bool RolloverEnabled { get; }
|
||||||
|
|
@ -568,9 +578,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
float boxY = ValueBox?.Y ?? 0f;
|
float boxY = ValueBox?.Y ?? 0f;
|
||||||
float boxWidth = ValueBox?.Width ?? Width;
|
float boxWidth = ValueBox?.Width ?? Width;
|
||||||
float boxHeight = ValueBox?.Height ?? Height;
|
float boxHeight = ValueBox?.Height ?? Height;
|
||||||
float vx = ValueAlign == LabelAlignment.Left
|
float valueWidth = vf.MeasureWidth(value);
|
||||||
? boxX + LabelOffsetX
|
// R4-1: Right mirrors CalcJustification's own far-edge formula
|
||||||
: boxX + (boxWidth - vf.MeasureWidth(value)) * 0.5f;
|
// (box's own right edge minus the measured text width, no
|
||||||
|
// decorative inset — the decomp's Right branch adds none either,
|
||||||
|
// and this box carries no threaded marginR of its own).
|
||||||
|
float vx = ValueAlign switch
|
||||||
|
{
|
||||||
|
LabelAlignment.Left => boxX + LabelOffsetX,
|
||||||
|
LabelAlignment.Right => boxX + boxWidth - valueWidth,
|
||||||
|
_ => boxX + (boxWidth - valueWidth) * 0.5f,
|
||||||
|
};
|
||||||
float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f;
|
float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f;
|
||||||
ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor);
|
ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -286,11 +286,60 @@ public sealed class UiScrollbar : UiElement
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th);
|
// R4-2 (Campaign CC gate round 1 re-test 3): the single-
|
||||||
|
// sprite thumb shape (no top/bottom caps — see this method's
|
||||||
|
// own doc, the R3-4/R3-7 fallback family: Skills listbox
|
||||||
|
// 0x100003f8, Summary overview 0x10000401, Summary how-to
|
||||||
|
// 0x100002e7) is a small fixed "diamond" marker graphic, NOT
|
||||||
|
// a stretchy bar — DrawTiled's UV-repeat was drawing it
|
||||||
|
// MULTIPLE times to fill the track-proportional thumb rect
|
||||||
|
// (~9 repeats on Summary's overview bar, ~2 on Skills, per
|
||||||
|
// the live capture). DrawThumbMarker draws exactly ONE
|
||||||
|
// instance at its own native size.
|
||||||
|
DrawThumbMarker(ctx, resolve, ThumbSprite, 0f, ty, Width, th, vertical: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a
|
||||||
|
/// single-sprite scrollbar thumb at its own native size, centered
|
||||||
|
/// within the computed thumb rect (<see cref="ThumbRect"/>'s own
|
||||||
|
/// decomp-cited <c>UIElement_Scrollbar::UpdateLayout @0x4710d0</c>
|
||||||
|
/// track-proportional geometry stays unchanged — this only changes HOW
|
||||||
|
/// the sprite fills that rect). Neither <see cref="DrawTiled"/> (UV-
|
||||||
|
/// repeat — draws the small marker graphic several times to fill a
|
||||||
|
/// large proportional thumb rect, R4-2's own "tiled diamonds" report)
|
||||||
|
/// nor a naive 1:1 stretch across the full computed rect (would distort
|
||||||
|
/// a small marker into an elongated bar) is correct for this shape —
|
||||||
|
/// <paramref name="vertical"/> selects which
|
||||||
|
/// axis is being filled/centered: a vertical scrollbar's thumb rect
|
||||||
|
/// varies in height (X/Width stay the bar's own full width, matching
|
||||||
|
/// every other draw call in this class), a horizontal one varies in
|
||||||
|
/// width (Y/Height stay the bar's own full height).
|
||||||
|
/// </summary>
|
||||||
|
private void DrawThumbMarker(
|
||||||
|
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
|
uint id, float rectX, float rectY, float rectW, float rectH, bool vertical)
|
||||||
|
{
|
||||||
|
if (id == 0 || rectW <= 0f || rectH <= 0f) return;
|
||||||
|
var (tex, nativeW, nativeH) = resolve(id);
|
||||||
|
if (tex == 0 || nativeW == 0 || nativeH == 0) return;
|
||||||
|
|
||||||
|
if (vertical)
|
||||||
|
{
|
||||||
|
float drawH = MathF.Min(nativeH, rectH);
|
||||||
|
float y = rectY + (rectH - drawH) * 0.5f;
|
||||||
|
ctx.DrawSprite(tex, rectX, y, rectW, drawH, 0f, 0f, rectW / nativeW, drawH / nativeH, Vector4.One);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float drawW = MathF.Min(nativeW, rectW);
|
||||||
|
float x = rectX + (rectW - drawW) * 0.5f;
|
||||||
|
ctx.DrawSprite(tex, x, rectY, drawW, rectH, 0f, 0f, drawW / nativeW, rectH / nativeH, Vector4.One);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawHorizontalModel(
|
private void DrawHorizontalModel(
|
||||||
UiRenderContext ctx,
|
UiRenderContext ctx,
|
||||||
Func<uint, (uint tex, int w, int h)> resolve,
|
Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
|
|
@ -315,7 +364,8 @@ public sealed class UiScrollbar : UiElement
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height);
|
// R4-2: horizontal counterpart of the vertical fallback above.
|
||||||
|
DrawThumbMarker(ctx, resolve, ThumbSprite, tx, 0f, tw, Height, vertical: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1673,6 +1673,36 @@ public sealed class CharacterCreationLiveDatTests
|
||||||
Assert.Equal(116f, valueChild.X);
|
Assert.Equal(116f, valueChild.X);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-1 (re-test 3): the raw authored value-child X (116, pinned above)
|
||||||
|
/// is NOT where the value actually draws — <c>UiLayoutPolicy</c>'s
|
||||||
|
/// raw-edge reflow (the value child's own Right-tracking edge modes
|
||||||
|
/// against its base-inherited 150px <c>OriginalParentWidth</c> vs the
|
||||||
|
/// Skills-credits button's actual 231px width) shifts it to X=197,
|
||||||
|
/// landing right after the caption's own measured 193px span instead
|
||||||
|
/// of colliding mid-caption ("Available Skill0Credits"). Health's own
|
||||||
|
/// value child shares the SAME 150px OriginalParentWidth as its OWN
|
||||||
|
/// actual 150px-wide button (no divergence), so it reflows to its
|
||||||
|
/// byte-identical raw rect — proving the fix is additive, not a
|
||||||
|
/// blanket shift.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void SkillsCreditsButton_ValueBoxReflowsPastCaption_HealthValueBoxUnchanged()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ImportedLayout screen = BuildSelected(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||||
|
|
||||||
|
UiButton skillsCredits = Assert.IsType<UiButton>(screen.FindElement(0x100003F9u));
|
||||||
|
Assert.Equal((197f, 0f, 34f, 28f), skillsCredits.ValueBox);
|
||||||
|
Assert.Equal(UiButton.LabelAlignment.Right, skillsCredits.ValueAlign);
|
||||||
|
|
||||||
|
UiButton health = Assert.IsType<UiButton>(screen.FindElement(0x100003E3u));
|
||||||
|
Assert.Equal((116f, 0f, 34f, 28f), health.ValueBox);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// R3-3 (re-test 2): the info-box title (<c>0x100003fb</c>) and
|
/// R3-3 (re-test 2): the info-box title (<c>0x100003fb</c>) and
|
||||||
/// description (<c>0x100003fc</c>) panes' own AUTHORED boxes overlap —
|
/// description (<c>0x100003fc</c>) panes' own AUTHORED boxes overlap —
|
||||||
|
|
@ -1714,6 +1744,41 @@ public sealed class CharacterCreationLiveDatTests
|
||||||
Assert.True(description.Y > title.Y);
|
Assert.True(description.Y > title.Y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-3 (re-test 3): the description pane's own raw box (Y=460,
|
||||||
|
/// H=100 -> bottom Y=560) extends PAST the bottom of the gold
|
||||||
|
/// decorative frame that visually contains both info panes
|
||||||
|
/// (<c>0x100003fa</c>, Y=430 H=110 -> bottom Y=540 — the SAME
|
||||||
|
/// corner/edge sprite family GF-12 already renders,
|
||||||
|
/// <c>0x100002de-e3</c>/<c>0x100000e8</c>/<c>0xea</c>). Pins the
|
||||||
|
/// geometric mismatch itself (so a future DAT re-extract that removes
|
||||||
|
/// it is visible) — <c>CharacterCreationSkillsPageTests</c>' own fixture
|
||||||
|
/// covers the constructor's Height-clamp behavior against this exact
|
||||||
|
/// shape.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void SkillsInfoBoxFrame_ShorterThanDescriptionPane()
|
||||||
|
{
|
||||||
|
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 frame = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003FAu));
|
||||||
|
ElementInfo description = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003FCu));
|
||||||
|
|
||||||
|
float frameBottom = frame.Y + frame.Height;
|
||||||
|
float paneBottom = description.Y + description.Height;
|
||||||
|
Assert.True(
|
||||||
|
frameBottom < paneBottom,
|
||||||
|
$"expected the frame's own bottom (Y={frame.Y} H={frame.Height}, bottom={frameBottom}) to sit "
|
||||||
|
+ $"ABOVE the description pane's own raw bottom (Y={description.Y} H={description.Height}, "
|
||||||
|
+ $"bottom={paneBottom}) — if it no longer does, CharacterCreationSkillsPage's Height clamp "
|
||||||
|
+ "may no longer be needed");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// R3-4/R3-7 (re-test 2): retail authors TWO distinct
|
/// R3-4/R3-7 (re-test 2): retail authors TWO distinct
|
||||||
/// <c>UIElement_Scrollbar</c> thumb shapes. Chat's own scrollbar
|
/// <c>UIElement_Scrollbar</c> thumb shapes. Chat's own scrollbar
|
||||||
|
|
|
||||||
|
|
@ -457,6 +457,37 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
Assert.Equal(VJustify.Top, environment.SkillInfoText().VerticalJustify);
|
Assert.Equal(VJustify.Top, environment.SkillInfoText().VerticalJustify);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-3 (Campaign CC gate round 1 re-test 3): the description pane's
|
||||||
|
/// own raw box (<c>0x100003fc</c>) is TALLER than the surrounding gold
|
||||||
|
/// decorative frame that visually contains it (<c>0x100003fa</c> —
|
||||||
|
/// live-DAT-measured, see <see cref="CharacterCreationSkillsPage"/>'s
|
||||||
|
/// own R4-3 comment for the full geometry + decomp citation: retail's
|
||||||
|
/// <c>ShowSkillsText</c> has no code relationship between the text
|
||||||
|
/// panes and this frame, so the frame's own authored bottom edge is
|
||||||
|
/// the only ground truth for "the visible box"). Before this fix, a
|
||||||
|
/// long skill's formula line could draw into blank page space below
|
||||||
|
/// the frame's own border — <c>BuildSkillsPage</c>'s fixture frame
|
||||||
|
/// (Y=0 H=50) is deliberately shorter than <c>TextInfo</c>'s own
|
||||||
|
/// default pane Height (60), so this pins the constructor clamping the
|
||||||
|
/// live-mounted pane's own Height down to the frame's bottom edge
|
||||||
|
/// (50) instead of leaving it at its own larger raw 60.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SkillsPage_InfoBoxDescriptionPane_HeightClampedToFrameBottom()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||||
|
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
|
||||||
|
|
||||||
|
Assert.Equal(50f, environment.SkillInfoText().Height, 3f);
|
||||||
|
// The title pane sits OUTSIDE the frame's own child range in this
|
||||||
|
// fixture (a sibling, not touched by the clamp) — confirms the fix
|
||||||
|
// is scoped to the description pane only, matching the constructor.
|
||||||
|
Assert.Equal(60f, environment.SkillInfoTitle().Height, 3f);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>R2-4a: retail re-selects the row after an arrow click too
|
/// <summary>R2-4a: retail re-selects the row after an arrow click too
|
||||||
/// (<c>ListenToElementMessage @0x004814c0</c>'s own
|
/// (<c>ListenToElementMessage @0x004814c0</c>'s own
|
||||||
/// <c>SetSelectedItem(...,1)</c> call following
|
/// <c>SetSelectedItem(...,1)</c> call following
|
||||||
|
|
@ -1229,6 +1260,34 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
|
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-4 (Campaign CC gate round 1 re-test 3): the framed help/
|
||||||
|
/// instructions box (<c>0x100003ab</c>) — before this fix, this
|
||||||
|
/// element was never touched by <see cref="CharacterCreationAppearancePage"/>'s
|
||||||
|
/// constructor at all, so it kept <see cref="UiText"/>'s own chat-style
|
||||||
|
/// default (<c>PreserveEndOnLayout=true</c>) and its own nested
|
||||||
|
/// authored scrollbar (live-DAT-confirmed a direct Type-11 child,
|
||||||
|
/// property <c>0x72</c>) was never wired to
|
||||||
|
/// <see cref="UiText.Scroll"/>. Pins both halves of the fix: the box is
|
||||||
|
/// no longer chat-style bottom-pinned, and the scrollbar's
|
||||||
|
/// <see cref="UiScrollbar.Model"/> now points at the SAME
|
||||||
|
/// <see cref="UiScrollable"/> the text itself scrolls.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppearancePage_HelpText_TopOriented_AndOwnScrollbarIsWired()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
|
||||||
|
UiText helpText = Assert.IsType<UiText>(
|
||||||
|
environment.Screen.FindElement(CharacterCreationAppearancePage.HelpTextId));
|
||||||
|
Assert.False(helpText.PreserveEndOnLayout);
|
||||||
|
|
||||||
|
UiScrollbar helpScroll = Assert.IsType<UiScrollbar>(
|
||||||
|
UiElement.FindDescendant(helpText, 0x100002E7u));
|
||||||
|
Assert.Same(helpText.Scroll, helpScroll.Model);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// GF-10 (Campaign CC gate round 1 Batch B): ports
|
/// GF-10 (Campaign CC gate round 1 Batch B): ports
|
||||||
/// <c>gmCGAppearancePage::ZoomIn @0x0047CF00</c>
|
/// <c>gmCGAppearancePage::ZoomIn @0x0047CF00</c>
|
||||||
|
|
@ -2813,6 +2872,21 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
page.Children.Add(ScrollbarInfo(0x100003F8u));
|
page.Children.Add(ScrollbarInfo(0x100003F8u));
|
||||||
page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge
|
page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge
|
||||||
page.Children.Add(TextInfo(0x100003FBu));
|
page.Children.Add(TextInfo(0x100003FBu));
|
||||||
|
// R4-3 (re-test 3): the description pane's own decorative frame
|
||||||
|
// (0x100003fa, live-DAT-measured SHORTER than the pane it visually
|
||||||
|
// contains — see CharacterCreationSkillsPage's own R4-3 comment).
|
||||||
|
// Y=0/Height=50 here is deliberately shorter than TextInfo's own
|
||||||
|
// default Height=60 so CharacterCreationSkillsPageTests can pin the
|
||||||
|
// constructor's Height clamp without needing the real installed
|
||||||
|
// DAT's exact pixel geometry.
|
||||||
|
page.Children.Add(new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x100003FAu,
|
||||||
|
Type = 12u,
|
||||||
|
Y = 0f,
|
||||||
|
Width = 200f,
|
||||||
|
Height = 50f,
|
||||||
|
});
|
||||||
page.Children.Add(TextInfo(0x100003FCu));
|
page.Children.Add(TextInfo(0x100003FCu));
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
@ -2912,6 +2986,22 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId));
|
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId));
|
||||||
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
|
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
|
||||||
|
|
||||||
|
// R4-4 (re-test 3): the framed instructions box, with its OWN
|
||||||
|
// nested authored scrollbar child — live-DAT-confirmed shape (a
|
||||||
|
// direct Type-11 child of the Type-12 text box, the SAME nesting
|
||||||
|
// CharacterCreationSummaryPage's HowToScrollRelativeId already
|
||||||
|
// uses). Deliberately taller than one view's worth so the long
|
||||||
|
// static help paragraph genuinely overflows in the test below.
|
||||||
|
var helpText = TextInfo(CharacterCreationAppearancePage.HelpTextId);
|
||||||
|
var helpScrollInfo = new ElementInfo { Id = 0x100002E7u, Type = 11u, Width = 12f, Height = 40f };
|
||||||
|
// UiText/UiField's own dat-children carve-out (LayoutImporter.BuildWidget)
|
||||||
|
// only builds a child that carries its own authored StateMedia — the
|
||||||
|
// SAME "genuinely renderable chrome, not swallowed prototype data"
|
||||||
|
// gate the real scrollbar's own DirectState track sprite satisfies.
|
||||||
|
helpScrollInfo.StateMedia[""] = (0x06001919u, 1);
|
||||||
|
helpText.Children.Add(helpScrollInfo);
|
||||||
|
page.Children.Add(helpText);
|
||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -499,6 +499,106 @@ public class DatWidgetFactoryTests
|
||||||
Assert.Equal("42", button.ValueLabel);
|
Assert.Equal("42", button.ValueLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-1 (Campaign CC gate round 1 re-test 3): the value child
|
||||||
|
/// (<c>0x100002f3</c>) is BASE-INHERITED across four sibling buttons of
|
||||||
|
/// DIFFERING widths — Health/Stamina/Mana at 150px share the same
|
||||||
|
/// child id/rect (local X=116) as the wider, 231px Skills credits
|
||||||
|
/// button — so the child's own authored <c>OriginalParentWidth</c>
|
||||||
|
/// (baked in at whichever button FIRST resolved it, 150) diverges from
|
||||||
|
/// the ACTUAL containing button's current width (231) for exactly the
|
||||||
|
/// wider button. Before this fix, <c>ValueBox</c> was the child's raw
|
||||||
|
/// (un-reflowed) rect regardless — this fixture reproduces that exact
|
||||||
|
/// shape (a 150px "design" width baked into the child, hosted under a
|
||||||
|
/// 231px-wide button, with the child's own Right-tracking edge modes
|
||||||
|
/// 2/1) and proves <c>UiLayoutPolicy</c>'s raw-edge reflow now shifts
|
||||||
|
/// the value box by the SAME 81px the button grew (116 -> 197),
|
||||||
|
/// landing clear of "Available Skill Credits"'s own measured span
|
||||||
|
/// instead of colliding mid-caption ("Available Skill0Credits").
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton()
|
||||||
|
{
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
||||||
|
var valueChild = new ElementInfo
|
||||||
|
{
|
||||||
|
Type = 12,
|
||||||
|
X = 116,
|
||||||
|
Y = 0,
|
||||||
|
Width = 34,
|
||||||
|
Height = 28,
|
||||||
|
Left = 2,
|
||||||
|
Top = 1,
|
||||||
|
Right = 1,
|
||||||
|
Bottom = 1,
|
||||||
|
OriginalParentWidth = 150,
|
||||||
|
OriginalParentHeight = 28,
|
||||||
|
HasOriginalParentSize = true,
|
||||||
|
HJustify = HJustify.Right,
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
// 116 + (231-150) = 197 -> width/height preserved (34/28).
|
||||||
|
Assert.Equal((197f, 0f, 34f, 28f), button.ValueBox);
|
||||||
|
Assert.Equal(UiButton.LabelAlignment.Right, button.ValueAlign);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Negative companion to the reflow test above: a value child whose
|
||||||
|
/// OWN authored parent width already MATCHES the actual button (the
|
||||||
|
/// Health/Stamina/Mana shape, un-widened) reflows to its byte-identical
|
||||||
|
/// raw rect — delta is zero, so this is confirmed additive, not a
|
||||||
|
/// blanket shift.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildButton_ValueChildOriginalParentMatchesActual_RectUnchanged()
|
||||||
|
{
|
||||||
|
uint captionStringId = 334u;
|
||||||
|
var info = new ElementInfo { Type = 1, Width = 150, 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),
|
||||||
|
};
|
||||||
|
|
||||||
|
var valueChild = new ElementInfo
|
||||||
|
{
|
||||||
|
Type = 12,
|
||||||
|
X = 116,
|
||||||
|
Y = 0,
|
||||||
|
Width = 34,
|
||||||
|
Height = 28,
|
||||||
|
Left = 2,
|
||||||
|
Top = 1,
|
||||||
|
Right = 1,
|
||||||
|
Bottom = 1,
|
||||||
|
OriginalParentWidth = 150,
|
||||||
|
OriginalParentHeight = 28,
|
||||||
|
HasOriginalParentSize = true,
|
||||||
|
};
|
||||||
|
info.Children.Add(valueChild);
|
||||||
|
|
||||||
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
||||||
|
info, NoTex, null,
|
||||||
|
stringResolve: value => value.StringId == captionStringId ? "Health" : null));
|
||||||
|
|
||||||
|
Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Negative companion: a button whose caption was LIFTED from a
|
/// Negative companion: a button whose caption was LIFTED from a
|
||||||
/// distinct Type-12 child (the town-marker shape,
|
/// distinct Type-12 child (the town-marker shape,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
using AcDream.App.Tests.Rendering.Gpu;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -466,4 +471,56 @@ public class UiScrollbarTests
|
||||||
Assert.Equal(expectedWidth, width, 3);
|
Assert.Equal(expectedWidth, width, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||||
|
{
|
||||||
|
public IGpuFrame? CurrentFrame => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-2 (Campaign CC gate round 1 re-test 3): the re-test-2 single-
|
||||||
|
/// sprite-thumb fallback (R3-4/R3-7, <see cref="UiScrollbar.OnDraw"/>'s
|
||||||
|
/// no-cap-sprites branch) used to call the same UV-repeat
|
||||||
|
/// <c>DrawTiled</c> the 3-slice middle tile uses — for a small fixed
|
||||||
|
/// "diamond" marker sprite drawn into a MUCH taller track-proportional
|
||||||
|
/// thumb rect, GL_REPEAT wrapping visibly tiled the marker several
|
||||||
|
/// times down the track (Summary's overview bar ~9, Skills 2, per the
|
||||||
|
/// live capture). Proves the fix draws exactly ONE quad for the thumb
|
||||||
|
/// texture whose V range never exceeds native (1.0) — i.e. one
|
||||||
|
/// unstretched, untiled sprite instance — even though the computed
|
||||||
|
/// thumb rect (168px trackLen * ThumbRatio 0.75 = 126px, well past the
|
||||||
|
/// sprite's native 16px) is far taller than the sprite.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack()
|
||||||
|
{
|
||||||
|
var device = new RecordingGpuDevice();
|
||||||
|
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
||||||
|
renderer.Begin(new Vector2(800f, 600f));
|
||||||
|
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
|
||||||
|
|
||||||
|
const uint thumbTex = 42u;
|
||||||
|
var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150 };
|
||||||
|
var bar = new UiScrollbar
|
||||||
|
{
|
||||||
|
Width = 16f,
|
||||||
|
Height = 200f,
|
||||||
|
SpriteResolve = id => id == thumbTex ? (thumbTex, 16, 16) : (0u, 0, 0),
|
||||||
|
ThumbSprite = thumbTex,
|
||||||
|
// ThumbTopSprite/ThumbBotSprite stay unset -> the R3-4/R3-7
|
||||||
|
// single-sprite fallback shape (no 3-slice caps authored).
|
||||||
|
Model = model,
|
||||||
|
};
|
||||||
|
|
||||||
|
bar.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
var thumbSegments = renderer.DebugSpriteSegmentVerts
|
||||||
|
.Where(s => s.Texture == thumbTex)
|
||||||
|
.ToArray();
|
||||||
|
Assert.Single(thumbSegments);
|
||||||
|
var verts = thumbSegments[0].Verts;
|
||||||
|
// 8 floats/vertex (x,y,u,v,r,g,b,a), one quad = 6 vertices.
|
||||||
|
Assert.Equal(6, verts.Count / 8);
|
||||||
|
for (int i = 0; i < verts.Count; i += 8)
|
||||||
|
Assert.True(verts[i + 3] <= 1.0001f, $"thumb sprite V={verts[i + 3]} exceeds native (tiled)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue