fix(chargen): Campaign CC gate round 1 Batch B — authored selection states, label state, zoom/swatch feedback
GF-1/GF-8: UiButton now recognizes retail's custom Unselected/Selected radio-pair (0x10000016/0x10000017), bypassing the standard Normal/ Highlight machine that never admitted those state names — .Selected now lights the heritage/template/gender/Face-Clothes rows it was always a no-op for. AP-222/GF-11b: per-state label color/outline (dat 0x1B/0x21) now applies off the REQUESTED retail state id, not the art-gated committed ActiveState — resolves the Appearance spins' current-part highlight (text recolors even though no Highlight art exists on either client) and the Town caption's Normal-to-white swap. GF-11c: UiButton.LabelBox lets a lifted caption with its own authored rect draw there instead of the face-relative offset that's only correct when the label is authored directly on the button (heritage/template family, unchanged). GF-9: wires the real nine companion overlay elements (SetColor's SetVisible mechanism) that swatch clicks were always meant to drive, retiring AP-215 item 1 (the swatch.Selected substitution was a permanent no-op — swatches author no Highlight media at all). GF-10: zoom buttons now set the retail-mirrored mutual-exclusive Highlight/Normal pair on click; InitializePage carries no initial SetState for either button, so both stay at "Normal" until first click. Register: AP-222 retired (mechanism identified and ported), AP-215 narrowed (item 1 retired, item 2 unrelated and unchanged), row count recount corrected 164 (was already one high before this batch). App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Fixture + live- DAT tests only — no graphical client launch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1d9de5e095
commit
7d09821fdc
11 changed files with 1043 additions and 42 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,20 @@
|
||||||
# Campaign CC connected gate — round 1 findings (2026-08-16)
|
# Campaign CC connected gate — round 1 findings (2026-08-16)
|
||||||
|
|
||||||
|
**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 —
|
||||||
|
launcher → character select → Create → six pages → name → Finish → real
|
||||||
|
character created. USER-CONFIRMED: "Yes i could now create a char." The
|
||||||
|
create flow's core path is live; the round continues for visual parity
|
||||||
|
(Batches B-D) and the remaining script checks (rejection dialogs,
|
||||||
|
log-straight-in confirmation, credit/randomize/exit warnings).**
|
||||||
|
|
||||||
|
**Batch B (selection state media + label state) is CODE-COMPLETE
|
||||||
|
2026-08-16, pending the user's visual gate.** GF-1, GF-8, GF-9, GF-10,
|
||||||
|
GF-11b, and GF-11c are fixed — see each entry's own FIXED note below.
|
||||||
|
Fixture + live-DAT tests only this round (no graphical client launch);
|
||||||
|
App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Register:
|
||||||
|
AP-222 RETIRED, AP-215 NARROWED (item 1 retired, item 2 stays open).
|
||||||
|
|
||||||
User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session,
|
User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session,
|
||||||
windowed). Screenshots: retail Heritage, acdream Heritage, retail
|
windowed). Screenshots: retail Heritage, acdream Heritage, retail
|
||||||
Profession. The user's side-by-side retail reports are AXIOMS
|
Profession. The user's side-by-side retail reports are AXIOMS
|
||||||
|
|
@ -9,12 +24,31 @@ ISSUES.md; this doc is the six-page batch.
|
||||||
|
|
||||||
## Functional (blocking or behavior-dead)
|
## Functional (blocking or behavior-dead)
|
||||||
|
|
||||||
- **GF-1 Heritage selection dead/unmarked.** Clicking a heritage row does
|
- **GF-1 Heritage selection dead/unmarked — FIXED (Campaign CC gate round
|
||||||
not light its radio dot (retail: orange lit dot on the selected row —
|
1, Batch B).** Root cause: retail authors a custom radio-selection state
|
||||||
screenshot 1). Unclear whether the click dispatches at all (the
|
pair (`RetailUiStateIds.Unselected`/`Selected`, `0x10000016`/
|
||||||
description text that would confirm is itself broken, GF-2). ALSO: the
|
`0x10000017`) on the heritage row (property-only state descriptors, no
|
||||||
open-roll's own rolled heritage shows NO lit dot on entry — every dot
|
media) with the actual art on a single stateful CHILD (the dot,
|
||||||
dark in the acdream screenshot.
|
`0x100003C0`, media `0x06006E35`/`0x06006E21`, live-DAT-probe-confirmed).
|
||||||
|
`UiButton.AddAvailableStates` only recognized the standard Normal/
|
||||||
|
Highlight/Ghosted name space, so `_availableStates` never admitted the
|
||||||
|
custom pair and `.Selected` committed nothing (probe-verified before the
|
||||||
|
fix: `Selected=true` left `ActiveState=="Unselected"`, while the raw
|
||||||
|
`TrySetRetailState(0x10000017)` already worked). Fixed by teaching
|
||||||
|
`UiButton` to detect the authored pair (`HasStateMedia("Unselected") &&
|
||||||
|
HasStateMedia("Selected")`) at construction and bypass the standard
|
||||||
|
state machine for it — `.Selected` now routes directly to
|
||||||
|
`RetailUiStateIds.Selected`/`Unselected`, additive and gated on the
|
||||||
|
pair's presence, so every OTHER button's Normal/Highlight path is
|
||||||
|
byte-identical. The SAME fix also lights the Profession template icon
|
||||||
|
(`0x100003D9`), the Appearance Face/Clothes sub-tabs (GF-8, below), and
|
||||||
|
the gender buttons (whose media lives directly on the button, not a
|
||||||
|
child — the OTHER shape this fix covers). The open-roll's own
|
||||||
|
no-lit-dot-on-entry symptom shares this same root: `CharacterCreationHeritagePage.Refresh`
|
||||||
|
already sets `button.Selected = heritageId == snapshot.HeritageId` for
|
||||||
|
every row on every refresh (including the first one after open), so the
|
||||||
|
same `.Selected`-was-a-no-op bug silently ate the initial roll's own dot
|
||||||
|
too — this fix closes both halves of GF-1 with the same change.
|
||||||
- **GF-5 Skills page empty — FIXED (Campaign CC gate round
|
- **GF-5 Skills page empty — FIXED (Campaign CC gate round
|
||||||
1, Batch A).** Root cause was `CharacterCreationSkillsPage.RebuildRows`
|
1, Batch A).** Root cause was `CharacterCreationSkillsPage.RebuildRows`
|
||||||
resolving `Templates[0]` (retail's own 3-child bucket-HEADER row,
|
resolving `Templates[0]` (retail's own 3-child bucket-HEADER row,
|
||||||
|
|
@ -33,10 +67,25 @@ ISSUES.md; this doc is the six-page batch.
|
||||||
fully retired — the flat-list-vs-four-bucket half stays). The credits-
|
fully retired — the flat-list-vs-four-bucket half stays). The credits-
|
||||||
caption clobber (`SkillsPage.cs:81-82`, now different line numbers) is
|
caption clobber (`SkillsPage.cs:81-82`, now different line numbers) is
|
||||||
UNCHANGED — Batch C's scope.
|
UNCHANGED — Batch C's scope.
|
||||||
- **GF-9 Appearance color swatches do nothing observable.** Clicking a
|
- **GF-9 Appearance color swatches do nothing observable — FIXED (Campaign
|
||||||
color produces no visible change (model recolor absent). Could be a dead
|
CC gate round 1, Batch B).** Root cause confirmed as working-but-
|
||||||
dispatch or could be working-but-invisible (AP-216 authored-art swatches
|
invisible, not a dead dispatch: the `SelectColor`/`SetAppearanceIndex`
|
||||||
+ a recolor that fails); investigate, don't guess.
|
click path was already intact end to end (unchanged by this fix). The
|
||||||
|
swatch buttons themselves author ONLY an unnamed DirectState sprite —
|
||||||
|
live-DAT-probe-confirmed NO Normal/Highlight media at all — so the
|
||||||
|
existing `swatch.Selected = ...` highlight assignment in
|
||||||
|
`RefreshColorAndShadeControls` was a permanent no-op; nothing could ever
|
||||||
|
have shown a click's effect. Retail's REAL feedback mechanism is nine
|
||||||
|
separate companion overlay elements (`0x10000318`-`0x10000320`,
|
||||||
|
`CharacterCreationAppearancePage.SwatchOverlayIds`, live-DAT-confirmed
|
||||||
|
siblings of the swatches under the color-wheel container `0x100003B9`,
|
||||||
|
index-paired 1:1 with `SwatchIds`) that retail's `SetColor @0x0047DD50`
|
||||||
|
shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` —
|
||||||
|
cross-confirmed against `gmCGAppearancePage::InitializePage`'s own
|
||||||
|
swatch/overlay id-pair table (`@0x004800ff-00480164`). Fixed by wiring
|
||||||
|
exactly one overlay visible per part, tracking the current part's
|
||||||
|
selected color index; retires AP-215's swatch-selection substitution
|
||||||
|
(item 1 — the icon-vs-ordinal item 2 stays open).
|
||||||
- **GF-11a Town description text does not change** when switching towns.
|
- **GF-11a Town description text does not change** when switching towns.
|
||||||
- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED
|
- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED
|
||||||
(Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat
|
(Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat
|
||||||
|
|
@ -113,18 +162,62 @@ ISSUES.md; this doc is the six-page batch.
|
||||||
per-attribute name labels (Strength…Self), Health/Stamina/Mana labels +
|
per-attribute name labels (Strength…Self), Health/Stamina/Mana labels +
|
||||||
values. Sliders and template selection themselves WORK.
|
values. Sliders and template selection themselves WORK.
|
||||||
- **GF-6 Appearance spin captions are numbers,** not part names
|
- **GF-6 Appearance spin captions are numbers,** not part names
|
||||||
("Hair Style", "Eyes", …). Known rows AP-215/AP-218 — the gate promotes
|
("Hair Style", "Eyes", …). Known rows AP-215 (item 2 — item 1, the
|
||||||
them to must-port.
|
swatch-selection substitution, RETIRED at Batch B/GF-9)/AP-218 — the
|
||||||
|
gate promotes them to must-port.
|
||||||
- **GF-7 Preview backdrop black** on Appearance (and Summary, GF-14);
|
- **GF-7 Preview backdrop black** on Appearance (and Summary, GF-14);
|
||||||
retail's chargen 3D view shows a scenic backdrop. (The Heritage-page
|
retail's chargen 3D view shows a scenic backdrop. (The Heritage-page
|
||||||
preview area shows terrain in BOTH clients — establish from the decomp
|
preview area shows terrain in BOTH clients — establish from the decomp
|
||||||
what actually renders behind the model per page/view.)
|
what actually renders behind the model per page/view.)
|
||||||
- **GF-8 Appearance Face/Clothes sub-tab selection unmarked** (AP-222
|
- **GF-8 Appearance Face/Clothes sub-tab selection unmarked — FIXED
|
||||||
family, promoted by the gate).
|
(Campaign CC gate round 1, Batch B).** Same root and same fix as GF-1:
|
||||||
- **GF-10 Zoom buttons show identical art** whichever is pushed.
|
the Face (`0x100003A9`)/Clothes (`0x100003AA`) sub-tab buttons author
|
||||||
- **GF-11b Town selected marker does not turn white** (button highlights,
|
the identical custom Unselected/Selected radio-pair shape (media on a
|
||||||
but retail's selected-town graphic swaps to white).
|
stateful icon child, `0x100002E9`, live-DAT-probe-confirmed) — not the
|
||||||
- **GF-11c Town names misaligned on the map** vs retail.
|
AP-222 family as originally suspected (AP-222 turned out to be a
|
||||||
|
DIFFERENT mechanism, the per-state label color/outline gap fixed
|
||||||
|
alongside GF-11b below). `UiButton`'s custom-selection-pair bypass
|
||||||
|
fixes both in one change.
|
||||||
|
- **GF-10 Zoom buttons show identical art — FIXED (Campaign CC gate round
|
||||||
|
1, Batch B).** Pure wiring gap, not a widget mechanism problem — both
|
||||||
|
zoom buttons already author a standard Normal/Highlight(/rollover) pair
|
||||||
|
(live-DAT-probe-confirmed). `gmCGAppearancePage::ZoomIn @0x0047CF00`
|
||||||
|
(`@0x0047d005/0x0047d00f`) ends `ZoomInButton->SetState(6)` (Highlight),
|
||||||
|
`ZoomOutButton->SetState(1)` (Normal); `ZoomOut @0x0047D050` mirrors.
|
||||||
|
`CharacterCreationAppearancePage`'s click handlers only ever called
|
||||||
|
`PreviewControl.ZoomIn()/ZoomOut()`, never touching either button's
|
||||||
|
state — fixed to set the mutual-exclusive pair on every click. Re-
|
||||||
|
derived the INITIAL state from `InitializePage @0x0047fdd0-0048032e`:
|
||||||
|
`m_bZoomedIn = 0` is set at construction, but NO explicit initial
|
||||||
|
`SetState` call exists for either zoom button anywhere in
|
||||||
|
`InitializePage` — both start at their DAT-authored "Normal" default
|
||||||
|
until the first real zoom click; this port does not force an initial
|
||||||
|
Highlight either.
|
||||||
|
- **GF-11b Town selected marker does not turn white — FIXED (Campaign CC
|
||||||
|
gate round 1, Batch B).** The marker PIN art itself already swapped
|
||||||
|
correctly (the town button's own Normal/Highlight state machine was
|
||||||
|
never broken — its marker child, `0x1000040C`, authors real Highlight
|
||||||
|
media). What was missing: retail ALSO recolors the town NAME caption
|
||||||
|
(a lifted Type-12 child, id collides with the page-level description
|
||||||
|
panel's own id `0x10000409` in the installed dat — two distinct
|
||||||
|
elements in two distinct subtrees, harmless for the per-button lift)
|
||||||
|
from gold (218,167,85) to white (255,255,255) on selection, live-DAT-
|
||||||
|
measured. `DatWidgetFactory.BuildButton` lifted the caption's font
|
||||||
|
COLOR once at build time with no per-state override. Same root and fix
|
||||||
|
as AP-222 (below): per-state label color/outline, applied off the
|
||||||
|
REQUESTED retail state id.
|
||||||
|
- **GF-11c Town names misaligned on the map — FIXED (Campaign CC gate
|
||||||
|
round 1, Batch B).** The per-button caption's own authored rect
|
||||||
|
(`(0,4,100,37)`, Center-justified, live-DAT-measured) was being
|
||||||
|
discarded in favor of a Left-aligned offset computed from the marker
|
||||||
|
FACE's rect (`face.X + face.Width + 4`) — correct for the heritage/
|
||||||
|
template/Face-Clothes row family (label authored DIRECTLY on the
|
||||||
|
button, beside a single-purpose face segment) but wrong here, where a
|
||||||
|
DISTINCT Type-12 caption child was lifted with its own independent
|
||||||
|
geometry. Fixed by adding `UiButton.LabelBox`: when a distinct lifted
|
||||||
|
caption carries its own rect, the label draws within THAT box using
|
||||||
|
its own authored justify instead of the face-relative offset; every
|
||||||
|
other button (`LabelBox` null) keeps the EXACT prior draw math.
|
||||||
- **GF-12 Missing authored gold frames** around boxes on every page
|
- **GF-12 Missing authored gold frames** around boxes on every page
|
||||||
(Skills/Appearance/Town/Summary called out explicitly).
|
(Skills/Appearance/Town/Summary called out explicitly).
|
||||||
- **GF-14 Summary paperdoll backdrop black** (same family as GF-7);
|
- **GF-14 Summary paperdoll backdrop black** (same family as GF-7);
|
||||||
|
|
@ -138,9 +231,19 @@ ISSUES.md; this doc is the six-page batch.
|
||||||
decide per element from the authored DAT + decomp.
|
decide per element from the authored DAT + decomp.
|
||||||
2. Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap
|
2. Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap
|
||||||
feeding GF-2/GF-3/GF-11a/GF-14.
|
feeding GF-2/GF-3/GF-11a/GF-14.
|
||||||
3. Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker,
|
3. ~~Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker,
|
||||||
GF-10 zoom art) — the AP-222 measured mechanism (state media authored
|
GF-10 zoom art) — the AP-222 measured mechanism (state media authored
|
||||||
vs applied) across widget kinds.
|
vs applied) across widget kinds.~~ CLOSED, split into TWO distinct
|
||||||
|
mechanisms, both fixed at Batch B: (a) GF-1/GF-8 share a genuinely
|
||||||
|
UNRECOGNIZED custom state-name pair (`UiButton` never admitted
|
||||||
|
"Unselected"/"Selected" into its available-states set at all); GF-10
|
||||||
|
was pure wiring (the standard Normal/Highlight pair was never even
|
||||||
|
requested). (b) GF-11b turned out NOT to be a state-media gap — the
|
||||||
|
marker's own media swap already worked; the actual gap was AP-222's
|
||||||
|
real mechanism, per-state LABEL COLOR/OUTLINE (a property commit
|
||||||
|
distinct from the art/media commit, and NOT gated by the same art-
|
||||||
|
availability check `ActiveState` is). See each GF's own FIXED entry
|
||||||
|
above and the retired AP-222 / narrowed AP-215 register rows.
|
||||||
4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws.
|
4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws.
|
||||||
5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked
|
5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked
|
||||||
chargen screen.~~ CLOSED: focus/typing routing was never broken (live-
|
chargen screen.~~ CLOSED: focus/typing routing was never broken (live-
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,22 @@ public static class RetailUiStateIds
|
||||||
public const uint LockedUi = 0x10000063u;
|
public const uint LockedUi = 0x10000063u;
|
||||||
public const uint UnlockedUi = 0x10000064u;
|
public const uint UnlockedUi = 0x10000064u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom
|
||||||
|
/// radio-selection state pair, live-DAT-probe-confirmed on the Heritage
|
||||||
|
/// row (<c>0x100003BF</c>), Profession template (<c>0x100003D9</c>),
|
||||||
|
/// Appearance Face/Clothes sub-tabs (<c>0x100003A9</c>/<c>0x100003AA</c>),
|
||||||
|
/// and gender buttons (<c>0x100003A7</c>/<c>0x100003A8</c>). Named
|
||||||
|
/// <c>UiStateInfo.Name</c> strings, not media file ids — the buttons
|
||||||
|
/// author their state DESCRIPTORS under these two ids, with the actual
|
||||||
|
/// per-state art living either directly on the button (gender) or on a
|
||||||
|
/// single stateful face-segment child (heritage/template/sub-tabs).
|
||||||
|
/// See <see cref="AcDream.App.UI.UiButton"/>'s custom-selection-pair
|
||||||
|
/// bypass in <c>UpdateVisualState</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const uint Unselected = 0x10000016u;
|
||||||
|
public const uint Selected = 0x10000017u;
|
||||||
|
|
||||||
public static string StateName(uint stateId)
|
public static string StateName(uint stateId)
|
||||||
=> stateId switch
|
=> stateId switch
|
||||||
{
|
{
|
||||||
|
|
@ -38,6 +54,8 @@ public static class RetailUiStateIds
|
||||||
Minimized => "Minimized",
|
Minimized => "Minimized",
|
||||||
LockedUi => "LockedUI",
|
LockedUi => "LockedUI",
|
||||||
UnlockedUi => "UnlockedUI",
|
UnlockedUi => "UnlockedUI",
|
||||||
|
Unselected => "Unselected",
|
||||||
|
Selected => "Selected",
|
||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -56,6 +74,8 @@ public static class RetailUiStateIds
|
||||||
"Minimized" => Minimized,
|
"Minimized" => Minimized,
|
||||||
"LockedUI" => LockedUi,
|
"LockedUI" => LockedUi,
|
||||||
"UnlockedUI" => UnlockedUi,
|
"UnlockedUI" => UnlockedUi,
|
||||||
|
"Unselected" => Unselected,
|
||||||
|
"Selected" => Selected,
|
||||||
_ => 0u,
|
_ => 0u,
|
||||||
};
|
};
|
||||||
return stateId != 0;
|
return stateId != 0;
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,31 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u,
|
0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-9 (Campaign CC gate round 1 Batch B): the nine Type-3 companion
|
||||||
|
/// "selected" overlay elements, one per <see cref="SwatchIds"/> entry at
|
||||||
|
/// the SAME index — retail <c>gmCGAppearancePage::InitializePage</c>'s
|
||||||
|
/// own id-pair table (<c>@0x004800ff-00480164</c>, the switch that fills
|
||||||
|
/// <c>m_tColorWheel[i][0x10]</c>/<c>[0x14]</c>-ish offsets with the
|
||||||
|
/// swatch/overlay id pair per index) resolves the SAME nine ids this
|
||||||
|
/// array carries, in the SAME index order. <c>SetColor</c> (case
|
||||||
|
/// <c>0x0047DD50</c>, <c>iCurColor</c> assignment) is retail's actual
|
||||||
|
/// click-feedback mechanism — <c>SetVisible</c> on the overlay at
|
||||||
|
/// <c>iCurColor</c>'s index, NOT a state swap on the swatch itself (the
|
||||||
|
/// swatch buttons author only an unnamed DirectState sprite; measured
|
||||||
|
/// against the installed dat, they have NO Normal/Highlight media at
|
||||||
|
/// all, so <see cref="UiButton.Selected"/> was always a complete no-op
|
||||||
|
/// here — see <see cref="RefreshColorAndShadeControls"/>). Live-DAT-
|
||||||
|
/// probe-confirmed siblings of the swatches under the same color-wheel
|
||||||
|
/// container (<c>0x100003B9</c>), each roughly centered on its paired
|
||||||
|
/// swatch's own rect.
|
||||||
|
/// </summary>
|
||||||
|
internal static readonly uint[] SwatchOverlayIds =
|
||||||
|
[
|
||||||
|
0x10000318u, 0x10000319u, 0x1000031Au, 0x1000031Bu, 0x1000031Cu,
|
||||||
|
0x1000031Du, 0x1000031Eu, 0x1000031Fu, 0x10000320u,
|
||||||
|
];
|
||||||
|
|
||||||
/// <summary>Live-DAT-measured arrow geometry, uniform across all nine
|
/// <summary>Live-DAT-measured arrow geometry, uniform across all nine
|
||||||
/// spins (every one is 200px wide): decrement child at local
|
/// spins (every one is 200px wide): decrement child at local
|
||||||
/// x=[80,127), increment child at x=[127,174). Anything outside both
|
/// x=[80,127), increment child at x=[127,174). Anything outside both
|
||||||
|
|
@ -144,6 +169,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
private readonly UiElement? _clothesChoices;
|
private readonly UiElement? _clothesChoices;
|
||||||
private readonly Dictionary<Part, UiButton> _spins = [];
|
private readonly Dictionary<Part, UiButton> _spins = [];
|
||||||
private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length];
|
private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length];
|
||||||
|
private readonly UiElement?[] _swatchOverlays = new UiElement?[SwatchOverlayIds.Length];
|
||||||
private readonly UiScrollbar? _shadeScroll;
|
private readonly UiScrollbar? _shadeScroll;
|
||||||
private readonly UiButton? _rotateClockwise;
|
private readonly UiButton? _rotateClockwise;
|
||||||
private readonly UiButton? _rotateCounterClockwise;
|
private readonly UiButton? _rotateCounterClockwise;
|
||||||
|
|
@ -209,6 +235,9 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
_swatches[i] = swatch;
|
_swatches[i] = swatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < SwatchOverlayIds.Length; i++)
|
||||||
|
_swatchOverlays[i] = Find<UiElement>(pageRoot, SwatchOverlayIds[i]);
|
||||||
|
|
||||||
_shadeScroll = Find<UiScrollbar>(pageRoot, ShadeScrollId);
|
_shadeScroll = Find<UiScrollbar>(pageRoot, ShadeScrollId);
|
||||||
if (_shadeScroll is not null)
|
if (_shadeScroll is not null)
|
||||||
_shadeScroll.ScalarChanged = SetShadeFromScalar;
|
_shadeScroll.ScalarChanged = SetShadeFromScalar;
|
||||||
|
|
@ -223,10 +252,33 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
_rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise();
|
_rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise();
|
||||||
_zoomIn = Find<UiButton>(pageRoot, ZoomInId);
|
_zoomIn = Find<UiButton>(pageRoot, ZoomInId);
|
||||||
if (_zoomIn is not null)
|
if (_zoomIn is not null)
|
||||||
_zoomIn.OnClick = () => PreviewControl?.ZoomIn();
|
_zoomIn.OnClick = () =>
|
||||||
|
{
|
||||||
|
PreviewControl?.ZoomIn();
|
||||||
|
// GF-10: gmCGAppearancePage::ZoomIn @0x0047CF00
|
||||||
|
// (@0x0047d005/0x0047d00f) ends ZoomInButton->SetState(6)
|
||||||
|
// (Highlight), ZoomOutButton->SetState(1) (Normal) — a
|
||||||
|
// mutual-exclusive pair. Re-derived from InitializePage
|
||||||
|
// @0x0047fdd0-0048032e (m_bZoomedIn = 0 at construction,
|
||||||
|
// @0x004802c3): NO explicit initial SetState call exists
|
||||||
|
// for either button, so both start at their DAT-authored
|
||||||
|
// "Normal" default (live-DAT-probe-confirmed) until the
|
||||||
|
// first real zoom click — this port does not force an
|
||||||
|
// initial Highlight.
|
||||||
|
_zoomIn.TrySetRetailState(UiButtonStateMachine.Highlight);
|
||||||
|
_zoomOut?.TrySetRetailState(UiButtonStateMachine.Normal);
|
||||||
|
};
|
||||||
_zoomOut = Find<UiButton>(pageRoot, ZoomOutId);
|
_zoomOut = Find<UiButton>(pageRoot, ZoomOutId);
|
||||||
if (_zoomOut is not null)
|
if (_zoomOut is not null)
|
||||||
_zoomOut.OnClick = () => PreviewControl?.ZoomOut();
|
_zoomOut.OnClick = () =>
|
||||||
|
{
|
||||||
|
PreviewControl?.ZoomOut();
|
||||||
|
// GF-10: gmCGAppearancePage::ZoomOut @0x0047D050
|
||||||
|
// (@0x0047d140/0x0047d14a) mirrors ZoomIn — ZoomOutButton
|
||||||
|
// -> Highlight(6), ZoomInButton -> Normal(1).
|
||||||
|
_zoomOut.TrySetRetailState(UiButtonStateMachine.Highlight);
|
||||||
|
_zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal);
|
||||||
|
};
|
||||||
|
|
||||||
ApplyChoiceVisibility();
|
ApplyChoiceVisibility();
|
||||||
}
|
}
|
||||||
|
|
@ -608,12 +660,21 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
: UiButtonStateMachine.Normal);
|
: UiButtonStateMachine.Normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch
|
||||||
|
// click feedback is the companion overlay's visibility (SetColor
|
||||||
|
// @0x0047DD50 -> m_tColorWheel[...][0x10][iCurColor*7]->SetVisible),
|
||||||
|
// not a state swap on the swatch button — measured against the
|
||||||
|
// installed dat, the nine swatches author only a DirectState sprite
|
||||||
|
// with no Normal/Highlight media at all, so a prior
|
||||||
|
// swatch.Selected assignment here was a permanent no-op (see
|
||||||
|
// SwatchOverlayIds' own doc comment). Exactly one overlay is
|
||||||
|
// visible: the one at the current part's own selected color index.
|
||||||
ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart);
|
ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart);
|
||||||
uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance);
|
uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance);
|
||||||
for (int i = 0; i < _swatches.Length; i++)
|
for (int i = 0; i < _swatchOverlays.Length; i++)
|
||||||
{
|
{
|
||||||
if (_swatches[i] is { } swatch)
|
if (_swatchOverlays[i] is { } overlay)
|
||||||
swatch.Selected = colorSlot is not null && currentColor == (uint)i;
|
overlay.Visible = colorSlot is not null && currentColor == (uint)i;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
|
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
|
||||||
|
|
|
||||||
|
|
@ -878,8 +878,31 @@ public static class DatWidgetFactory
|
||||||
button.FaceTop = face.Y;
|
button.FaceTop = face.Y;
|
||||||
button.FaceWidth = face.Width;
|
button.FaceWidth = face.Width;
|
||||||
button.FaceHeight = face.Height;
|
button.FaceHeight = face.Height;
|
||||||
button.LabelAlign = UiButton.LabelAlignment.Left;
|
|
||||||
button.LabelOffsetX = face.X + face.Width + 4f;
|
if (!ReferenceEquals(labelInfo, info))
|
||||||
|
{
|
||||||
|
// GF-11c (Campaign CC gate round 1 Batch B): a DISTINCT
|
||||||
|
// Type-12 caption was lifted (e.g. the Town page's per-
|
||||||
|
// marker name label, 0x10000409 under each town button —
|
||||||
|
// live-DAT-probe-confirmed authored rect + Center justify,
|
||||||
|
// independent of the marker face's own geometry) — honor
|
||||||
|
// ITS OWN authored rect/justify instead of the face-
|
||||||
|
// relative offset below, which is only correct when the
|
||||||
|
// label text is authored DIRECTLY on the button itself,
|
||||||
|
// immediately beside a single-purpose face segment (the
|
||||||
|
// heritage/template/Face-Clothes sub-tab row family —
|
||||||
|
// still handled by the else-branch two lines down, since
|
||||||
|
// ReferenceEquals(labelInfo, info) is true there).
|
||||||
|
button.LabelBox = (labelInfo.X, labelInfo.Y, labelInfo.Width, labelInfo.Height);
|
||||||
|
button.LabelAlign = labelInfo.HJustify == HJustify.Left
|
||||||
|
? UiButton.LabelAlignment.Left
|
||||||
|
: UiButton.LabelAlignment.Center;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
button.LabelAlign = UiButton.LabelAlignment.Left;
|
||||||
|
button.LabelOffsetX = face.X + face.Width + 4f;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (labelInfo.HJustify == HJustify.Left)
|
else if (labelInfo.HJustify == HJustify.Left)
|
||||||
{
|
{
|
||||||
|
|
@ -901,6 +924,15 @@ public static class DatWidgetFactory
|
||||||
button.LabelOffsetX = labelInfo.X;
|
button.LabelOffsetX = labelInfo.X;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label
|
||||||
|
// color/outline (dat properties 0x1B/0x21 authored PER STATE on the
|
||||||
|
// label-bearing element — the Appearance spins' own states, or the
|
||||||
|
// Town caption child's states) — additive, only non-null when the
|
||||||
|
// authored dat genuinely carries more than one distinct value.
|
||||||
|
button.SetPerStateLabelStyle(
|
||||||
|
ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu),
|
||||||
|
ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u));
|
||||||
|
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
|
|
||||||
|
|
@ -673,4 +674,65 @@ public static class ElementReader
|
||||||
})
|
})
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): resolves a color
|
||||||
|
/// property (0x1B FontColor's Array-tolerant shape, same unwrap as
|
||||||
|
/// <see cref="ReadEffectiveColorPalette"/>) for EVERY state <paramref
|
||||||
|
/// name="info"/> itself authors, keyed by retail numeric state id.
|
||||||
|
/// Returns null unless at least two states resolve to GENUINELY
|
||||||
|
/// DIFFERENT colors — the overwhelming majority of elements author one
|
||||||
|
/// color for every state (or none at all), and for those this returns
|
||||||
|
/// null so the caller keeps its existing single-default-color behavior
|
||||||
|
/// untouched. Only elements that really do recolor per state (the
|
||||||
|
/// Appearance spins' Highlight brightening, the Town buttons' Normal-
|
||||||
|
/// to-white caption swap) get a non-null map.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyDictionary<uint, Vector4>? BuildPerStateColorMap(
|
||||||
|
ElementInfo info, uint propertyId)
|
||||||
|
{
|
||||||
|
Dictionary<uint, Vector4>? map = null;
|
||||||
|
foreach (uint stateId in info.States.Keys)
|
||||||
|
{
|
||||||
|
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
UiPropertyValue? colorValue = value.Kind == UiPropertyKind.Color
|
||||||
|
? value
|
||||||
|
: value.Kind == UiPropertyKind.Array
|
||||||
|
&& value.ArrayValue.Count > 0
|
||||||
|
&& value.ArrayValue[0].Kind == UiPropertyKind.Color
|
||||||
|
? value.ArrayValue[0]
|
||||||
|
: null;
|
||||||
|
if (colorValue is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
UiColorValue c = colorValue.ColorValue;
|
||||||
|
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
|
||||||
|
(map ??= new Dictionary<uint, Vector4>())[stateId] =
|
||||||
|
new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AP-222 counterpart of <see cref="BuildPerStateColorMap"/> for a bool
|
||||||
|
/// property (0x21 Outline) — same "null unless genuinely per-state"
|
||||||
|
/// gating.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyDictionary<uint, bool>? BuildPerStateBoolMap(
|
||||||
|
ElementInfo info, uint propertyId)
|
||||||
|
{
|
||||||
|
Dictionary<uint, bool>? map = null;
|
||||||
|
foreach (uint stateId in info.States.Keys)
|
||||||
|
{
|
||||||
|
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId)
|
||||||
|
|| value.Kind != UiPropertyKind.Bool)
|
||||||
|
continue;
|
||||||
|
(map ??= new Dictionary<uint, bool>())[stateId] = value.BoolValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
private readonly FaceSegment[] _faceSegments;
|
private readonly FaceSegment[] _faceSegments;
|
||||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||||
private readonly HashSet<uint> _availableStates = new();
|
private readonly HashSet<uint> _availableStates = new();
|
||||||
|
private readonly bool _hasCustomSelectionPair;
|
||||||
|
private IReadOnlyDictionary<uint, Vector4>? _stateLabelColors;
|
||||||
|
private IReadOnlyDictionary<uint, bool>? _stateLabelOutlines;
|
||||||
private bool _pressed;
|
private bool _pressed;
|
||||||
private bool _pointerOver;
|
private bool _pointerOver;
|
||||||
private bool _selected;
|
private bool _selected;
|
||||||
|
|
@ -157,6 +160,25 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
|
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
|
||||||
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-11c (Campaign CC gate round 1 Batch B): optional authored label
|
||||||
|
/// rectangle, LOCAL to this button. When a caption is LIFTED from a
|
||||||
|
/// DISTINCT Type-12 child that carries its own independent rect (e.g.
|
||||||
|
/// the Town page's per-marker name label, positioned below/beside its
|
||||||
|
/// marker rather than immediately right of it), <see cref="OnDraw"/>
|
||||||
|
/// draws the label within THIS box using its own authored geometry
|
||||||
|
/// instead of the FaceLeft-derived offset / full-button-width centering
|
||||||
|
/// the ordinary case uses (label authored directly on the button, right
|
||||||
|
/// beside a single-purpose face segment — the heritage/template/Face-
|
||||||
|
/// Clothes row family, where the current face-relative math is already
|
||||||
|
/// correct). Null (default, every pre-existing button) preserves the
|
||||||
|
/// EXACT prior draw math — <see cref="LabelAlignment.Left"/> still adds
|
||||||
|
/// <see cref="LabelOffsetX"/> to the button's own local origin, and
|
||||||
|
/// <see cref="LabelAlignment.Center"/> still centers within the whole
|
||||||
|
/// button width/height.
|
||||||
|
/// </summary>
|
||||||
|
public (float X, float Y, float Width, float Height)? LabelBox { get; set; }
|
||||||
|
|
||||||
/// <summary>Label horizontal alignment options.</summary>
|
/// <summary>Label horizontal alignment options.</summary>
|
||||||
public enum LabelAlignment { Center, Left }
|
public enum LabelAlignment { Center, Left }
|
||||||
|
|
||||||
|
|
@ -330,6 +352,21 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
foreach (FaceSegment segment in _faceSegments)
|
foreach (FaceSegment segment in _faceSegments)
|
||||||
AddAvailableStates(segment.Info);
|
AddAvailableStates(segment.Info);
|
||||||
|
|
||||||
|
// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom
|
||||||
|
// "Unselected"/"Selected" radio-selection state pair
|
||||||
|
// (RetailUiStateIds.Unselected/Selected, 0x10000016/0x10000017) is
|
||||||
|
// authored as STATE DESCRIPTORS whose names UiButtonStateMachine's
|
||||||
|
// Normal/Highlight machine doesn't recognize — the standard
|
||||||
|
// AddAvailableStates loop above never admits them, so the ordinary
|
||||||
|
// RequestedState()-driven UpdateVisualState can never select them
|
||||||
|
// (measured: Selected=true committed nothing against the installed
|
||||||
|
// dat before this fix). HasStateMedia already checks the same media
|
||||||
|
// presence (face-segment child OR the button's own StateMedia) used
|
||||||
|
// everywhere else in this class, so this reuses that exact
|
||||||
|
// detection rather than adding a new one.
|
||||||
|
_hasCustomSelectionPair = HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Unselected))
|
||||||
|
&& HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Selected));
|
||||||
|
|
||||||
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
|
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
|
||||||
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
|
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
|
||||||
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
|
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
|
||||||
|
|
@ -402,10 +439,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
|
|
||||||
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||||
{
|
{
|
||||||
|
// GF-11c: LabelBox null (every pre-existing button) reduces boxX/
|
||||||
|
// boxY to 0 and boxWidth/boxHeight to the button's own Width/
|
||||||
|
// Height — byte-identical to the prior unconditional math.
|
||||||
|
float boxX = LabelBox?.X ?? 0f;
|
||||||
|
float boxY = LabelBox?.Y ?? 0f;
|
||||||
|
float boxWidth = LabelBox?.Width ?? Width;
|
||||||
|
float boxHeight = LabelBox?.Height ?? Height;
|
||||||
float tx = LabelAlign == LabelAlignment.Left
|
float tx = LabelAlign == LabelAlignment.Left
|
||||||
? LabelOffsetX
|
? boxX + LabelOffsetX
|
||||||
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
|
: boxX + (boxWidth - lf.MeasureWidth(label)) * 0.5f; // centered (default)
|
||||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
float ty = boxY + (boxHeight - lf.LineHeight) * 0.5f;
|
||||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
|
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -638,13 +682,77 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
|
|
||||||
private void UpdateVisualState()
|
private void UpdateVisualState()
|
||||||
{
|
{
|
||||||
uint requested = UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
|
uint requested = ComputeRequestedStateId();
|
||||||
Disabled: !Enabled,
|
if (_hasCustomSelectionPair)
|
||||||
Selected: _selected,
|
{
|
||||||
RolloverEnabled: RolloverEnabled,
|
// gmCGHeritagePage::Update @0x00483219-0x0048372D (and the
|
||||||
Pressed: _pressed,
|
// mirrored template/sub-tab/gender call sites): retail sets
|
||||||
PointerOver: _pointerOver));
|
// this pair directly by SELECTION, not through the ordinary
|
||||||
if (_availableStates.Contains(requested))
|
// Normal/Highlight/rollover/pressed machine — these buttons
|
||||||
|
// never author rollover or pressed media for the pair, so
|
||||||
|
// there is nothing faithful to compute beyond selected-or-not.
|
||||||
|
ActiveState = RetailUiStateIds.StateName(requested);
|
||||||
|
}
|
||||||
|
else if (_availableStates.Contains(requested))
|
||||||
|
{
|
||||||
ActiveState = UiButtonStateMachine.StateName(requested);
|
ActiveState = UiButtonStateMachine.StateName(requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AP-222: apply the per-state label style off the REQUESTED id, not
|
||||||
|
// the (possibly art-gated) committed ActiveState — retail's own
|
||||||
|
// SetState(6) commits the state's PROPERTIES (including text color)
|
||||||
|
// unconditionally; only the SPRITE draw silently no-ops when a
|
||||||
|
// state has no media (this class's own #382 comment on
|
||||||
|
// TrySetRetailState documents the same distinction). The
|
||||||
|
// Appearance spins' current-part highlight is exactly this case:
|
||||||
|
// _availableStates never contains Highlight (their arrow face
|
||||||
|
// segments carry no Highlight art), so ActiveState stays "Normal"
|
||||||
|
// forever, but the spin's OWN label color must still swap.
|
||||||
|
ApplyPerStateLabelStyle(requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint ComputeRequestedStateId()
|
||||||
|
=> _hasCustomSelectionPair
|
||||||
|
? (_selected ? RetailUiStateIds.Selected : RetailUiStateIds.Unselected)
|
||||||
|
: UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
|
||||||
|
Disabled: !Enabled,
|
||||||
|
Selected: _selected,
|
||||||
|
RolloverEnabled: RolloverEnabled,
|
||||||
|
Pressed: _pressed,
|
||||||
|
PointerOver: _pointerOver));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): optional per-
|
||||||
|
/// RETAIL-STATE label color/outline override, additive over the single
|
||||||
|
/// default <see cref="LabelColor"/>/<see cref="Outline"/> lifted once at
|
||||||
|
/// construction. Set by <see cref="Layout.DatWidgetFactory"/> ONLY when
|
||||||
|
/// the authored dat genuinely carries more than one distinct value
|
||||||
|
/// across this button's (or its lifted caption child's) own states —
|
||||||
|
/// e.g. the Appearance spins' Highlight-state gold brightening
|
||||||
|
/// (dat properties <c>0x1B</c>/<c>0x21</c>, live-DAT-measured
|
||||||
|
/// 218,167,85 -> 255,221,131 plus outline off -> on) or the Town
|
||||||
|
/// buttons' Normal-to-white caption swap (218,167,85 -> 255,255,255).
|
||||||
|
/// A button with a single authored color (the overwhelming majority)
|
||||||
|
/// never calls this, so <see cref="LabelColor"/>/<see cref="Outline"/>
|
||||||
|
/// keep behaving exactly as before — including every existing external
|
||||||
|
/// post-construction assignment (e.g. <c>ChatWindowController</c>'s Send
|
||||||
|
/// caption, <c>PaperdollController</c>'s Slots label), none of which
|
||||||
|
/// author a second distinct per-state color.
|
||||||
|
/// </summary>
|
||||||
|
internal void SetPerStateLabelStyle(
|
||||||
|
IReadOnlyDictionary<uint, Vector4>? colors,
|
||||||
|
IReadOnlyDictionary<uint, bool>? outlines)
|
||||||
|
{
|
||||||
|
_stateLabelColors = colors;
|
||||||
|
_stateLabelOutlines = outlines;
|
||||||
|
ApplyPerStateLabelStyle(ComputeRequestedStateId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPerStateLabelStyle(uint requestedStateId)
|
||||||
|
{
|
||||||
|
if (_stateLabelColors is { } colors && colors.TryGetValue(requestedStateId, out Vector4 color))
|
||||||
|
LabelColor = color;
|
||||||
|
if (_stateLabelOutlines is { } outlines && outlines.TryGetValue(requestedStateId, out bool outline))
|
||||||
|
Outline = outline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
|
|
@ -278,6 +279,239 @@ public sealed class CharacterCreationLiveDatTests
|
||||||
UiElement.FindDescendant(townRoot, 0x10000409u));
|
UiElement.FindDescendant(townRoot, 0x10000409u));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-11b/GF-11c (Campaign CC gate round 1 Batch B). Live-DAT-measured:
|
||||||
|
/// each town button's marker (<c>0x1000040D</c>'s own child
|
||||||
|
/// <c>0x1000040C</c>) and its per-button name caption both carry the
|
||||||
|
/// SAME numeric id <c>0x10000409</c> as the page-level description
|
||||||
|
/// panel found by the sibling test above — a genuine id collision in
|
||||||
|
/// the installed dat between two DIFFERENT elements in DIFFERENT
|
||||||
|
/// subtrees (harmless for <c>DatWidgetFactory.BuildButton</c>'s lift,
|
||||||
|
/// which walks the button's OWN <c>ElementInfo.Children</c> list rather
|
||||||
|
/// than resolving by a global id lookup — but it means this test
|
||||||
|
/// verifies the BUILT BUTTON's own <see cref="UiButton.Label"/>/
|
||||||
|
/// <see cref="UiButton.LabelBox"/>/<see cref="UiButton.LabelColor"/>,
|
||||||
|
/// not a second <c>FindDescendant</c> call, which would ambiguously
|
||||||
|
/// return the unrelated page-level panel). Pins: the caption's own
|
||||||
|
/// authored rect (0,4,100,37) survives instead of being overwritten by
|
||||||
|
/// the marker-face-relative offset (GF-11c), and its color swaps
|
||||||
|
/// Normal (218,167,85) -> Highlight/white (255,255,255) on selection
|
||||||
|
/// (GF-11b).
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void TownPage_HoltburgButton_CaptionHonorsOwnRectAndRecolorsWhiteOnSelection()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ImportedLayout screen = BuildSelected(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||||
|
|
||||||
|
UiElement townRoot = Assert.IsAssignableFrom<UiElement>(
|
||||||
|
screen.FindElement(CharacterCreationUiController.TownPageElementId));
|
||||||
|
UiButton holtburg = AssertButton(townRoot, 0x1000040Du);
|
||||||
|
|
||||||
|
Assert.Equal("Holtburg", holtburg.Label);
|
||||||
|
Assert.Equal(UiButton.LabelAlignment.Center, holtburg.LabelAlign);
|
||||||
|
Assert.Equal((0f, 4f, 100f, 37f), holtburg.LabelBox);
|
||||||
|
|
||||||
|
holtburg.Selected = false;
|
||||||
|
Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), holtburg.LabelColor);
|
||||||
|
|
||||||
|
holtburg.Selected = true;
|
||||||
|
Assert.Equal(new Vector4(1f, 1f, 1f, 1f), holtburg.LabelColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-1 (Campaign CC gate round 1 Batch B). Live-DAT-measured: the
|
||||||
|
/// Heritage row (<c>0x100003BFu</c>, Aluvian) authors retail's custom
|
||||||
|
/// "Unselected"/"Selected" radio-pair state DESCRIPTORS directly on the
|
||||||
|
/// row (property-only, no media), with the actual per-state art on its
|
||||||
|
/// single stateful dot child (<c>0x100003C0</c>). Before this fix,
|
||||||
|
/// <see cref="UiButton.Selected"/> committed nothing here.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void HeritagePage_Row_SelectedTogglesTheAuthoredRadioDotState()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ImportedLayout screen = BuildSelected(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||||
|
|
||||||
|
UiElement heritageRoot = Assert.IsAssignableFrom<UiElement>(
|
||||||
|
screen.FindElement(CharacterCreationUiController.HeritagePageElementId));
|
||||||
|
UiButton aluvian = AssertButton(heritageRoot, 0x100003BFu);
|
||||||
|
|
||||||
|
Assert.Equal("Unselected", aluvian.ActiveState);
|
||||||
|
aluvian.Selected = true;
|
||||||
|
Assert.Equal("Selected", aluvian.ActiveState);
|
||||||
|
Assert.Equal(RetailUiStateIds.Selected, aluvian.ActiveRetailStateId);
|
||||||
|
aluvian.Selected = false;
|
||||||
|
Assert.Equal("Unselected", aluvian.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-1 counterpart: the Profession template row (<c>0x100003D9u</c>,
|
||||||
|
/// Custom/Adventurer) authors the identical custom radio-pair shape on
|
||||||
|
/// its own icon child (<c>0x100002E9</c>).
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void ProfessionPage_TemplateButton_SelectedTogglesTheAuthoredIconState()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ImportedLayout screen = BuildSelected(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||||
|
|
||||||
|
UiElement professionRoot = Assert.IsAssignableFrom<UiElement>(
|
||||||
|
screen.FindElement(CharacterCreationUiController.ProfessionPageElementId));
|
||||||
|
UiButton template = AssertButton(professionRoot, 0x100003D9u);
|
||||||
|
|
||||||
|
Assert.Equal("Unselected", template.ActiveState);
|
||||||
|
template.Selected = true;
|
||||||
|
Assert.Equal("Selected", template.ActiveState);
|
||||||
|
template.Selected = false;
|
||||||
|
Assert.Equal("Unselected", template.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-8: the Appearance page's Face/Clothes sub-tab buttons
|
||||||
|
/// (<c>0x100003A9u</c>/<c>0x100003AAu</c>) author the SAME custom
|
||||||
|
/// radio-pair shape on their own icon child (<c>0x100002E9</c>) — same
|
||||||
|
/// mechanism as the heritage/template rows, different page.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void AppearancePage_FaceClothesSubTabButtons_SelectedTogglesTheAuthoredState()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ImportedLayout screen = BuildSelected(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||||
|
|
||||||
|
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
|
||||||
|
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
|
||||||
|
|
||||||
|
foreach (uint buttonId in new[]
|
||||||
|
{
|
||||||
|
CharacterCreationAppearancePage.FaceButtonId,
|
||||||
|
CharacterCreationAppearancePage.ClothesButtonId,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
UiButton button = AssertButton(appearanceRoot, buttonId);
|
||||||
|
Assert.Equal("Unselected", button.ActiveState);
|
||||||
|
button.Selected = true;
|
||||||
|
Assert.Equal("Selected", button.ActiveState);
|
||||||
|
button.Selected = false;
|
||||||
|
Assert.Equal("Unselected", button.ActiveState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-1 family, gender-button shape: <c>0x100003A7u</c>/
|
||||||
|
/// <c>0x100003A8u</c> author the custom Unselected/Selected media
|
||||||
|
/// DIRECTLY on the button's own StateMedia (no separate face-segment
|
||||||
|
/// child) — live-DAT-measured, distinct from the heritage/template/
|
||||||
|
/// sub-tab family above. Exercises <see cref="UiButton"/>'s OTHER
|
||||||
|
/// custom-selection-pair code path (media on the button itself, so
|
||||||
|
/// <c>info.StateMedia.Count != 0</c> and no face child is ever
|
||||||
|
/// computed).
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void AppearancePage_GenderButtons_SelectedTogglesTheAuthoredState()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ImportedLayout screen = BuildSelected(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||||
|
|
||||||
|
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
|
||||||
|
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
|
||||||
|
|
||||||
|
foreach (uint buttonId in new[]
|
||||||
|
{
|
||||||
|
CharacterCreationAppearancePage.FemaleButtonId,
|
||||||
|
CharacterCreationAppearancePage.MaleButtonId,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
UiButton button = AssertButton(appearanceRoot, buttonId);
|
||||||
|
Assert.Equal("Unselected", button.ActiveState);
|
||||||
|
button.Selected = true;
|
||||||
|
Assert.Equal("Selected", button.ActiveState);
|
||||||
|
button.Selected = false;
|
||||||
|
Assert.Equal("Unselected", button.ActiveState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-9 (Campaign CC gate round 1 Batch B). Live-DAT-measured: all nine
|
||||||
|
/// companion overlay elements (<c>SwatchOverlayIds</c>) resolve as
|
||||||
|
/// siblings of the swatches under the color-wheel container — retail's
|
||||||
|
/// ACTUAL click-feedback mechanism (<c>SetColor</c>'s
|
||||||
|
/// <c>m_tColorWheel[...][0x10][iCurColor*7]->SetVisible</c>), not a
|
||||||
|
/// state swap on the swatch buttons themselves (which author only an
|
||||||
|
/// unnamed DirectState sprite — no Normal/Highlight media at all).
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void AppearancePage_SwatchOverlays_AllNinePresent()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ImportedLayout screen = BuildSelected(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||||
|
|
||||||
|
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
|
||||||
|
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
|
||||||
|
|
||||||
|
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
|
||||||
|
{
|
||||||
|
UiElement overlay = Assert.IsAssignableFrom<UiElement>(
|
||||||
|
UiElement.FindDescendant(appearanceRoot, overlayId));
|
||||||
|
// Retail's overlay ring starts hidden — SetColor only shows the
|
||||||
|
// one at the current color index; nothing is selected before
|
||||||
|
// any color choice runs.
|
||||||
|
Assert.True(overlay.Visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-10 (Campaign CC gate round 1 Batch B). Live-DAT-measured: both
|
||||||
|
/// zoom buttons author a STANDARD Normal/Highlight(/rollover) pair —
|
||||||
|
/// unlike the custom radio-pair family above, this is pure wiring
|
||||||
|
/// (<see cref="CharacterCreationAppearancePage"/>'s click handlers), not
|
||||||
|
/// a new UiButton mechanism.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void AppearancePage_ZoomButtons_AuthorStandardNormalHighlightPair()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
uint layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||||
|
ImportedLayout screen = BuildSelected(
|
||||||
|
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||||
|
|
||||||
|
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
|
||||||
|
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
|
||||||
|
|
||||||
|
foreach (uint buttonId in new[]
|
||||||
|
{
|
||||||
|
CharacterCreationAppearancePage.ZoomInId,
|
||||||
|
CharacterCreationAppearancePage.ZoomOutId,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
UiButton button = AssertButton(appearanceRoot, buttonId);
|
||||||
|
Assert.Equal("Normal", button.ActiveState);
|
||||||
|
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Highlight));
|
||||||
|
Assert.Equal("Highlight", button.ActiveState);
|
||||||
|
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Normal));
|
||||||
|
Assert.Equal("Normal", button.ActiveState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The exit-warning + all per-heritage/per-town DAT string
|
/// <summary>The exit-warning + all per-heritage/per-town DAT string
|
||||||
/// keys this slice cites actually resolve in the installed table.
|
/// keys this slice cites actually resolve in the installed table.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -401,6 +635,25 @@ public sealed class CharacterCreationLiveDatTests
|
||||||
spin.TrySetRetailState(UiButtonStateMachine.Highlight),
|
spin.TrySetRetailState(UiButtonStateMachine.Highlight),
|
||||||
$"spin 0x{spinId:X8} must accept a Highlight state request.");
|
$"spin 0x{spinId:X8} must accept a Highlight state request.");
|
||||||
Assert.Equal("Normal", spin.ActiveState);
|
Assert.Equal("Normal", spin.ActiveState);
|
||||||
|
|
||||||
|
// AP-222 CORRECTED + RESOLVED (Campaign CC gate round 1 Batch B):
|
||||||
|
// the art half of the "no-op" stays a genuine no-op (ActiveState
|
||||||
|
// pinned to "Normal" above, unchanged) — retail's own spin art
|
||||||
|
// authors no Highlight media either, matching acdream. But the
|
||||||
|
// current-part highlight is NOT presentation-dead: retail's
|
||||||
|
// SetState(6) also recolors the spin's caption text (dat
|
||||||
|
// property 0x1B), live-DAT-measured 218,167,85 (Normal) ->
|
||||||
|
// 255,221,131 (Highlight), plus outline off -> on (property
|
||||||
|
// 0x21). DatWidgetFactory.BuildButton wires this per-state style
|
||||||
|
// unconditionally, so it is already active on `spin` from
|
||||||
|
// TrySetRetailState(Highlight) above, independent of whether the
|
||||||
|
// page has assigned a Label string yet.
|
||||||
|
Assert.Equal(new Vector4(255f / 255f, 221f / 255f, 131f / 255f, 1f), spin.LabelColor);
|
||||||
|
Assert.True(spin.Outline, $"spin 0x{spinId:X8} must outline its label in the Highlight state.");
|
||||||
|
|
||||||
|
Assert.True(spin.TrySetRetailState(UiButtonStateMachine.Normal));
|
||||||
|
Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), spin.LabelColor);
|
||||||
|
Assert.False(spin.Outline, $"spin 0x{spinId:X8} must not outline its label in the Normal state.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every color-wheel-family id resolves through EXISTING
|
// Every color-wheel-family id resolves through EXISTING
|
||||||
|
|
|
||||||
|
|
@ -785,6 +785,52 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
|
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch click
|
||||||
|
/// feedback — exactly one companion overlay visible, tracking the
|
||||||
|
/// current part's own selected color index (<c>SetColor</c>'s
|
||||||
|
/// <c>m_tColorWheel[...][0x10][iCurColor*7]->SetVisible</c>). Drives
|
||||||
|
/// the snapshot directly (the fake binding only records what a click
|
||||||
|
/// SENDS, it doesn't feed it back) to exercise
|
||||||
|
/// <c>RefreshColorAndShadeControls</c>'s own overlay loop end to end.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceSwatchOverlays_ExactlyOneVisible_TrackingTheCurrentPartsColorIndex()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
SelectAluvianMale(environment);
|
||||||
|
// Part defaults to Hair on construction — no extra click needed.
|
||||||
|
|
||||||
|
UiElement[] overlays = [.. CharacterCreationAppearancePage.SwatchOverlayIds
|
||||||
|
.Select(environment.Page)];
|
||||||
|
|
||||||
|
// No color selected yet (Unset) -> every overlay hidden.
|
||||||
|
Assert.All(overlays, overlay => Assert.False(overlay.Visible));
|
||||||
|
|
||||||
|
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
|
||||||
|
environment.Runtime.View.Snapshot = snapshot with
|
||||||
|
{
|
||||||
|
Revision = snapshot.Revision + 1,
|
||||||
|
Appearance = snapshot.Appearance with { HairColor = 1u },
|
||||||
|
};
|
||||||
|
environment.Controller.Tick();
|
||||||
|
|
||||||
|
for (int i = 0; i < overlays.Length; i++)
|
||||||
|
Assert.Equal(i == 1, overlays[i].Visible);
|
||||||
|
|
||||||
|
snapshot = environment.Runtime.View.Snapshot;
|
||||||
|
environment.Runtime.View.Snapshot = snapshot with
|
||||||
|
{
|
||||||
|
Revision = snapshot.Revision + 1,
|
||||||
|
Appearance = snapshot.Appearance with { HairColor = 2u },
|
||||||
|
};
|
||||||
|
environment.Controller.Tick();
|
||||||
|
|
||||||
|
for (int i = 0; i < overlays.Length; i++)
|
||||||
|
Assert.Equal(i == 2, overlays[i].Visible);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart()
|
public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart()
|
||||||
{
|
{
|
||||||
|
|
@ -848,6 +894,48 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
|
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-10 (Campaign CC gate round 1 Batch B): ports
|
||||||
|
/// <c>gmCGAppearancePage::ZoomIn @0x0047CF00</c>
|
||||||
|
/// (<c>@0x0047d005/0x0047d00f</c>: ZoomInButton -> Highlight(6),
|
||||||
|
/// ZoomOutButton -> Normal(1)) and its <c>ZoomOut</c> mirror
|
||||||
|
/// (<c>@0x0047D050</c>, <c>@0x0047d140/0x0047d14a</c>). Both buttons
|
||||||
|
/// start at their DAT-authored "Normal" default — re-derived from
|
||||||
|
/// <c>InitializePage @0x0047fdd0-0048032e</c>: <c>m_bZoomedIn = 0</c> is
|
||||||
|
/// set at construction (<c>@0x004802c3</c>) but NO explicit initial
|
||||||
|
/// <c>SetState</c> call exists for either zoom button anywhere in
|
||||||
|
/// <c>InitializePage</c>, so this port does not force one either.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceZoomButtons_ClickPath_TogglesMutualExclusiveHighlightPair()
|
||||||
|
{
|
||||||
|
using var environment = new EnvironmentHarness();
|
||||||
|
environment.Controller.Open();
|
||||||
|
var preview = new FakeChargenPreviewControl();
|
||||||
|
environment.Controller.AppearancePreviewControl = preview;
|
||||||
|
|
||||||
|
UiButton zoomIn = environment.Button(CharacterCreationAppearancePage.ZoomInId);
|
||||||
|
UiButton zoomOut = environment.Button(CharacterCreationAppearancePage.ZoomOutId);
|
||||||
|
|
||||||
|
Assert.Equal("Normal", zoomIn.ActiveState);
|
||||||
|
Assert.Equal("Normal", zoomOut.ActiveState);
|
||||||
|
|
||||||
|
zoomIn.OnClick!();
|
||||||
|
Assert.Equal("Highlight", zoomIn.ActiveState);
|
||||||
|
Assert.Equal("Normal", zoomOut.ActiveState);
|
||||||
|
|
||||||
|
zoomOut.OnClick!();
|
||||||
|
Assert.Equal("Normal", zoomIn.ActiveState);
|
||||||
|
Assert.Equal("Highlight", zoomOut.ActiveState);
|
||||||
|
|
||||||
|
// Re-asserting the SAME direction is idempotent (retail's own early-
|
||||||
|
// return branch when already zoomed in/out — this port doesn't
|
||||||
|
// track m_bZoomedIn, but the RESULT is identical either way).
|
||||||
|
zoomOut.OnClick!();
|
||||||
|
Assert.Equal("Normal", zoomIn.ActiveState);
|
||||||
|
Assert.Equal("Highlight", zoomOut.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
private static void SelectAluvianMale(EnvironmentHarness environment)
|
private static void SelectAluvianMale(EnvironmentHarness environment)
|
||||||
{
|
{
|
||||||
environment.Runtime.SelectHeritageDirect(AluvianId);
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||||
|
|
@ -2151,6 +2239,11 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
|
|
||||||
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
|
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
|
||||||
page.Children.Add(ButtonInfo(swatchId));
|
page.Children.Add(ButtonInfo(swatchId));
|
||||||
|
// GF-9: the nine companion overlay elements, live-DAT-measured as
|
||||||
|
// plain Type-3 siblings of the swatches under the color-wheel
|
||||||
|
// container.
|
||||||
|
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
|
||||||
|
page.Children.Add(ContainerInfo(overlayId));
|
||||||
|
|
||||||
page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId));
|
page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId));
|
||||||
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
|
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
|
||||||
|
|
@ -2166,12 +2259,27 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
|
|
||||||
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId));
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId));
|
||||||
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId));
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId));
|
||||||
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomInId));
|
// GF-10: unlike the plain ButtonInfo() used above, the zoom buttons
|
||||||
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
|
// need REAL Normal/Highlight media so AppearanceZoomButtons_
|
||||||
|
// ClickPath_TogglesMutualExclusiveHighlightPair can observe the
|
||||||
|
// actual mutual-exclusive state swap through TrySetRetailState —
|
||||||
|
// live-DAT-measured shape (both start "Normal", both author
|
||||||
|
// Highlight/rollover media).
|
||||||
|
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId));
|
||||||
|
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
|
||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ElementInfo ZoomButtonInfo(uint id)
|
||||||
|
{
|
||||||
|
var info = new ElementInfo { Id = id, Type = 1u, Width = 81f, Height = 38f };
|
||||||
|
info.StateMedia["Normal"] = (0x06004D55u, 1);
|
||||||
|
info.StateMedia["Highlight"] = (0x06004D56u, 1);
|
||||||
|
info.DefaultStateName = "Normal";
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
private static ElementInfo SpinInfo(uint id)
|
private static ElementInfo SpinInfo(uint id)
|
||||||
{
|
{
|
||||||
var spin = new ElementInfo
|
var spin = new ElementInfo
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,96 @@ public class DatWidgetFactoryTests
|
||||||
Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign);
|
Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-11c (Campaign CC gate round 1 Batch B): the Town page's per-marker
|
||||||
|
/// caption shape (live-DAT-measured on 0x1000040D/0x10000409) — a
|
||||||
|
/// single stateful face child (the marker/pin, Normal/Highlight media)
|
||||||
|
/// PLUS a DISTINCT Type-12 caption child with its own authored rect and
|
||||||
|
/// Center justify, positioned independently of the marker (e.g. below
|
||||||
|
/// or above it, not necessarily beside it). Before this fix, the
|
||||||
|
/// caption's own rect/justify was discarded in favor of a Left-aligned
|
||||||
|
/// offset computed from the FACE rect — correct only for the heritage/
|
||||||
|
/// template row family below, where the label is authored directly on
|
||||||
|
/// the button itself (see the companion regression test).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildButton_SingleFaceChild_LiftedCaptionWithOwnRect_HonorsLabelBoxNotFaceOffset()
|
||||||
|
{
|
||||||
|
uint stringId = 555u;
|
||||||
|
var info = new ElementInfo { Type = 1, Width = 106, Height = 80 };
|
||||||
|
info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" };
|
||||||
|
info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" };
|
||||||
|
|
||||||
|
var caption = new ElementInfo
|
||||||
|
{
|
||||||
|
Type = 12,
|
||||||
|
X = 0,
|
||||||
|
Y = 4,
|
||||||
|
Width = 100,
|
||||||
|
Height = 37,
|
||||||
|
HJustify = HJustify.Center,
|
||||||
|
};
|
||||||
|
caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||||
|
caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
||||||
|
{
|
||||||
|
Kind = UiPropertyKind.StringInfo,
|
||||||
|
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
|
||||||
|
};
|
||||||
|
info.Children.Add(caption);
|
||||||
|
|
||||||
|
var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 };
|
||||||
|
marker.StateMedia["Normal"] = (0x06004D60u, 1);
|
||||||
|
marker.StateMedia["Highlight"] = (0x06004D61u, 1);
|
||||||
|
info.Children.Add(marker);
|
||||||
|
|
||||||
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
||||||
|
info, NoTex, null,
|
||||||
|
stringResolve: value => value.StringId == stringId ? "Holtburg" : null));
|
||||||
|
|
||||||
|
Assert.Equal("Holtburg", button.Label);
|
||||||
|
Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign);
|
||||||
|
Assert.Equal((0f, 4f, 100f, 37f), button.LabelBox);
|
||||||
|
// The face geometry is still captured for the marker's own draw —
|
||||||
|
// just no longer used to derive the label's position.
|
||||||
|
Assert.Equal((36f, 36f, 38f, 38f), (button.FaceLeft, button.FaceTop, button.FaceWidth, button.FaceHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression companion: the heritage/template/Face-Clothes row shape —
|
||||||
|
/// the label string is authored DIRECTLY on the button (no distinct
|
||||||
|
/// Type-12 child), beside a single stateful face child (the radio dot).
|
||||||
|
/// This is the case the FACE-relative Left-aligned offset math IS
|
||||||
|
/// correct for, and it must keep working exactly as before GF-11c.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildButton_SingleFaceChild_DirectLabel_KeepsFaceRelativeOffset()
|
||||||
|
{
|
||||||
|
uint stringId = 777u;
|
||||||
|
var info = new ElementInfo { Type = 1, Width = 305, Height = 32, HJustify = HJustify.Left };
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
info.States[RetailUiStateIds.Unselected] = new UiStateInfo { Id = RetailUiStateIds.Unselected, Name = "Unselected" };
|
||||||
|
info.States[RetailUiStateIds.Selected] = new UiStateInfo { Id = RetailUiStateIds.Selected, Name = "Selected" };
|
||||||
|
|
||||||
|
var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 };
|
||||||
|
dot.StateMedia["Unselected"] = (0x06006E35u, 1);
|
||||||
|
dot.StateMedia["Selected"] = (0x06006E21u, 1);
|
||||||
|
info.Children.Add(dot);
|
||||||
|
|
||||||
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
||||||
|
info, NoTex, null,
|
||||||
|
stringResolve: value => value.StringId == stringId ? "Aluvian" : null));
|
||||||
|
|
||||||
|
Assert.Equal("Aluvian", button.Label);
|
||||||
|
Assert.Null(button.LabelBox);
|
||||||
|
Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign);
|
||||||
|
Assert.Equal(36f, button.LabelOffsetX); // face.X(0) + face.Width(32) + 4
|
||||||
|
}
|
||||||
|
|
||||||
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
|
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,171 @@ public class UiButtonTests
|
||||||
Assert.Equal(2, clicks);
|
Assert.Equal(2, clicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GF-1/GF-8 (Campaign CC gate round 1 Batch B): the "gender button"
|
||||||
|
/// shape — retail's custom Unselected/Selected radio-pair media authored
|
||||||
|
/// DIRECTLY on the button's own StateMedia (no separate face-segment
|
||||||
|
/// child), live-DAT-measured on 0x100003A7/0x100003A8 (Female/Male).
|
||||||
|
/// Before this fix, .Selected committed nothing: the standard
|
||||||
|
/// AddAvailableStates loop never recognized the "Unselected"/"Selected"
|
||||||
|
/// names, so _availableStates was empty and UpdateVisualState's
|
||||||
|
/// RequestedState (which only ever returns Normal/Highlight/Ghosted ids)
|
||||||
|
/// could never match anyway.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CustomSelectionPair_MediaDirectlyOnButton_SelectedTogglesActiveState()
|
||||||
|
{
|
||||||
|
var info = ButtonInfo("Unselected", "Selected");
|
||||||
|
var b = CreateButton(info);
|
||||||
|
|
||||||
|
Assert.Equal("Unselected", b.ActiveState);
|
||||||
|
|
||||||
|
b.Selected = true;
|
||||||
|
Assert.Equal("Selected", b.ActiveState);
|
||||||
|
|
||||||
|
b.Selected = false;
|
||||||
|
Assert.Equal("Unselected", b.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The "heritage/template/sub-tab row" shape — the parent authors the
|
||||||
|
/// Unselected/Selected state DESCRIPTORS (property bag only, no media),
|
||||||
|
/// and a single stateful child (the radio dot / icon) carries the
|
||||||
|
/// actual per-state art, matching <c>FindStatefulFaceChildren</c>'s
|
||||||
|
/// name-overlap detection. Live-DAT-measured on the Heritage row
|
||||||
|
/// (0x100003BF, dot child 0x100003C0) and the Profession template row
|
||||||
|
/// (0x100003D9, icon child 0x100002E9).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CustomSelectionPair_MediaOnFaceChild_SelectedTogglesActiveState()
|
||||||
|
{
|
||||||
|
var info = new ElementInfo { Type = 1, Width = 305, Height = 32 };
|
||||||
|
info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo
|
||||||
|
{
|
||||||
|
Id = UiButtonStateMachine.NormalPressed,
|
||||||
|
Name = "Normal_pressed",
|
||||||
|
};
|
||||||
|
info.States[RetailUiStateIds.Unselected] = new UiStateInfo
|
||||||
|
{
|
||||||
|
Id = RetailUiStateIds.Unselected,
|
||||||
|
Name = "Unselected",
|
||||||
|
};
|
||||||
|
info.States[RetailUiStateIds.Selected] = new UiStateInfo
|
||||||
|
{
|
||||||
|
Id = RetailUiStateIds.Selected,
|
||||||
|
Name = "Selected",
|
||||||
|
};
|
||||||
|
info.DefaultStateName = "Unselected";
|
||||||
|
|
||||||
|
var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 };
|
||||||
|
dot.StateMedia["Unselected"] = (0x06006E35u, 1);
|
||||||
|
dot.StateMedia["Selected"] = (0x06006E21u, 1);
|
||||||
|
info.Children.Add(dot);
|
||||||
|
|
||||||
|
// Face-child discovery (FindStatefulFaceChildren) is DatWidgetFactory's
|
||||||
|
// job, not UiButton's own constructor — go through the real factory
|
||||||
|
// path so this fixture matches production exactly (raw CreateButton
|
||||||
|
// below bypasses that discovery entirely).
|
||||||
|
var b = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
|
||||||
|
|
||||||
|
Assert.Equal("Unselected", b.ActiveState);
|
||||||
|
|
||||||
|
b.Selected = true;
|
||||||
|
Assert.Equal("Selected", b.ActiveState);
|
||||||
|
Assert.Equal(RetailUiStateIds.Selected, b.ActiveRetailStateId);
|
||||||
|
|
||||||
|
b.Selected = false;
|
||||||
|
Assert.Equal("Unselected", b.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression pin: a STANDARD ToggleBehavior button (no Unselected/
|
||||||
|
/// Selected states authored at all — the overwhelming majority of
|
||||||
|
/// buttons, including every pre-existing ToggleBehavior consumer) keeps
|
||||||
|
/// behaving exactly as before the custom-pair bypass was added.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CustomSelectionPair_Absent_StandardToggleBehaviorUnchanged()
|
||||||
|
{
|
||||||
|
var info = ButtonInfo("Normal", "Highlight");
|
||||||
|
AddBoolProperty(info, 0x0Bu, true);
|
||||||
|
var b = CreateButton(info);
|
||||||
|
|
||||||
|
b.Selected = true;
|
||||||
|
Assert.Equal("Highlight", b.ActiveState);
|
||||||
|
|
||||||
|
b.Selected = false;
|
||||||
|
Assert.Equal("Normal", b.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label
|
||||||
|
/// color/outline reacts to the REQUESTED retail state id even when the
|
||||||
|
/// standard art-availability gate never lets ActiveState reach it — the
|
||||||
|
/// Appearance spins' exact shape (their arrow face segments carry no
|
||||||
|
/// Highlight media at all, so ActiveState is permanently stuck at
|
||||||
|
/// "Normal", but the label text must still recolor). Live-DAT-measured
|
||||||
|
/// values: Normal (218,167,85), Highlight (255,221,131), outline
|
||||||
|
/// off -> on.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void PerStateLabelStyle_AppliesEvenWhenActiveStateCannotReachIt()
|
||||||
|
{
|
||||||
|
var info = ButtonInfo("Normal"); // no Highlight media at all
|
||||||
|
AddBoolProperty(info, 0x0Bu, true); // ToggleBehavior
|
||||||
|
var b = CreateButton(info);
|
||||||
|
b.Label = "Hair Style";
|
||||||
|
b.LabelColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f);
|
||||||
|
|
||||||
|
var colors = new Dictionary<uint, System.Numerics.Vector4>
|
||||||
|
{
|
||||||
|
[UiButtonStateMachine.Normal] = new(218f / 255f, 167f / 255f, 85f / 255f, 1f),
|
||||||
|
[UiButtonStateMachine.Highlight] = new(255f / 255f, 221f / 255f, 131f / 255f, 1f),
|
||||||
|
};
|
||||||
|
var outlines = new Dictionary<uint, bool>
|
||||||
|
{
|
||||||
|
[UiButtonStateMachine.Normal] = false,
|
||||||
|
[UiButtonStateMachine.Highlight] = true,
|
||||||
|
};
|
||||||
|
b.SetPerStateLabelStyle(colors, outlines);
|
||||||
|
|
||||||
|
Assert.Equal(colors[UiButtonStateMachine.Normal], b.LabelColor);
|
||||||
|
Assert.False(b.Outline);
|
||||||
|
|
||||||
|
b.Selected = true;
|
||||||
|
|
||||||
|
// The art stays "Normal" (no Highlight media exists to commit to) —
|
||||||
|
// this is the exact AP-222 no-op the standard gate always produced —
|
||||||
|
// but the label color/outline must still reach the Highlight values.
|
||||||
|
Assert.Equal("Normal", b.ActiveState);
|
||||||
|
Assert.Equal(colors[UiButtonStateMachine.Highlight], b.LabelColor);
|
||||||
|
Assert.True(b.Outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression pin: a button with NO per-state color map (null, the
|
||||||
|
/// overwhelming majority — every existing external post-construction
|
||||||
|
/// LabelColor assignment such as ChatWindowController's Send caption or
|
||||||
|
/// PaperdollController's Slots label) never has its LabelColor touched
|
||||||
|
/// by a state change.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void PerStateLabelStyle_Absent_ExternalLabelColorAssignmentSurvivesStateChanges()
|
||||||
|
{
|
||||||
|
var info = ButtonInfo("Normal", "Highlight");
|
||||||
|
AddBoolProperty(info, 0x0Bu, true);
|
||||||
|
var b = CreateButton(info);
|
||||||
|
var externalColor = new System.Numerics.Vector4(1f, 0.92f, 0.72f, 1f);
|
||||||
|
b.LabelColor = externalColor;
|
||||||
|
|
||||||
|
b.Selected = true;
|
||||||
|
Assert.Equal("Highlight", b.ActiveState);
|
||||||
|
Assert.Equal(externalColor, b.LabelColor);
|
||||||
|
|
||||||
|
b.Selected = false;
|
||||||
|
Assert.Equal(externalColor, b.LabelColor);
|
||||||
|
}
|
||||||
|
|
||||||
private static UiButton ButtonWithStates(params string[] states)
|
private static UiButton ButtonWithStates(params string[] states)
|
||||||
{
|
{
|
||||||
var info = ButtonInfo(states);
|
var info = ButtonInfo(states);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue