From a9b6435f5521e223225cfc1811bc4df1d451b0b2 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 13 Aug 2026 08:51:57 +0200 Subject: [PATCH] =?UTF-8?q?fix=20#385:=20Options=20dropdowns=20=E2=80=94?= =?UTF-8?q?=20white=20centered=20text=20+=20size-to-content=20popup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User gate report (Campaign OP happy-testing round, 2026-08-13): every Config-tab dropdown drew its text gold + left-aligned and its popup a fixed 6 rows regardless of item count. All three were unmeasured styling divergences — the authored data (new probe menuprobe3, live DAT) says: - button label child 0x10000355: fontColor white, hJustify=Center - row template 0x1000035A: fontColor white, hJustify=Center - popup ListBox 0x10000358: edge-docked L=T=R=B=1, the authored condition arming retail UIElement_Menu::RecalculatePopupSize @0x0046caf0 — popup resizes to the ListBox's summed content height, uncapped (0x0046e5f4..0046e66c via ResizeScrollableArea's 0x32 broadcast) UiMenu gains three opt-in properties (ButtonTextCentered, ItemTextCentered, PopupSizeToContent) plus retail Open @0x0046cc42's empty-list gate; chat + vendor keep the class defaults, so their shipped behavior is untouched. ConfigOptionsPageController.ApplyMenuChrome wires all four corrections for the 8 Config menus with the probe citation. The same probe found vendor's authored popup ListBox is ALSO docked while our vendor dropdown ships G5's fixed 6-row window — filed as #386 + register row AD-88 (UNCLEAR: the G5 retail screenshot and the decomp mechanism conflict) instead of silently reworking a user-gated surface. The "resolution change resizes the window" observation from the same report is #374's designed windowed-mode behavior (display-mode switching is #376/#377) — no change. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 45 ++++++ .../retail-divergence-register.md | 3 +- .../2026-08-11-campaign-op-test-script.md | 10 ++ .../UI/Layout/ConfigOptionsPageController.cs | 15 ++ src/AcDream.App/UI/UiMenu.cs | 100 +++++++++++- .../ConfigOptionsPageControllerTests.cs | 43 ++++++ .../Layout/OptionsPanelLiveMountProbeTests.cs | 143 ++++++++++++++++++ tests/AcDream.App.Tests/UI/UiMenuTests.cs | 72 +++++++++ 8 files changed, 422 insertions(+), 9 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b8ae4ef2..0f0bbb0c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,51 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #386 — Vendor category dropdown: authored ListBox is edge-docked — retail would size the popup to content, our shipped 6-row window may diverge + +**Status:** OPEN — filed 2026-08-13 while fixing #385. The #385 probe +(`OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle`, +menuprobe3) measured the vendor category popup's authored ListBox +(`0x21000043/0x10000350`) as edge-docked on all four sides (L=T=R=B=1) — +the exact authored condition that arms retail's +`UIElement_Menu::RecalculatePopupSize @0x0046caf0` size-to-content path +(popup grows/shrinks to the summed row heights, uncapped). Our vendor +dropdown ships G5's fixed 6-row scrollable window instead, which the G5 +retail screenshot ("~one-column-with-scrollbar look") appeared to support +and the vendor connected gate user-passed. The two pieces of evidence +conflict: the decomp mechanism says an 18-category popup should open +full-height (~324 px) with an inert stretched scrollbar strip; the G5 +screenshot was read as a 6-row scroll window. Next step is a retail +side-by-side of the vendor category dropdown specifically (open the +category menu at a vendor with many categories). If retail shows the +full-height popup, flip `UiMenu.PopupSizeToContent = true` in +`VendorUiController` (one line — the mechanism shipped with #385) and +retire the divergence; if retail truly shows a 6-row window, document WHY +the docked ListBox does not trigger RecalculatePopupSize there (a message +routing difference is plausible: the vendor popup's items are inserted +BEFORE `RegisterForElementMessages`, so the 0x32 broadcast may never reach +the menu). Register row AD-88 (unclear) tracks it. + +## #385 — Options-panel dropdowns: gold left-aligned text + fixed 6-row popup (retail: white, centered, size-to-content) + +**Status:** DONE 2026-08-13 (this commit). User gate report (Campaign OP +happy-testing round): every Config-tab dropdown (Sound Features, +Resolution, …) drew its text yellow and left-aligned, and the popup a +fixed 6 rows regardless of item count. All three symptoms were +unmeasured-styling divergences in `UiMenu`/`ApplyMenuChrome` — the +authored data (menuprobe3, live-DAT) says: button label child +`0x10000355` white + hJustify=Center; row template `0x1000035A` white + +hJustify=Center; popup ListBox `0x10000358` edge-docked, arming retail's +`RecalculatePopupSize @0x0046caf0` size-to-content resize (content = +summed laid-out row heights, uncapped — `0x0046e5f4..0046e66c`). Fix: +three opt-in `UiMenu` properties (`ButtonTextCentered`, `ItemTextCentered`, +`PopupSizeToContent` — chat + vendor keep class defaults) plus retail's +`Open @0x0046cc42` empty-list gate, wired for all 8 Config menus in +`ConfigOptionsPageController.ApplyMenuChrome`. The resolution-row +"changing resolution resizes the window" observation from the same report +is the #374 designed windowed-mode behavior (true display-mode switching +is #376/#377) — no change. + ## #384 — FA6 allegiance-swear bot gate: ACE returns no response to 0x001D swear (no confirmation/0x0020/error) **Status:** OPEN — filed 2026-08-12 at Campaign FA slice FA6. The two-bot diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 445a9b00..85368629 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 67 active rows (AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 68 active rows (AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -182,6 +182,7 @@ readiness/requeue adaptation. See | AD-85 | **Filed 2026-08-12 at Campaign FA slice FA5.** The SAME unported `StringInfo` variable-substitution engine AD-81 filed for the Fellowship page's row/stats text (`StringInfo::InqString` → `StringTableMetaLanguage::UnescapeString`) also affects the Allegiance page's numeric fields and its three LOCAL confirmation dialogs: (1) **Numeric fields** — self/monarch followers (`0x10000252`/`0x10000258`), self rank (`0x10000253`), and the "experience passed up" text (`0x10000492` ×2, the vassal row's `0x10000269`) render as bare numbers with no surrounding retail words, same disposition as AD-81's `"{level} {pct}%"`. (2) **Swear/Break/Kick confirmation dialogs** — `MakeSwearConfirmationDialog`/`MakeBreakConfirmationDialog`/`MakeKickConfirmationDialog` bind the target's name into a `StringInfo` template (`ID_Allegiance_SwearConfirmation`/`BreakConfirmation`/`KickConfirmation`, lane C §5.1); this controller shows the RESOLVED retail template text VERBATIM (unsubstituted — the raw DAT string, whatever it says) when resolution succeeds, and falls back to the bare target name (also non-invented) when it does not. Neither path ever composes new English. (3) **The server-driven "accept incoming swear" dialog** (`ConfirmationType` 1, handled generically by `GameplayConfirmationController` — no allegiance-specific code) inherits the SAME gap from the wire side: ACE sends the target's bare `Name` as the ENTIRE confirmation message (lane C §6.4: `Player_Allegiance.cs:91`/`ConfirmationManager.cs:38`), not a formatted sentence — retail's own client wraps it via the identical `StringInfo` mechanism this row already covers. | `src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs` (`RefreshSelfBlock`, `RefreshMonarchBlock`, `RefreshPatronBlock`, `UpdateRow`, `OnSwearClick`, `OnBreakClick`, `OnKickClick`) | Same argument as AD-81: porting `StringTableMetaLanguage` is a cross-cutting prerequisite, not allegiance-specific, and guessing its token/placeholder syntax to blend a name into an unverified sentence shape would risk silently-wrong substitution — exactly the guessing CLAUDE.md's workflow forbids. Using retail's own unsubstituted text verbatim (or the bare name) is the non-inventing alternative. | A user sees bare numbers instead of retail's full sentences for followers/rank/XP-passed-up, and the three local confirmation dialogs (plus the server-driven accept-swear dialog) show either an unsubstituted retail template or a bare name instead of "Swear allegiance to PlayerName?"-style prose. The underlying DATA (the numbers, the target's real name) is correct in every case — only the surrounding words are absent. | `gmAllegianceUI::UpdatePlayerData @0x00491330`, `UpdateMonarchData @0x00491B40`, `UpdatePatronData @0x004917C0`, `UpdateVassalsData @0x00492340` (lane C/A field sources); `MakeSwearConfirmationDialog @0x004927B0` family (lane A §5.1); `StringInfo::InqString @0x0042e490` (unresolved, same citation as AD-81) | | AD-86 | **Filed 2026-08-12 at Campaign FA slice FA5, item 4.** ACE deliberately zeroes or empties NINE `AllegianceProfile`/`AllegianceData` fields on the wire — officers, officer titles, MOTD, MOTD-set-by, name-last-set-time, lock state, and approved vassal are always empty/false/zero regardless of the allegiance's real state; `timeOnline`/`allegianceAge` (the remaining two) are hard-coded 0 forever (lane C §5.1). acdream's FA1 parser reads all of these (to keep the byte cursor aligned for the fields after them) but drops most at increasing layers: `AllegianceMemberRecord` never surfaces `timeOnline`/`allegianceAge` as fields at all; `RuntimeAllegianceState.ApplyUpdate` (FA2) does not forward `Motd`/`MotdSetBy`/`ChatRoomId`/`NameLastSetTime`/`IsLocked`/`ApprovedVassal` from the parsed `AllegianceUpdate` record to `RuntimeAllegianceSnapshot` even though the C# record itself carries them; retail's own `gmAllegianceUI` (FA5) has no widget for any of the seven either (lane A §3.3: "No allegiance MOTD / officer / ban / hometown UI" — they are chat-verb-only in the 2013 client, out of this campaign's scope per the plan's §4). | `src/AcDream.Core.Net/Messages/ClientCommandResponses.cs` (`ReadAllegianceProfileBody`, `AllegianceMemberRecord`, `AllegianceUpdate`); `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (`ApplyUpdate`) | Retail's own client renders nothing for these seven fields either (no panel widget consumes them) — dropping them past the parse layer matches retail's OWN presentation exactly, and is strictly safer than surfacing values that are always wrong/empty against ACE. | Any FUTURE consumer (the chat-verb-only officer/MOTD/lock/ban management features, §2 master table features #11-31 of the allegiance wire research, explicitly out of Campaign FA's scope) that reads these fields off the Runtime layer will find them permanently zero/empty against ACE regardless of the allegiance's real server-side state — do not chase this as a parser bug; it is ACE's own zeroing. | ACE `Network/Structure/AllegianceHierarchy.cs:53-56,62-64,74-75,78-83,86-89,153-155` (broadcast counters/isLocked/officers/officerTitles/motd/approvedVassal); ACE `Network/Structure/AllegianceData.cs:59-60,86-89,111-112` (timeOnline/allegianceAge); `docs/research/2026-08-11-fa-allegiance-wire.md` §5.1 | | AD-87 | **Filed 2026-08-12 at Campaign FA slice FA6.** The allegiance-swear half of the two-bot headless connected gate (`FellowshipAllegianceLeaderBotPolicy`/`FellowshipAllegianceRecruitBotPolicy`) is written and wired end-to-end (proximity, `0x001D` swear, the confirmation-relay seam, `0x0020` tree-reseed assertions, break, reconnect-idempotence) but has never actually been verified to complete over the wire — `AllegianceGateEnabled = false` in both classes keeps it unreachable by default. Six live runs against local ACE all reproduced the same result: the fellowship half passes decisively (the Recruit bot's own `RuntimeFellowshipState` flips, proven three separate times), but ACE returns nothing at all to the `0x001D` swear (no `0x0274` confirmation, no `0x0020`, no error) even at 0.005 m separation — see docs/ISSUES.md #384 for the full evidence trail. So while the FELLOWSHIP two-session machinery is proven live, the ALLEGIANCE two-session machinery (Runtime commands, wire builders, `RuntimeAllegianceState` reseed) remains unverified end-to-end over a real connection — only its unit/fixture-level tests and its (successful) LOCAL echo on the swearer's own client are exercised. | `src/AcDream.Headless/Policies/HeadlessBotPolicy.cs` (`FellowshipAllegianceLeaderBotPolicy.AllegianceGateEnabled`, `FellowshipAllegianceRecruitBotPolicy.AllegianceGateEnabled`, both `false`) | Shipping the fellowship gate ALONE (rather than blocking the whole slice on the allegiance blocker) matches the campaign's own D8/item-6 split — fellowship and allegiance are independent retail systems with independent wire families, and the fellowship half's proof stands on its own regardless of the allegiance outcome. Disabling rather than deleting the allegiance code keeps a reviewed-quality, ready-to-run harness in place for whoever closes #384. | Anyone reading "the FA6 bot-vs-ACE gate passed" without the qualifier could assume the allegiance swear/break/reconnect path is proven over the wire when it is not — only its LOCAL send-and-echo behavior is proven; ACE's actual acceptance of the swear is the open question #384 tracks. | docs/ISSUES.md #384; `docs/research/2026-08-11-fa-allegiance-wire.md` §1.3 (the expected `0x0274`/`0x0275`/`0x0020` handshake); run6 evidence (0.005 m distance, zero inbound after swear) | +| AD-88 | **Filed 2026-08-13 at the #385 dropdown fix (classification: UNCLEAR).** The vendor category dropdown ships G5's fixed 6-row scrollable popup window, but its authored popup ListBox (`0x21000043/0x10000350`) is edge-docked on all four sides (L=T=R=B=1, measured by menuprobe3 `OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle`) — the exact authored condition that arms retail `UIElement_Menu::RecalculatePopupSize @0x0046caf0`, which resizes the popup to the ListBox's summed content height, uncapped (`0x0046e5f4..0046e66c`). The Config option-menus' identical docked shape now drives `UiMenu.PopupSizeToContent=true` (#385); vendor deliberately keeps `false`. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (its UiMenu wiring leaves `PopupSizeToContent` at the class-default false) | The G5 vendor-gate retail screenshot was read as a ~6-row-with-scrollbar look and the vendor connected gate USER-PASSED on that shape — reworking a user-gated surface on decomp inference alone would invert the retail-oracle rule. The two pieces of evidence conflict; the row records the conflict rather than silently picking a side. | If retail actually opens the category popup full-height, our vendor dropdown shows a 6-row scroll window where retail shows every category at once — visible at any vendor with >6 categories. If retail truly shows 6 rows, the mechanism question (why the docked ListBox does not trigger RecalculatePopupSize there) is unanswered and could mislead the next dropdown port. | docs/ISSUES.md #386 (the retail side-by-side to run + the two candidate resolutions); #385 (the Config fix that exposed the conflict) | --- diff --git a/docs/research/2026-08-11-campaign-op-test-script.md b/docs/research/2026-08-11-campaign-op-test-script.md index 08f836a8..bd27677b 100644 --- a/docs/research/2026-08-11-campaign-op-test-script.md +++ b/docs/research/2026-08-11-campaign-op-test-script.md @@ -9,6 +9,16 @@ Re-gate note for the previously-blank tabs: #372's viewport fix landed `[options] gameplay button 0x... clicked` — a silent click in that log IS evidence, report it per-button. +**Dropdown appearance (fix #385, 2026-08-13 happy-testing round).** Every +Config-tab dropdown (Sound Features, Resolution, texture menus, Chat Font +Size, …) now matches the authored retail style: the button caption and the +popup rows draw WHITE and CENTERED (not gold/left), and the popup's height +conforms to its item count (a 3-choice menu opens 3 rows tall, a 10-mode +Resolution list opens 10 rows tall — retail's `RecalculatePopupSize` +size-to-content port; there is no 6-row scroll window on these menus any +more). A clicked dropdown with NO choices no longer opens an empty popup. +Report any dropdown still showing gold/left text or a fixed-height popup. + **Caption dimming (AD-78, user-directed, gate 2).** As of this session, every row this script marks as a store-only row (see each section's own "Store-only rows" subsection, or the register row AD-78 for the complete four-controller diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index d9784659..310591e0 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -314,6 +314,21 @@ public static class ConfigOptionsPageController // glyph to clear space for (see MenuChromeSprites' own doc). menu.TextIndent = 0f; menu.ButtonTextIndent = 0f; + + // User gate report 2026-08-13 (happy-testing round): dropdown text + // drew gold + left-aligned and the popup a fixed 6 rows. All four + // corrections below are MEASURED authored facts (menuprobe3, + // OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle), + // not styling choices: + // - button label child 0x10000355: fontColor white, hJustify=Center; + // - row template 0x1000035A: fontColor white, hJustify=Center; + // - popup ListBox 0x10000358: edges L=T=R=B=1 (docked), which arms + // retail's RecalculatePopupSize @0x0046caf0 size-to-content path + // (see UiMenu.PopupSizeToContent's own doc for the mechanism). + menu.TextColor = Vector4.One; + menu.ButtonTextCentered = true; + menu.ItemTextCentered = true; + menu.PopupSizeToContent = true; } /// The live read/write seam every row on this page writes/reads diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 29c62226..9de157ef 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -190,6 +190,47 @@ public sealed class UiMenu : UiElement public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17) public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row + /// + /// Port of retail UIElement_Menu::RecalculatePopupSize @0x0046caf0 + /// (user gate report 2026-08-13: the Sound Features popup drew a fixed 6 + /// rows tall for a 3-item list): when the authored popup ListBox is + /// edge-docked on both top+bottom (m_topEdge==1 && + /// m_bottomEdge==1), retail resizes the popup to the ListBox's + /// scrollable CONTENT extent — the SUM of laid-out row heights, uncapped + /// (0x0046e5f4..0046e66c feeding + /// UIElement_Scrollable::ResizeScrollableArea @0x00474730, whose + /// 0x32 broadcast RecalculatePopupSize answers) — so the popup shrinks + /// AND grows to the item count. Whether the path is active is an + /// authored per-menu fact, not a convention: the Config option-menu + /// popup ListBox (0x21000043/0x10000358) reads edges L=T=R=B=1 + /// (menuprobe3, OptionsPanelLiveMountProbeTests), so + /// sets + /// this true; chat's grid popup and vendor's shipped 6-row window keep + /// the class default false (vendor's authored ListBox is ALSO docked — + /// tracked as its own issue, not silently reworked here). + /// When set, stops being the visible-window + /// height and the popup shows every item with no scroll overflow. + /// + public bool PopupSizeToContent { get; set; } + + /// + /// Retail draws the button caption through the authored label child named + /// by menu attr 8 (UIElement_Menu::NewSelection @0x0046cd60 writes + /// the selected item's text into it) — its justification comes from the + /// LayoutDesc, not menu code. The Config option-menu label child + /// (0x21000043/0x10000355) authors hJustify=Center (menuprobe3); + /// chat's own label child authors Left, the class default. + /// + public bool ButtonTextCentered { get; set; } + + /// + /// Same authored-justification rule for the popup rows: each row is an + /// authored text template (menu attr 9). The Config option-menu row + /// template (0x21000043/0x1000035A) authors hJustify=Center + /// (menuprobe3); chat's and vendor's row templates author Left. + /// + public bool ItemTextCentered { get; set; } + public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f); /// Available item text — retail white #FFFFFF (gmMainChatUI talk-focus /// enabled state). Confirmed via decomp: enabled items render white. @@ -232,10 +273,28 @@ public sealed class UiMenu : UiElement private float InteriorW => Scrollable ? ColumnWidth + ScrollbarWidth : ColumnCount * ColumnWidth; - private float InteriorH => RowsPerColumn * RowHeight; + + /// The popup's visible row count. Size-to-content (retail's + /// RecalculatePopupSize path — see ): + /// every item, uncapped; otherwise the authored fixed window + /// (). Max(1,·) keeps a detached/empty test + /// menu's geometry finite — a live empty menu never opens (retail + /// Open @0x0046cc42 gates on m_listItems.m_num != 0, + /// ported in ). + private int EffectiveVisibleRows => Scrollable && PopupSizeToContent + ? System.Math.Max(1, Items.Count) + : RowsPerColumn; + + private float InteriorH => EffectiveVisibleRows * RowHeight; private float OuterW => InteriorW + 2 * Border; private float OuterH => InteriorH + 2 * Border; + /// The open popup's outer (bevel-inclusive) height — read-only + /// test seam, same rationale as /: + /// the size-to-content geometry has no other assertable surface short of + /// a full render pass. + public float PopupOuterHeight => OuterH; + /// /// G7 (vendor gate finding, item 2 — popup direction): port of retail /// UIElement_Menu::Open (pc:120210-120252, 0x0046cc30)'s Y placement: @@ -282,7 +341,15 @@ public sealed class UiMenu : UiElement var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite); if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw); } - DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor); + string caption = ButtonLabelProvider?.Invoke() ?? ""; + // Centered captions centre within the label-child band — the authored + // label child spans the button MINUS the arrow-cap overlay's right + // socket (0x10000355 is 100 wide of the 117 button, docked; the + // arrow child overlays the last 17px — menuprobe3). + float capX = ButtonTextCentered + ? MathF.Max(0f, (Width - (ArrowCapClosedSprite != 0 ? ArrowCapWidth : 0f) - MeasureText(caption)) * 0.5f) + : ButtonTextIndent; + DrawLabel(ctx, caption, capX, (Height - LineH()) * 0.5f, TextColor); // G6: the open/closed arrow-cap overlay — see ArrowCapClosedSprite's doc comment. if (resolve is not null) DrawArrowCap(ctx, resolve); @@ -373,11 +440,21 @@ public sealed class UiMenu : UiElement int col = i / RowsPerColumn, row = i % RowsPerColumn; // Items grey out when unavailable; when EnabledProvider is null all items are enabled. bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true; - DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY, + DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + ItemTextX(Items[i].Label), + inY + row * RowHeight + textY, avail ? TextColorAvailable : TextColorGhosted); } } + /// A row label's X offset within its column — the authored row + /// template's own justification (see ). + private float ItemTextX(string label) => ItemTextCentered + ? MathF.Max(0f, (ColumnWidth - MeasureText(label)) * 0.5f) + : TextIndent; + + private float MeasureText(string s) + => DatFont?.MeasureWidth(s) ?? Font?.MeasureWidth(s) ?? s.Length * 7f; + /// /// G5: single-column popup with a docked scrollbar — port of the vendor category /// dropdown's authored shape (LayoutDesc 0x21000043, see 's @@ -397,7 +474,7 @@ public sealed class UiMenu : UiElement DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, ColumnWidth, InteriorH); int start = VisibleTopRow; - int count = System.Math.Min(RowsPerColumn, Items.Count - start); + int count = System.Math.Min(EffectiveVisibleRows, Items.Count - start); float textY = (RowHeight - LineH()) * 0.5f; for (int i = 0; i < count; i++) { @@ -411,7 +488,7 @@ public sealed class UiMenu : UiElement int idx = start + i; float y = inY + i * RowHeight; bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true; - DrawLabel(ctx, Items[idx].Label, inX + TextIndent, y + textY, + DrawLabel(ctx, Items[idx].Label, inX + ItemTextX(Items[idx].Label), y + textY, avail ? TextColorAvailable : TextColorGhosted); } @@ -425,7 +502,10 @@ public sealed class UiMenu : UiElement { int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight)); PopupScroll.LineHeight = lineHeight; - PopupScroll.SetExtents(Items.Count * lineHeight, RowsPerColumn * lineHeight); + // Size-to-content: view == content, so HasOverflow is false and the + // scrollbar draws its chrome with no thumb (retail's authored + // scrollbar sibling stretches with the docked popup the same way). + PopupScroll.SetExtents(Items.Count * lineHeight, EffectiveVisibleRows * lineHeight); } /// Index of the first visible row — nearest-row snap of the (possibly @@ -436,7 +516,7 @@ public sealed class UiMenu : UiElement get { int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight)); - int maxStart = System.Math.Max(0, Items.Count - RowsPerColumn); + int maxStart = System.Math.Max(0, Items.Count - EffectiveVisibleRows); int row = (int)MathF.Round((float)PopupScroll.ScrollY / lineHeight); return System.Math.Clamp(row, 0, maxStart); } @@ -581,6 +661,10 @@ public sealed class UiMenu : UiElement return true; } + // Retail Open @0x0046cc42 refuses an empty list (gates on + // m_listBox->m_listItems.m_num != 0) — a bare click on an itemless + // menu is a no-op rather than an empty popup. + if (!_open && Items.Count == 0) return true; SetOpen(!_open); // toggle on button click return true; } @@ -598,7 +682,7 @@ public sealed class UiMenu : UiElement { int row = (int)(iy / RowHeight); int idx = VisibleTopRow + row; - if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count + if (row >= 0 && row < EffectiveVisibleRows && idx >= 0 && idx < Items.Count && (EnabledProvider?.Invoke(Items[idx].Payload) ?? true)) { OnSelect?.Invoke(Items[idx].Payload); diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs index c6a6a68a..e134f2eb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs @@ -564,6 +564,49 @@ public sealed class ConfigOptionsPageControllerTests Assert.Equal(1, fakeBindings.AudioSaves[^1].SoundFeatures); } + /// User gate report 2026-08-13: every Config dropdown drew its + /// text gold + left-aligned and its popup a fixed 6 rows. The corrected + /// values are MEASURED authored facts (menuprobe3, + /// ): + /// label child 0x10000355 + row template 0x1000035A are white + + /// hJustify=Center, and popup ListBox 0x10000358 is edge-docked + /// (L=T=R=B=1), arming retail's RecalculatePopupSize size-to-content + /// path. Asserted on ALL 8 menus — one shared ApplyMenuChrome must not + /// quietly skip any. + [Fact] + public void MenuRows_All8_UseTheAuthoredTextStyleAndSizeToContent() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + var fakeBindings = new FakeBindings(); + Assert.True(ConfigOptionsPageController.Bind( + layout, + controller.ConfigPage, + MakeTemplateResolver(), + (_, _) => null, + fakeBindings.ToBindings(), + resolveSprite: _ => (1u, 8, 8))); + + var listBox = Assert.IsType( + layout.FindElement(ConfigOptionsPageController.ListBoxElementId)); + List menus = CollectMenus(listBox); + Assert.Equal(8, menus.Count); + foreach (UiMenu menu in menus) + { + Assert.Equal(System.Numerics.Vector4.One, menu.TextColor); + Assert.True(menu.ButtonTextCentered); + Assert.True(menu.ItemTextCentered); + Assert.True(menu.PopupSizeToContent); + } + } + [Fact] public void MenuRow_Resolution_IsStringBacked_AndWritesThroughDisplayBindings() { diff --git a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs index 3b58c08f..9ec226f7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs @@ -293,6 +293,149 @@ public sealed class OptionsPanelLiveMountProbeTests return null; } + /// User gate report 2026-08-13 (Campaign OP happy-testing round): + /// every Options-panel dropdown draws its text yellow + left-aligned (user: + /// retail is white + centered) and its popup a fixed 6 rows tall (user: + /// retail conforms to the item count). Retail mechanism, decomp-verified: + /// UIElement_Menu::RecalculatePopupSize @0x0046caf0 resizes the popup + /// to the ListBox's content extent (m_iScrollableHeight = SUM of laid + /// out row heights, uncapped — 0x0046e5f4..0046e66c) + the authored + /// popup-minus-ListBox border, but ONLY when the authored ListBox is docked + /// on both edges (m_leftEdge==1 && m_rightEdge==1 / + /// m_topEdge==1 && m_bottomEdge==1); the caption is an + /// authored UIElement_Text child named by menu attr 8 + /// (NewSelection @0x0046cd60) whose color/justify come from the + /// LayoutDesc, and Open @0x0046cc30 centers the POPUP over the + /// button when bool attr 3 is authored. This probe measures all of that + /// authored data for the Config option-menu chain (catalog 0x21000043, + /// base 0x10000353) with vendor's dropdown (0x1000034F chain — visibly a + /// FIXED 6-row + scrollbar popup in retail, user-gated during the vendor + /// campaign) as the contrast control. + [Fact] + public void ProbeMenuPopupSizingAndTextStyle() + { + if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1") + return; + + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDir, DatAccessType.Read); + + Console.WriteLine("[menuprobe3] === Config option-menu base 0x21000043/0x10000353 ==="); + ElementInfo? menuBase = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x10000353u); + if (menuBase is null) + { + Console.WriteLine("[menuprobe3] 0x10000353 FAILED to import"); + } + else + { + DumpMenuAttrs3(menuBase, "base 0x10000353", new[] { 2u, 3u, 5u, 6u, 7u, 8u, 9u }); + foreach (ElementInfo c in menuBase.Children) + DumpTextStyle(c, $"base child 0x{c.Id:X8}"); + } + + Console.WriteLine("[menuprobe3] === Config leaf 0x2100002B/0x10000224 (attrs 3+8) ==="); + ElementInfo? configRow = LayoutImporter.ImportInfos(dats, 0x2100002Bu, 0x10000222u); + if (configRow is not null && FindInfo(configRow, 0x10000224u) is { } leaf) + { + DumpMenuAttrs3(leaf, "leaf 0x10000224", new[] { 3u, 8u }); + foreach (ElementInfo c in leaf.Children) + DumpTextStyle(c, $"leaf child 0x{c.Id:X8}"); + } + else + { + Console.WriteLine("[menuprobe3] leaf 0x10000224 MISSING"); + } + + Console.WriteLine("[menuprobe3] === Config popup root/ListBox/row template ==="); + DumpDockAndSize(dats, 0x21000043u, 0x10000357u, "Config popup root"); + DumpDockAndSize(dats, 0x21000043u, 0x10000358u, "Config popup ListBox"); + ElementInfo? rowTemplate = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x1000035Au); + if (rowTemplate is not null) + { + DumpTextStyle(rowTemplate, "Config row template 0x1000035A"); + foreach (ElementInfo c in rowTemplate.Children) + DumpTextStyle(c, $"row-template child 0x{c.Id:X8}"); + } + else + { + Console.WriteLine("[menuprobe3] row template 0x1000035A FAILED to import"); + } + + Console.WriteLine("[menuprobe3] === CONTROL: vendor chain (fixed 6-row + scrollbar in retail) ==="); + DumpDockAndSize(dats, 0x21000043u, 0x1000034Fu, "Vendor popup root"); + DumpDockAndSize(dats, 0x21000043u, 0x10000350u, "Vendor popup ListBox"); + ElementInfo? vendorBase = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x1000034Bu); + if (vendorBase is not null) + { + DumpMenuAttrs3(vendorBase, "vendor base 0x1000034B", new[] { 2u, 3u, 5u, 6u, 7u, 8u, 9u }); + foreach (ElementInfo c in vendorBase.Children) + DumpTextStyle(c, $"vendor base child 0x{c.Id:X8}"); + } + ElementInfo? vendorRowTemplate = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x10000352u); + if (vendorRowTemplate is not null) + { + DumpTextStyle(vendorRowTemplate, "Vendor row template 0x10000352"); + foreach (ElementInfo c in vendorRowTemplate.Children) + DumpTextStyle(c, $"vendor row-template child 0x{c.Id:X8}"); + } + } + + private static void DumpMenuAttrs3(ElementInfo el, string label, uint[] attrs) + { + Console.WriteLine( + $"[menuprobe3] {label}: type=0x{el.Type:X8} ({el.X},{el.Y} {el.Width}x{el.Height}) " + + $"edges L={el.Left} T={el.Top} R={el.Right} B={el.Bottom} " + + $"children=[{string.Join(",", el.Children.ConvertAll(c => $"0x{c.Id:X8}"))}]"); + foreach (uint attr in attrs) + { + if (el.TryGetEffectiveProperty(attr, out UiPropertyValue value)) + { + string rendered = value.Kind switch + { + UiPropertyKind.Bool => value.BoolValue.ToString(), + UiPropertyKind.DataId or UiPropertyKind.Enum => $"0x{value.UnsignedValue:X8}", + UiPropertyKind.Integer => value.IntegerValue.ToString(), + _ => value.Kind.ToString(), + }; + Console.WriteLine($"[menuprobe3] attr[{attr}] kind={value.Kind} value={rendered}"); + } + else + { + Console.WriteLine($"[menuprobe3] attr[{attr}] MISSING"); + } + } + } + + private static void DumpDockAndSize(DatCollection dats, uint layoutId, uint elementId, string label) + { + ElementInfo? el = LayoutImporter.ImportInfos(dats, layoutId, elementId); + if (el is null) + { + Console.WriteLine($"[menuprobe3] {label} 0x{elementId:X8}: FAILED to import"); + return; + } + Console.WriteLine( + $"[menuprobe3] {label} 0x{elementId:X8}: type=0x{el.Type:X8} " + + $"({el.X},{el.Y} {el.Width}x{el.Height}) " + + $"edges L={el.Left} T={el.Top} R={el.Right} B={el.Bottom} " + + $"children=[{string.Join(",", el.Children.ConvertAll(c => $"0x{c.Id:X8}"))}]"); + } + + private static void DumpTextStyle(ElementInfo el, string label) + { + string color = el.FontColor is { } fc + ? $"({fc.X:F2},{fc.Y:F2},{fc.Z:F2},{fc.W:F2})" + : "null(default-white)"; + Console.WriteLine( + $"[menuprobe3] {label}: type=0x{el.Type:X8} ({el.X},{el.Y} {el.Width}x{el.Height}) " + + $"hJustify={el.HJustify} vJustify={el.VJustify} fontColor={color} fontDid=0x{el.FontDid:X8} " + + $"edges L={el.Left} T={el.Top} R={el.Right} B={el.Bottom}"); + } + /// #380 (gate 4): exercises the PRODUCTION /// mechanism against the real /// DAT and asserts the two resolved captions match the user's own diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs index 5b780753..2127ada4 100644 --- a/tests/AcDream.App.Tests/UI/UiMenuTests.cs +++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs @@ -489,4 +489,76 @@ public class UiMenuTests Assert.Equal(0f, menu.TextIndent); Assert.Equal(0f, menu.ButtonTextIndent); } + + // ------------------------------------------------------------------ + // User gate report 2026-08-13: fixed-6-row popups for short option + // lists. Retail (UIElement_Menu::RecalculatePopupSize @0x0046caf0) + // sizes the popup to the ListBox's summed content height, uncapped, + // when the authored ListBox is edge-docked — see + // UiMenu.PopupSizeToContent's own doc for the full mechanism. + // ------------------------------------------------------------------ + + private static UiMenu MakeScrollableMenu(int itemCount, bool sizeToContent) => new UiMenu + { + Width = 120f, Height = 18f, + Scrollable = true, + OpenUpward = false, + RowsPerColumn = 6, + RowHeight = 18f, + ColumnWidth = 100f, + PopupSizeToContent = sizeToContent, + Items = Enumerable.Range(0, itemCount) + .Select(i => new UiMenu.MenuItem($"Item {i}", (object?)i)).ToArray(), + }; + + [Fact] + public void SizeToContent_ShrinksThePopupToTheItemCount() + { + // 3 items: interior 3*18, plus the 5px bevel top+bottom. + Assert.Equal(3 * 18f + 10f, MakeScrollableMenu(3, sizeToContent: true).PopupOuterHeight); + // Control: without size-to-content the fixed 6-row window stands. + Assert.Equal(6 * 18f + 10f, MakeScrollableMenu(3, sizeToContent: false).PopupOuterHeight); + } + + [Fact] + public void SizeToContent_GrowsPastTheFixedWindow_AndTheLastRowIsPickable() + { + // Retail's content sum is UNCAPPED — 9 items = 9 rows, no scrolling. + UiMenu menu = MakeScrollableMenu(9, sizeToContent: true); + Assert.Equal(9 * 18f + 10f, menu.PopupOuterHeight); + + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + Assert.True(menu.IsOpen); + + object? fired = null; + menu.OnSelect = p => fired = p; + // Row 8 (the 9th item) sits past the old 6-row window: it must be + // directly pickable with NO scroll. Downward popup: interior starts + // at Height + border. + float ly = menu.Height + 5f + 8 * menu.RowHeight + menu.RowHeight / 2f; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, (int)ly))); + Assert.Equal(8, fired); + Assert.False(menu.IsOpen); // picking closes, same as every other path + } + + [Fact] + public void EmptyItems_ButtonClickDoesNotOpen() + { + // Retail UIElement_Menu::Open @0x0046cc42 gates on + // m_listBox->m_listItems.m_num != 0 — an itemless menu never opens. + UiMenu menu = MakeScrollableMenu(0, sizeToContent: true); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); + Assert.False(menu.IsOpen); + } + + [Fact] + public void TextStyleDefaults_PreserveChatAndVendorBehavior() + { + // The three 2026-08-13 additions are opt-in: chat's gold left-aligned + // caption and vendor's fixed 6-row window are untouched by default. + var menu = new UiMenu(); + Assert.False(menu.ButtonTextCentered); + Assert.False(menu.ItemTextCentered); + Assert.False(menu.PopupSizeToContent); + } }