From 3441a71833040746e84dca29d9ee32da7173eee9 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 11 Aug 2026 15:52:20 +0200 Subject: [PATCH] feat(ui): mark store-only option rows dimmed (user-directed, gate 2) User directive (gate 2, verbatim): "mark all options that are not implemented now, so I can clearly see what is not implemented." Store-only rows keep full interactivity (still persist/send) but render their caption in a shared dimmed grey (UiRenderContext.StoreOnlyCaptionColor, matching the existing UiMenu.TextColorGhosted convention) instead of white/DAT color. No invented marker text anywhere -- the dim IS the marker. Config tab (ConfigOptionsPageController, 21 of 27 rows dimmed): Sound Features menu, Interface Sound trio, Play Sound Only When Active (AP-199); Screen Brightness, Automatic Degrades, Graphics Performance, Degrade Distance, the four Rendering Quality menus, Building Detail Textures, Multi-Pass Alpha (AP-198); Camera Stiffness, Camera Adjustment Speed, Align To Slope, Mouse Look Sensitivity, Invert Mouselook Y Axis, Use Mouse Turning (TS-74); Chat Font Face/Size (AP-200). NOT dimmed: Sound/Ambient trios, Resolution, Full Screen (LIVE), VSync and Field of View (NEXT-LAUNCH -- still implemented, just deferred to next process start, per the controller's own doc). Character tab (CharacterOptionsPageController, 35 of 50 rows dimmed): every Group A (wire+store only) and Group D (deferred) row, plus the Group B rows the OP4 gate script's own step 16 confirms are unbound (ShowTooltips, SideBySideVitals, SpellDuration, AdvancedCombatUI, StayInChatMode, DisableMostWeatherEffects, PersistentAtDay, FilterLanguage, MainPackPreferred). NOT dimmed (15 rows): the six ListenTo*Chat ids (TurbineChatMembershipGate), DisableDistanceFog/ DisplayTimeStamps/ToggleRun (bound at GameWindow.cs), the Group-C re-point (ViewCombatTarget/VividTargetingIndicator/CoordinatesOnRadar/ AutoTarget/AutoRepeatAttack), and DragItemOnPlayerOpensSecureTrade (TS-48). Cross-checked against actual shipped consumers via source grep, not just the research doc's Group table, since OP4 only wired a subset of the doc's aspirational Group B. Configure Keyboard (KeyboardConfigController): a row whose RetailActionIdentityTable lookup fails (MappedAction null -- AP-203's Emote/CharacterSettings set) dims its synthesized caption; the key buttons stay fully bindable/persisted/conflict-checked. Chat tab (ChatOptionsPageController): audited, zero store-only rows -- every filter block and both opacity sliders already have a live consumer (ChatWindowState / RetailWindowOpacityController). Ambiguity flagged, not guessed: the character-options-map.md research doc lists AcceptLootPermits in BOTH Group A and Group C; its only code site (LiveSessionRuntimeFactory.cs, the /consent command) is a second setter for the same server bit, not a behavioral reader, so it is classified Group A / dimmed here. Register: AD-78 documents the convention (retail dims nothing; this is a deliberate acdream-only divergence that retires as consumers land). New per-surface conformance tests pin the exact dimmed/live set against a literal expected list, so wiring a future consumer without also flipping its row's literal fails the build: CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly + Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows, ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly, KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite. Build green; full Release suite 13,086 passed / 4 skipped / 0 failed (baseline 13,082/4/0 -- delta is exactly the four new tests above). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- .../2026-08-11-campaign-op-test-script.md | 12 ++ .../Layout/CharacterOptionsPageController.cs | 183 ++++++++++++------ .../UI/Layout/ChatOptionsPageController.cs | 12 ++ .../UI/Layout/ConfigOptionsPageController.cs | 128 ++++++++---- .../UI/Layout/KeyboardConfigController.cs | 26 ++- src/AcDream.App/UI/UiRenderContext.cs | 17 ++ .../CharacterOptionsPageControllerTests.cs | 124 ++++++++++++ .../ConfigOptionsPageControllerTests.cs | 105 ++++++++++ .../Layout/KeyboardConfigControllerTests.cs | 46 +++++ 10 files changed, 563 insertions(+), 93 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a0cd46ad..3a239534 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) — 57 active rows (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) — 58 active rows (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 @@ -172,6 +172,7 @@ readiness/requeue adaptation. See | AD-75 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** Urgent Assistance (`0x10000206`) and Report Abuse (`0x10000207`) never call `ShellExecuteA` against `http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic` — the endpoint is dead in 2026. Each button instead ALWAYS emits its own byte-verified retail failure body (the `ShellExecuteA`-failure `MessageBoxA` text, `(Error code %d)` dropped since no real Win32 error ever occurs, the URL kept verbatim) through the interface-text seam (`RetailLogTextType.ClientLocal`) instead of a native `MessageBoxA` popup. | `src/AcDream.Core/Chat/OptionsPanelText.cs` (`UrgentAssistanceUnavailable`/`ReportAbuseUnavailable`); `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring) | The URL genuinely does not resolve to a live Turbine support endpoint; attempting `ShellExecuteA` would open a browser to a dead page rather than usefully fail. The retained failure TEXT is retail's own (byte-verified), just always shown instead of conditionally on a real launch failure, and routed to acdream's existing interface-text channel rather than a modal OS dialog (retail's own EoR-era mechanism has no acdream analogue for a one-off native `MessageBoxA`). | If Turbine ever revives the endpoint, both buttons would still short-circuit instead of opening it — a silent staleness, not a crash. | `gmGameplayOptionsUI::ListenToElementMessage @0x0049E110`; `ShellExecuteA` call sites `0x0049E154`/`0x0049E1F0`; research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.1/§4.2 | | AD-76 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** In-Game Help Files (`0x10000205`) is authored and clickable but has no handler — clicking it does nothing visible. | `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring — no callback bound) | Retail's own `KeyStone::OpenHelp` loads a third-party embedded help viewer (`plugins\ACHelpPlugin.dll` via `keystone.dll`) that acdream does not have and cannot port (no DAT-resident help content, no source). Retail ITSELF fails silently with the plugin absent (`KeyStone::m_fnAC2HelpPluginExecute` unresolved) — mirroring that as an inert button is the faithful behavior for "the asset is missing", not an invented stub screen. | A user clicking In-Game Help Files gets no feedback at all, same as retail with the plugin missing — indistinguishable from a dead button unless they already expect the asset-missing case. | `KeyStone::OpenHelp @0x00557010`; `KeyStone::Init @0x00556CF0` (the unresolved plugin function pointer); research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.5 | | AD-77 | **Filed 2026-08-11 at the Campaign OP OP3 review-fix round (dual-review S4/MUST-FIX 2 — the plan's §5 "out of scope" list explicitly delegated this ruling to the OP3 review).** Retail exposes TWO `gmPanelUI` host variants for the same panel stack — a floating host (`0x2100006E`, `gmFloatyPanelUI`) and a docked host (`0x21000017`) — so a retail user can dock the Options panel (and every other `gmPanelUI` sibling) into a fixed screen position instead of leaving it freely floating. acdream mounts every main panel through `RetailWindowFrame.Mount` + `RetailPanelUiController.RegisterMainPanel` against the floating host ONLY; no code path resolves or mounts `0x21000017` at all. | `src/AcDream.App/UI/RetailUiRuntime.cs` (every `Mount*`/`RegisterMainPanel` call site for a `gmPanelUI` sibling — Character/Inventory/Spellbook/Effects/the four indicator-detail panels/Options); `src/AcDream.App/UI/Layout/RetailWindowFrame.cs` | This predates OP3 — every `gmPanelUI` sibling has shipped floating-only since its own slice landed; OP3 did not introduce the gap, it just added a tenth panel to an already-floating-only cohort. The plan explicitly scoped filing the row to "whichever slice's review deems it a divergence" rather than blocking any one panel's slice on building a docked-host variant no prior panel has either. | A user who expects to dock the Options panel (or any other main panel) the way retail allows cannot — every `gmPanelUI` sibling is floating-only in acdream, client-wide, not an Options-specific gap. | research doc `2026-08-10-options-panel-structure.md` §10.1 (docked/floating host pair); `docs/plans/2026-08-10-options-panel-campaign.md` §5 | +| AD-78 | **Filed 2026-08-11, user-directed (verbatim: "mark all options that are not implemented now, so I can clearly see what is not implemented"), gate 2 of Campaign OP's follow-up.** Retail dims nothing on any Options-panel row or Configure-Keyboard action row — every retail row drives its own real consumer by construction, so retail has no "does this actually do anything" ambiguity to signal. acdream, by contrast, ships a large honest store-only set (AP-198/AP-199/AP-200/AP-203, TS-73/TS-74/TS-75/TS-76/TS-77/TS-78/TS-79/TS-80, and the Character-tab Group A/D rows) that persist and, where auto-save, send the wire bit, but drive nothing observable client-side. Per explicit user direction, every such row's CAPTION now renders in a shared neutral grey (`UiRenderContext.StoreOnlyCaptionColor`, `(0.5,0.5,0.5,1)` — the SAME value the existing disabled/ghosted convention already used, `UiMenu.TextColorGhosted`) instead of its normal white/DAT-authored color, while the row itself stays fully interactive (click/drag/persist exactly as before — only the caption's paint color changes). No invented marker text is added anywhere (the project's "no user-visible strings outside the DAT" rule stands); the dim IS the marker. | `src/AcDream.App/UI/UiRenderContext.cs` (`StoreOnlyCaptionColor`, the one shared constant); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (21 of 27 rows dimmed — `ApplyLabelAndTooltip`/`SetLabelText`'s `storeOnly` parameter, threaded from each `BindXxxSection` call site); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (35 of 50 rows dimmed — `RowSpec.StoreOnly`, derived per-row in the class doc's table, cross-checked against actual shipped consumers rather than the research doc alone); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`BuildActionRow` dims a row when `RetailActionIdentityTable.TryResolve` fails, i.e. `MappedAction` is null — AP-203's set); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (audited, zero dimmed rows — every row already has a live consumer). | Explicit, unambiguous user direction (this session, gate 2) overriding the earlier per-slice register rows' silence on presentation; the four controllers' own conformance tests (`ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly`, `CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly` + `Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`, `KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite`) pin the exact dimmed set so a future consumer landing without also flipping its row's literal fails the build, not just the eye. | A reviewer comparing a byte-exact retail screenshot to acdream will see caption colors retail never has — this row exists precisely so that divergence is understood as intentional, not a bug. If a row's dim/live classification in the four cited tables ever drifts from its ACTUAL consumer state (a landed consumer whose row was never un-dimmed, or a regressed consumer whose row was never re-dimmed), the caption becomes misleading in the OPPOSITE direction it was built to prevent — treat any report of "this dimmed row visibly does something" or "this live-looking row does nothing" as a real defect, not a rendering nit (see the gate script's own note). This row retires only when acdream reaches full retail parity (zero store-only rows remaining), at which point the convention itself — not just its content — should be deleted. | None (acdream-only divergence; retail has no store-only rows to compare against) — `docs/research/2026-08-10-character-options-map.md` §7.1 (Group A/B/C/D split); `docs/research/2026-08-11-campaign-op-test-script.md` (per-tab store-only enumerations this row's dimmed set matches) | --- 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 af8b95ba..f608a397 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,18 @@ 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. +**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 +enumeration) renders its caption in a dimmed neutral grey instead of the +normal white/DAT-authored color — the Character tab, the Config tab, and +Configure Keyboard's unmapped rows. A dimmed caption = stored (and +wire-synced where the row is auto-save) but drives nothing yet — this is +CONTRACTED, not a bug; do not report it. What IS worth reporting: a row this +script or AD-78 lists as LIVE that renders dimmed (a wiring regression), or a +row listed as store-only/dimmed that visibly DOES something (its "no +consumer" premise going stale without its caption catching up). + This document is the script the user runs against the live connected client (`ACDREAM_LIVE=1` against the local ACE server) to accept each slice. Each item states what to do and what retail-faithful behavior to diff --git a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs index 098e76a0..8bc483cd 100644 --- a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Numerics; using AcDream.App.UI; using AcDream.Core.Net.Messages; using AcDream.Runtime.Gameplay; @@ -82,95 +83,160 @@ public static class CharacterOptionsPageController private const uint StringTableId = 0x23000003u; - /// One authored Character-tab row: the wire/storage id plus - /// retail's OWN PlayerOption enum member name (for the + /// One authored Character-tab row: the wire/storage id, retail's + /// OWN PlayerOption enum member name (for the /// ID_PlayerOption_<Name>/_Help string hash — see - /// the U2 note above). - public readonly record struct RowSpec(CharacterOptionId Id, string RetailName); + /// the U2 note above), and (AD-78, + /// user-directed, 2026-08-11, gate 2 — see the derivation table below + /// this record). + public readonly record struct RowSpec(CharacterOptionId Id, string RetailName, bool StoreOnly); /// One authored header group: its section string key plus /// authored-order rows. public readonly record struct GroupSpec(string HeaderKey, RowSpec[] Rows); + /// + /// AD-78 caption-dimming derivation (user-directed, 2026-08-11, gate 2). + /// below is derived from + /// docs/research/2026-08-10-character-options-map.md §2/§7.1's + /// four-group split (A = wire+store only, no client consumer needed; + /// B = needs a client consumer; C = already live, re-pointed to the + /// server bit; D = deferred with a register row), CROSS-CHECKED against + /// the actual shipped code (a source grep for every + /// CharacterOptionId.* reference outside + /// CharacterOptionTable/this controller/the wire codec) because + /// the research doc predates OP1/OP4's implementation and Group B was + /// only PARTIALLY wired (confirmed against + /// docs/research/2026-08-11-campaign-op-test-script.md §OP4 + /// steps 13-18, written after the code landed): + /// + /// + /// NOT dimmed (15 rows, real acdream-side + /// consumer): Group B rows actually bound — + /// ViewCombatTarget/AutoTarget/AutoRepeatAttack + /// (combat: LiveCombatAttackOperations.cs, + /// CombatUiController.cs — Group C re-point), + /// VividTargetingIndicator (InteractionRetainedUiComposition.cs:696 + /// — Group C), CoordinatesOnRadar + /// (LivePresentationComposition.cs:845 — Group C), + /// DisableDistanceFog/DisplayTimeStamps/ToggleRun + /// (Group B, polled at GameWindow.cs:655-680), + /// DragItemOnPlayerOpensSecureTrade (TS-48, + /// InteractionRetainedUiComposition.cs:326), and all six + /// ListenTo*Chat ids (TurbineChatMembershipGate.cs:105-136 + /// gates every Turbine room join on the matching bit). + /// Dimmed (35 rows, store-only): every + /// remaining Group A row (wire+store only — ACE, not acdream, is the + /// consumer) and every remaining Group B row the OP4 gate script's own + /// step 16 lists as "no consumer surface" (ShowTooltips, + /// SideBySideVitals, SpellDuration, + /// AdvancedCombatUI, StayInChatMode, + /// DisableMostWeatherEffects, PersistentAtDay, + /// FilterLanguage, MainPackPreferred), plus every Group D + /// deferral (SalvageMultiple, + /// DisableHouseRestrictionEffects, FellowshipShareXP, + /// HearPkDeathMessages). + /// Flagged ambiguity, resolved by code evidence + /// (see final report, not re-litigated here): the research doc's + /// §7.1 lists AcceptLootPermits in BOTH Group A's bullet list AND + /// Group C's bullet list. The actual consumer + /// (LiveSessionRuntimeFactory.cs:497-502, the /consent + /// slash command) is a second SETTER surface for the same server bit, + /// not a behavioral reader — ACE alone decides whether to honor corpse + /// looting (Player_Death.cs:755). Classified Group A / dimmed. + /// IgnoreFellowshipRequests/FellowshipAutoAcceptRequests + /// have a genuine coded mutual-exclusion side effect + /// (RuntimeCharacterState.TrySetOption, MF-2) that the research + /// doc's Group A classification does not capture (the doc predates + /// OP1's review-fix round) — kept dimmed per the doc's explicit Group A + /// listing and this task's instruction to treat the doc as the sole + /// authority rather than infer from code comments; a future un-dim is a + /// one-line judgment call, not a re-investigation. + /// + /// + private const bool Live = false; + private const bool StoreOnly = true; + /// /// The complete 6-group / 50-row authored table — research doc §2 / /// §7, id-for-id, in EXACT authored order. RetailName values /// verified against acclient.h:4162-4218's literal enumerator - /// spelling. + /// spelling. See the derivation table above for the + /// StoreOnly column's sourcing. /// public static readonly GroupSpec[] Groups = { new("ID_CharacterOption_UIBehavior_Section", new RowSpec[] { - new(CharacterOptionId.ViewCombatTarget, "ViewCombatTarget"), - new(CharacterOptionId.SalvageMultiple, "SalvageMultiple"), - new(CharacterOptionId.MainPackPreferred, "MainPackPreferred"), + new(CharacterOptionId.ViewCombatTarget, "ViewCombatTarget", Live), // Group C + new(CharacterOptionId.SalvageMultiple, "SalvageMultiple", StoreOnly), // Group D + new(CharacterOptionId.MainPackPreferred, "MainPackPreferred", StoreOnly), // Group B, unbound }), new("ID_CharacterOption_UIDisplay_Section", new RowSpec[] { - new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator"), - new(CharacterOptionId.ShowTooltips, "ShowTooltips"), - new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar"), - new(CharacterOptionId.SideBySideVitals, "SideBySideVitals"), - new(CharacterOptionId.SpellDuration, "SpellDuration"), - new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects"), - new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog"), - new(CharacterOptionId.PersistentAtDay, "PersistentAtDay"), - new(CharacterOptionId.DisableHouseRestrictionEffects, "DisableHouseRestrictionEffects"), - new(CharacterOptionId.UseCraftSuccessDialog, "UseCraftSuccessDialog"), - new(CharacterOptionId.ConfirmVolatileRareUse, "ConfirmVolatileRareUse"), - new(CharacterOptionId.DisplayTimeStamps, "DisplayTimeStamps"), - new(CharacterOptionId.FilterLanguage, "FilterLanguage"), - new(CharacterOptionId.ShowHelm, "ShowHelm"), - new(CharacterOptionId.ShowCloak, "ShowCloak"), + new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator", Live), // Group C + new(CharacterOptionId.ShowTooltips, "ShowTooltips", StoreOnly), // Group B, unbound + new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar", Live), // Group C + new(CharacterOptionId.SideBySideVitals, "SideBySideVitals", StoreOnly), // Group B, unbound + new(CharacterOptionId.SpellDuration, "SpellDuration", StoreOnly), // Group B, unbound + new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects", StoreOnly), // Group B, unbound + new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog", Live), // Group B, bound (GameWindow.cs:657) + new(CharacterOptionId.PersistentAtDay, "PersistentAtDay", StoreOnly), // Group B, unbound + new(CharacterOptionId.DisableHouseRestrictionEffects, "DisableHouseRestrictionEffects", StoreOnly), // Group D + new(CharacterOptionId.UseCraftSuccessDialog, "UseCraftSuccessDialog", StoreOnly), // Group A + new(CharacterOptionId.ConfirmVolatileRareUse, "ConfirmVolatileRareUse", StoreOnly), // Group A + new(CharacterOptionId.DisplayTimeStamps, "DisplayTimeStamps", Live), // Group B, bound (GameWindow.cs:664) + new(CharacterOptionId.FilterLanguage, "FilterLanguage", StoreOnly), // Group B, unbound + new(CharacterOptionId.ShowHelm, "ShowHelm", StoreOnly), // Group A + new(CharacterOptionId.ShowCloak, "ShowCloak", StoreOnly), // Group A }), new("ID_CharacterOption_Grouping_Section", new RowSpec[] { - new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests"), - new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests"), - new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications"), - new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP"), - new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot"), - new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests"), + new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A + new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", StoreOnly), // Group A (see ambiguity note) + new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A + new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", StoreOnly), // Group D + new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot", StoreOnly), // Group A + new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests", StoreOnly), // Group A (see ambiguity note) }), new("ID_CharacterOption_OtherPlayers_Section", new RowSpec[] { - new(CharacterOptionId.AcceptLootPermits, "AcceptLootPermits"), - new(CharacterOptionId.UseDeception, "UseDeception"), - new(CharacterOptionId.AllowGive, "AllowGive"), - new(CharacterOptionId.IgnoreTradeRequests, "IgnoreTradeRequests"), - new(CharacterOptionId.DragItemOnPlayerOpensSecureTrade, "DragItemOnPlayerOpensSecureTrade"), - new(CharacterOptionId.DisplayDateOfBirth, "DisplayDateOfBirth"), - new(CharacterOptionId.DisplayAge, "DisplayAge"), - new(CharacterOptionId.DisplayChessRank, "DisplayChessRank"), - new(CharacterOptionId.DisplayFishingSkill, "DisplayFishingSkill"), - new(CharacterOptionId.DisplayNumberDeaths, "DisplayNumberDeaths"), - new(CharacterOptionId.DisplayNumberCharacterTitles, "DisplayNumberCharacterTitles"), + new(CharacterOptionId.AcceptLootPermits, "AcceptLootPermits", StoreOnly), // Group A (see ambiguity note) + new(CharacterOptionId.UseDeception, "UseDeception", StoreOnly), // Group A + new(CharacterOptionId.AllowGive, "AllowGive", StoreOnly), // Group A + new(CharacterOptionId.IgnoreTradeRequests, "IgnoreTradeRequests", StoreOnly), // Group A + new(CharacterOptionId.DragItemOnPlayerOpensSecureTrade, "DragItemOnPlayerOpensSecureTrade", Live), // TS-48, live gate + new(CharacterOptionId.DisplayDateOfBirth, "DisplayDateOfBirth", StoreOnly), // Group A + new(CharacterOptionId.DisplayAge, "DisplayAge", StoreOnly), // Group A + new(CharacterOptionId.DisplayChessRank, "DisplayChessRank", StoreOnly), // Group A + new(CharacterOptionId.DisplayFishingSkill, "DisplayFishingSkill", StoreOnly), // Group A + new(CharacterOptionId.DisplayNumberDeaths, "DisplayNumberDeaths", StoreOnly), // Group A + new(CharacterOptionId.DisplayNumberCharacterTitles, "DisplayNumberCharacterTitles", StoreOnly), // Group A }), new("ID_CharacterOption_CharacterBehavior_Section", new RowSpec[] { - new(CharacterOptionId.ToggleRun, "ToggleRun"), - new(CharacterOptionId.AdvancedCombatUI, "AdvancedCombatUI"), - new(CharacterOptionId.AutoTarget, "AutoTarget"), - new(CharacterOptionId.AutoRepeatAttack, "AutoRepeatAttack"), - new(CharacterOptionId.UseChargeAttack, "UseChargeAttack"), - new(CharacterOptionId.LeadMissileTargets, "LeadMissileTargets"), - new(CharacterOptionId.UseFastMissiles, "UseFastMissiles"), + new(CharacterOptionId.ToggleRun, "ToggleRun", Live), // Group B, bound (GameWindow.cs:680) + new(CharacterOptionId.AdvancedCombatUI, "AdvancedCombatUI", StoreOnly), // Group B, unbound + new(CharacterOptionId.AutoTarget, "AutoTarget", Live), // Group C + new(CharacterOptionId.AutoRepeatAttack, "AutoRepeatAttack", Live), // Group C + new(CharacterOptionId.UseChargeAttack, "UseChargeAttack", StoreOnly), // Group A + new(CharacterOptionId.LeadMissileTargets, "LeadMissileTargets", StoreOnly), // Group A + new(CharacterOptionId.UseFastMissiles, "UseFastMissiles", StoreOnly), // Group A }), new("ID_CharacterOption_Chat_Section", new RowSpec[] { - new(CharacterOptionId.StayInChatMode, "StayInChatMode"), - new(CharacterOptionId.ListenToAllegianceChat, "HearAllegianceChat"), - new(CharacterOptionId.ListenToGeneralChat, "HearGeneralChat"), - new(CharacterOptionId.ListenToTradeChat, "HearTradeChat"), - new(CharacterOptionId.ListenToLFGChat, "HearLFGChat"), - new(CharacterOptionId.ListenToRoleplayChat, "HearRoleplayChat"), - new(CharacterOptionId.ListenToSocietyChat, "HearSocietyChat"), + new(CharacterOptionId.StayInChatMode, "StayInChatMode", StoreOnly), // Group B, unbound + new(CharacterOptionId.ListenToAllegianceChat, "HearAllegianceChat", Live), // TurbineChatMembershipGate.cs:107-110 + new(CharacterOptionId.ListenToGeneralChat, "HearGeneralChat", Live), // TurbineChatMembershipGate.cs:111-114 + new(CharacterOptionId.ListenToTradeChat, "HearTradeChat", Live), // TurbineChatMembershipGate.cs:115-118 + new(CharacterOptionId.ListenToLFGChat, "HearLFGChat", Live), // TurbineChatMembershipGate.cs:119-122 + new(CharacterOptionId.ListenToRoleplayChat, "HearRoleplayChat", Live), // TurbineChatMembershipGate.cs:123-126 + new(CharacterOptionId.ListenToSocietyChat, "HearSocietyChat", Live), // TurbineChatMembershipGate.cs:127-130 // D3: the 50th row. Not in the 2013 build (register row // AP-193 covers the ACE-sourced id/mask); the DAT string // 0x0D16E9A3 ("Listen to PK death messages.") IS present, so // it renders exactly like every other row — wire+store only. - new(CharacterOptionId.HearPkDeathMessages, "HearPKDeaths"), + new(CharacterOptionId.HearPkDeathMessages, "HearPKDeaths", StoreOnly), // Group D }), }; @@ -349,6 +415,13 @@ public static class CharacterOptionsPageController $"[D.2b] CharacterOptionsPageController: label '{labelKey}' did not resolve — " + "row renders with no caption rather than invented English."); + // AD-78 (user-directed, 2026-08-11, gate 2): store-only rows keep + // full interactivity — only the caption dims, per the derivation + // table above Groups. + checkbox.LabelColor = spec.StoreOnly + ? UiRenderContext.StoreOnlyCaptionColor + : Vector4.One; + string? tooltip = resolveString( StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help")); if (tooltip is not null) diff --git a/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs b/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs index 6037f363..81c09296 100644 --- a/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs @@ -47,6 +47,18 @@ namespace AcDream.App.UI.Layout; /// (Campaign CH slice CH6b). carries the /// mapping so nothing downstream needs to know retail's raw numbering. /// +/// +/// +/// No store-only rows (AD-78 audit, user-directed, 2026-08-11, gate 2). +/// Every row on this tab has a live acdream consumer — the two opacity +/// sliders drive RetailWindowOpacityController (AP-190) through +/// /, +/// and all five filter blocks write directly, +/// the SAME state CH6's floating chat windows read to decide what to show. +/// Unlike the Character/Config tabs and Configure Keyboard, this controller +/// has no caption-dimming to apply; verified by inspection rather than +/// skipped. +/// /// public static class ChatOptionsPageController { diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index 6295796d..1a27fdf7 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Numerics; using AcDream.App.UI; using AcDream.UI.Abstractions.Panels.Settings; @@ -130,6 +131,24 @@ namespace AcDream.App.UI.Layout; /// 0x004039ed, NOT a per-machine runtime enumeration as the /// rejected slice's comment claimed). /// +/// +/// +/// Caption dimming (AD-78, user-directed, 2026-08-11, gate 2). Every +/// STORE-ONLY row above (the 21 rows named in the paragraph before this one +/// -- AP-198's ten, AP-199's three, and TS-74's six Camera/Input rows plus +/// AP-200's two Chat-font rows) renders its caption in +/// instead of the normal +/// white/DAT-authored color. The row stays fully interactive -- it still +/// persists -- the dim is ONLY a visual marker so the four-tab panel makes +/// "does this actually do anything yet" legible at a glance, per the +/// campaign's gate-2 directive. LIVE rows (Sound/Ambient trios, Resolution, +/// Full Screen) and NEXT-LAUNCH rows (Sync To Refresh, Field of View -- they +/// DO apply, just not until the next process start) are NOT dimmed. Threaded +/// through every row builder below as an explicit storeOnly parameter +/// at each of the 27 call sites, so wiring a future consumer means flipping +/// that literal to -- the matching conformance test +/// then fails until the change is made consciously. +/// /// public static class ConfigOptionsPageController { @@ -317,7 +336,8 @@ public static class ConfigOptionsPageController AudioSettings updated = bindings.LoadAudio() with { SoundFeatures = value }; bindings.SaveAudio(updated); }, - defaultValue: 0); + defaultValue: 0, + storeOnly: true); // AP-199 // OP6 rework (2026-08-11, review M2): read/apply the ENABLED-sense // fields directly — toggleDefault stays `true` because retail's own @@ -339,7 +359,8 @@ public static class ConfigOptionsPageController toggleRead: () => bindings.LoadAudio().SfxEnabled, toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { SfxEnabled = value }), sliderRead: () => bindings.LoadAudio().Sfx, - sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Sfx = value })); + sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Sfx = value }), + storeOnly: false); // LIVE BuildTrioRow( listBox, "ID_Sound_DisableAmbientSound", sliderTooltipKey: "ID_Sound_AmbientVolume", @@ -349,7 +370,8 @@ public static class ConfigOptionsPageController toggleRead: () => bindings.LoadAudio().AmbientEnabled, toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { AmbientEnabled = value }), sliderRead: () => bindings.LoadAudio().Ambient, - sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Ambient = value })); + sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Ambient = value }), + storeOnly: false); // LIVE // Interface Sound: retail's own dead knob (AP-174 — "interface // sounds are scaled by the EFFECT knob"; registered and never @@ -363,12 +385,14 @@ public static class ConfigOptionsPageController toggleRead: () => bindings.LoadAudio().InterfaceEnabled, toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceEnabled = value }), sliderRead: () => bindings.LoadAudio().InterfaceVolume, - sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceVolume = value })); + sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceVolume = value }), + storeOnly: true); // AP-174 / AP-199 BuildToggleRow( listBox, "ID_Sound_NoFocusNoSound", defaultValue: true, page, resolveString, read: () => bindings.LoadAudio().PlaySoundOnlyWhenActive, - apply: value => bindings.SaveAudio(bindings.LoadAudio() with { PlaySoundOnlyWhenActive = value })); + apply: value => bindings.SaveAudio(bindings.LoadAudio() with { PlaySoundOnlyWhenActive = value }), + storeOnly: true); // AP-199 audio = bindings.LoadAudio(); } @@ -393,6 +417,7 @@ public static class ConfigOptionsPageController min: 0.285714298f, max: 1f, defaultValue: 0.45f, page, resolveString, read: () => bindings.LoadCameraTurning().Stiffness, apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { Stiffness = value }), + storeOnly: true, // TS-74 rangeLowKey: "ID_Graphics_Value_Soft", rangeHighKey: "ID_Graphics_Value_Hard"); BuildSliderRow( @@ -400,6 +425,7 @@ public static class ConfigOptionsPageController min: 5f, max: 80f, defaultValue: 40.0f, page, resolveString, read: () => bindings.LoadCameraTurning().AdjustmentSpeed, apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AdjustmentSpeed = value }), + storeOnly: true, // TS-74 rangeLowKey: "ID_Graphics_Value_Slow", rangeHighKey: "ID_Graphics_Value_Fast"); // Field of View: NEXT-LAUNCH via DisplaySettings.FieldOfView + the @@ -411,12 +437,14 @@ public static class ConfigOptionsPageController min: 10f, max: 160f, defaultValue: 90.0f, page, resolveString, read: () => bindings.LoadDisplay().FieldOfView, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { FieldOfView = value }), + storeOnly: false, // NEXT-LAUNCH, not store-only rangeLowKey: "ID_Graphics_Value_Narrow", rangeHighKey: "ID_Graphics_Value_Wide"); BuildToggleRow( listBox, "ID_Camera_AlignToSlope", defaultValue: true, page, resolveString, read: () => bindings.LoadCameraTurning().AlignToSlope, - apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AlignToSlope = value })); + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AlignToSlope = value }), + storeOnly: true); // TS-74 cameraTurning = bindings.LoadCameraTurning(); } @@ -455,19 +483,22 @@ public static class ConfigOptionsPageController DisplaySettings.AvailableResolutions, page, resolveString, read: () => bindings.LoadDisplay().Resolution, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Resolution = value }), - defaultValue: "800x600"); + defaultValue: "800x600", + storeOnly: false); // LIVE BuildToggleRow( listBox, "ID_Rendering_FullScreen", defaultValue: true, page, resolveString, read: () => bindings.LoadDisplay().Fullscreen, - apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Fullscreen = value })); + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Fullscreen = value }), + storeOnly: false); // LIVE // Sync To Refresh: NEXT-LAUNCH (same DisplaySettings.VSync // pre-existing precedent as FieldOfView above). BuildToggleRow( listBox, "ID_Rendering_SyncToDisplayRefresh", defaultValue: false, page, resolveString, read: () => bindings.LoadDisplay().VSync, - apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { VSync = value })); + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { VSync = value }), + storeOnly: false); // NEXT-LAUNCH, not store-only // Screen Brightness: OP6 rework (2026-08-11, review S2) — its OWN // DisplaySettings.ScreenBrightness field ([-1,1], default 0), NOT @@ -479,18 +510,21 @@ public static class ConfigOptionsPageController min: -1f, max: 1f, defaultValue: 0f, page, resolveString, read: () => bindings.LoadDisplay().ScreenBrightness, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { ScreenBrightness = value }), + storeOnly: true, // review S2 rangeLowKey: "ID_Graphics_Value_Dark", rangeHighKey: "ID_Graphics_Value_Bright"); BuildToggleRow( listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString, read: () => bindings.LoadDisplay().AutomaticDegrades, - apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value })); + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value }), + storeOnly: true); // AP-198 BuildSliderRow( listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias", min: -1f, max: 1f, defaultValue: 0f, page, resolveString, read: () => bindings.LoadDisplay().GraphicsPerformance, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }), + storeOnly: true, // AP-198 rangeLowKey: "ID_Graphics_Value_Speed", rangeHighKey: "ID_Graphics_Value_Detail"); BuildSliderRow( @@ -498,6 +532,7 @@ public static class ConfigOptionsPageController min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString, read: () => bindings.LoadDisplay().DegradeDistance, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }), + storeOnly: true, // AP-198 rangeLowKey: "ID_Graphics_Value_Close", rangeHighKey: "ID_Graphics_Value_Far"); display = bindings.LoadDisplay(); @@ -539,19 +574,22 @@ public static class ConfigOptionsPageController listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString, read: () => bindings.LoadDisplay().LandscapeTextureDetail, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeTextureDetail = value }), - defaultValue: 2); + defaultValue: 2, + storeOnly: true); // AP-198 BuildMenuRow( listBox, "ID_Graphics_EnvironmentTextureDetail", TextureDetailChoices, page, resolveString, read: () => bindings.LoadDisplay().EnvironmentTextureDetail, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { EnvironmentTextureDetail = value }), - defaultValue: 1); + defaultValue: 1, + storeOnly: true); // AP-198 BuildMenuRow( listBox, "ID_Graphics_TextureFiltering", TextureFilteringChoices, page, resolveString, read: () => bindings.LoadDisplay().TextureFiltering, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { TextureFiltering = value }), - defaultValue: 1); + defaultValue: 1, + storeOnly: true); // AP-198 // UNRESOLVED (see class doc / register row): retail's own // SetDefaultValue(8) does not index this 6-entry choice array. @@ -561,17 +599,20 @@ public static class ConfigOptionsPageController listBox, "ID_Graphics_LandscapeDrawDistance", LandscapeDrawDistanceChoices, page, resolveString, read: () => bindings.LoadDisplay().LandscapeDrawDistance, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }), - defaultValue: 8); + defaultValue: 8, + storeOnly: true); // AP-198 BuildToggleRow( listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString, read: () => bindings.LoadDisplay().BuildingDetailTextures, - apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value })); + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }), + storeOnly: true); // AP-198 BuildToggleRow( listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString, read: () => bindings.LoadDisplay().MultiPassAlpha, - apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { MultiPassAlpha = value })); + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { MultiPassAlpha = value }), + storeOnly: true); // AP-198 display = bindings.LoadDisplay(); } @@ -595,12 +636,14 @@ public static class ConfigOptionsPageController listBox, SimpleSliderTemplateIndex, "ID_Input_MouseLookSensitivity", min: 0.00999999978f, max: 1f, defaultValue: 0.55f, page, resolveString, read: () => bindings.LoadCameraTurning().MouseLookSensitivity, - apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { MouseLookSensitivity = value })); + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { MouseLookSensitivity = value }), + storeOnly: true); // TS-74 BuildToggleRow( listBox, "ID_Input_InvertMouseLookYAxis", defaultValue: false, page, resolveString, read: () => bindings.LoadCameraTurning().InvertMouseLookYAxis, - apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { InvertMouseLookYAxis = value })); + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { InvertMouseLookYAxis = value }), + storeOnly: true); // TS-74 // Input_UseMouseTurning: the Config tab's OWN client-local // UIPreference — DISTINCT from the Gameplay tab macro's @@ -609,7 +652,8 @@ public static class ConfigOptionsPageController BuildToggleRow( listBox, "ID_Input_UseMouseTurning", defaultValue: false, page, resolveString, read: () => bindings.LoadCameraTurning().UseMouseTurning, - apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { UseMouseTurning = value })); + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { UseMouseTurning = value }), + storeOnly: true); // TS-74 cameraTurning = bindings.LoadCameraTurning(); } @@ -662,13 +706,15 @@ public static class ConfigOptionsPageController listBox, "ID_UI_ChatFontFace", ChatFontFaceChoices, page, resolveString, read: () => bindings.LoadChat().ChatFontFace, apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontFace = value }), - defaultValue: 2); + defaultValue: 2, + storeOnly: true); // AP-200 BuildMenuRow( listBox, "ID_UI_ChatFontSize", ChatFontSizeChoices, page, resolveString, read: () => bindings.LoadChat().ChatFontSizeIndex, apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontSizeIndex = value }), - defaultValue: 1); + defaultValue: 1, + storeOnly: true); // AP-200 chat = bindings.LoadChat(); } @@ -712,7 +758,8 @@ public static class ConfigOptionsPageController OptionPage page, Func resolveString, Func read, - Action apply) + Action apply, + bool storeOnly) { UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex); if (row is null) @@ -732,7 +779,7 @@ public static class ConfigOptionsPageController return; } - ApplyLabelAndTooltip(checkbox, labelKey, resolveString); + ApplyLabelAndTooltip(checkbox, labelKey, resolveString, storeOnly); bool initial = read(); checkbox.Selected = initial; @@ -774,6 +821,7 @@ public static class ConfigOptionsPageController Func resolveString, Func read, Action apply, + bool storeOnly, string? rangeLowKey = null, string? rangeHighKey = null) { @@ -787,7 +835,7 @@ public static class ConfigOptionsPageController } if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label) - SetLabelText(label, labelKey, resolveString); + SetLabelText(label, labelKey, resolveString, storeOnly); if (rangeLowKey is not null) SetRangeLabel(row, SliderRangeMinElementId, rangeLowKey, resolveString); @@ -870,7 +918,8 @@ public static class ConfigOptionsPageController Func toggleRead, Action toggleApply, Func sliderRead, - Action sliderApply) + Action sliderApply, + bool storeOnly) { if (listBox.AddItemFromTemplateList(TrioTemplateIndex) is not UiOptionToggleSlider trio) { @@ -891,7 +940,7 @@ public static class ConfigOptionsPageController return; } - ApplyLabelAndTooltip(checkbox, toggleLabelKey, resolveString); + ApplyLabelAndTooltip(checkbox, toggleLabelKey, resolveString, storeOnly); bool toggleInitial = toggleRead(); checkbox.Selected = toggleInitial; @@ -940,7 +989,8 @@ public static class ConfigOptionsPageController Func resolveString, Func read, Action apply, - int defaultValue) + int defaultValue, + bool storeOnly) { UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); if (row is null) @@ -952,7 +1002,7 @@ public static class ConfigOptionsPageController } if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label) - SetLabelText(label, labelKey, resolveString); + SetLabelText(label, labelKey, resolveString, storeOnly); if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu) { @@ -1022,7 +1072,8 @@ public static class ConfigOptionsPageController Func resolveString, Func read, Action apply, - string defaultValue) + string defaultValue, + bool storeOnly) { UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); if (row is null) @@ -1034,7 +1085,7 @@ public static class ConfigOptionsPageController } if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label) - SetLabelText(label, labelKey, resolveString); + SetLabelText(label, labelKey, resolveString, storeOnly); if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu) { @@ -1079,7 +1130,7 @@ public static class ConfigOptionsPageController } private static void ApplyLabelAndTooltip( - UiButton checkbox, string labelKey, Func resolveString) + UiButton checkbox, string labelKey, Func resolveString, bool storeOnly) { string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey)); if (label is not null) @@ -1089,6 +1140,13 @@ public static class ConfigOptionsPageController $"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — " + "row renders with no caption rather than invented English."); + // AD-78: store-only rows keep their full interactivity (see class + // doc) — only the caption color changes, matching the existing + // disabled/ghosted convention (UiRenderContext.StoreOnlyCaptionColor). + checkbox.LabelColor = storeOnly + ? UiRenderContext.StoreOnlyCaptionColor + : Vector4.One; + string? tooltip = ResolveTooltip(labelKey, resolveString); if (tooltip is not null) checkbox.TooltipText = tooltip; @@ -1106,7 +1164,8 @@ public static class ConfigOptionsPageController private static string? ResolveTooltip(string labelKey, Func resolveString) => resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help")); - private static void SetLabelText(UiText label, string labelKey, Func resolveString) + private static void SetLabelText( + UiText label, string labelKey, Func resolveString, bool storeOnly) { string? text = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey)); if (text is null) @@ -1116,7 +1175,10 @@ public static class ConfigOptionsPageController + "row renders with no caption rather than invented English."); return; } - label.LinesProvider = () => new[] { new UiText.Line(text, label.DefaultColor) }; + // AD-78: store-only rows dim their caption instead of the DAT-authored + // (or default white) color — see class doc. + Vector4 color = storeOnly ? UiRenderContext.StoreOnlyCaptionColor : label.DefaultColor; + label.LinesProvider = () => new[] { new UiText.Line(text, color) }; } private static UiButton? FindCheckbox(UiElement root) diff --git a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs index 65c160d6..0dab3574 100644 --- a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs +++ b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; using AcDream.App.UI; using AcDream.Core.Input; using AcDream.UI.Abstractions.Input; @@ -120,6 +121,18 @@ namespace AcDream.App.UI.Layout; /// retail's OpenOverwriteBindingDialog(&conflicts) — BEFORE reassigning; /// only on accept are the losing rows' slots erased and the new chord applied. /// +/// +/// +/// Caption dimming (AD-78, user-directed, 2026-08-11, gate 2). A row +/// whose is null (AP-203's store-only +/// set — mostly Emotes and CharacterSettings, plus every non-user-bindable +/// InputMap this screen renders) dims its synthesized caption via +/// in +/// . The row stays fully rendered, bindable, +/// conflict-checked, and persisted (per the paragraph above) — only the +/// caption color changes, so the dim is a visual "no live gameplay consumer +/// yet" marker, not a functional restriction. +/// /// public sealed class KeyboardConfigController { @@ -332,9 +345,16 @@ public sealed class KeyboardConfigController string? label = resolveString(RetailInputMapHeaders.StringTableId, row.LabelHash); string? tooltip = resolveString(RetailInputMapHeaders.StringTableId, row.TooltipHash); + bool mapped = RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action); + InputAction? mappedAction = mapped ? action : null; + // The row's own caption — synthesized, composed beside the authored key // buttons (UiText is sealed; see class doc). Occupies the "Command" column - // (x=0..270, matching the authored column headers). + // (x=0..270, matching the authored column headers). AD-78 (user-directed, + // 2026-08-11, gate 2): an unmapped row (MappedAction null — no live + // InputDispatcher consumer, AP-203) dims its caption; the key buttons + // themselves stay fully interactive (bindable/persisted/conflict-checked, + // see class doc). var captionText = new UiText { Left = 0f, @@ -346,14 +366,12 @@ public sealed class KeyboardConfigController RightAligned = false, Padding = 2f, Anchors = AnchorEdges.Left | AnchorEdges.Top, + DefaultColor = mapped ? Vector4.One : UiRenderContext.StoreOnlyCaptionColor, }; if (label is not null) captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) }; built.AddChild(captionText); - bool mapped = RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action); - InputAction? mappedAction = mapped ? action : null; - // M1: capture this row's live Activation/Scope ONCE, from the first // existing binding for the action (every multi-chord action in // KeyBindings.RetailDefaults() shares one Activation/Scope pair across diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 6d365b12..d4ed5b11 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -209,6 +209,23 @@ public sealed class UiRenderContext /// 9 elements in the whole DAT set — every other outlined element uses this). public static readonly Vector4 DefaultOutlineColor = new(0f, 0f, 0f, 1f); + /// + /// AD-78 (user-directed, 2026-08-11, Campaign OP gate 2): the shared caption + /// color for Options-panel / Configure-Keyboard rows that are store-only — + /// they persist (and, where auto-save, send the wire bit) but drive nothing + /// observable in acdream yet. Retail dims nothing here (every retail row has + /// a live consumer by construction); this is a deliberate acdream-only + /// convention so a store-only row is visually distinguishable from a live + /// one without any invented marker text. Same neutral grey as the existing + /// disabled/ghosted convention (, + /// retail's disabled StateDesc grey) — reused rather than a new color so the + /// "this doesn't do anything yet" signal reads consistently across the whole + /// UI. See AD-78 for the full citation list; the row retires as consumers + /// land (each landing un-dims its own rows via the owning controller's + /// conformance test). + /// + public static readonly Vector4 StoreOnlyCaptionColor = new(0.5f, 0.5f, 0.5f, 1f); + /// /// Draw a single line of text with a retail dat font (), /// at , = the top-left of the diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs index 4a6057cd..066fdb3e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Net.Messages; @@ -693,4 +694,127 @@ public sealed class CharacterOptionsPageControllerTests // degrades to "no text", never a fabricated label. Assert.Equal(50, controller.CharacterPage.Rows.Count); } + + // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── + + /// + /// The exact 35 values this slice dims + /// ( == true), transcribed independently + /// of CharacterOptionsPageController.Groups from the derivation + /// table in that class's own doc comment. Wiring a future consumer + /// for any of these means removing it from this literal set AND + /// flipping its Groups table entry from StoreOnly to + /// Live consciously — leaving either one stale fails this + /// test. + /// + private static readonly HashSet ExpectedStoreOnlyIds = + [ + // Group 1 (UI Behavior) — SalvageMultiple (D), MainPackPreferred (B, unbound) + CharacterOptionId.SalvageMultiple, + CharacterOptionId.MainPackPreferred, + // Group 2 (UI Display) — 11 of 15 + CharacterOptionId.ShowTooltips, + CharacterOptionId.SideBySideVitals, + CharacterOptionId.SpellDuration, + CharacterOptionId.DisableMostWeatherEffects, + CharacterOptionId.PersistentAtDay, + CharacterOptionId.DisableHouseRestrictionEffects, + CharacterOptionId.UseCraftSuccessDialog, + CharacterOptionId.ConfirmVolatileRareUse, + CharacterOptionId.FilterLanguage, + CharacterOptionId.ShowHelm, + CharacterOptionId.ShowCloak, + // Group 3 (Grouping) — all 6 + CharacterOptionId.IgnoreAllegianceRequests, + CharacterOptionId.IgnoreFellowshipRequests, + CharacterOptionId.DisplayAllegianceLogonNotifications, + CharacterOptionId.FellowshipShareXP, + CharacterOptionId.FellowshipShareLoot, + CharacterOptionId.FellowshipAutoAcceptRequests, + // Group 4 (Other Players) — 10 of 11 + CharacterOptionId.AcceptLootPermits, + CharacterOptionId.UseDeception, + CharacterOptionId.AllowGive, + CharacterOptionId.IgnoreTradeRequests, + CharacterOptionId.DisplayDateOfBirth, + CharacterOptionId.DisplayAge, + CharacterOptionId.DisplayChessRank, + CharacterOptionId.DisplayFishingSkill, + CharacterOptionId.DisplayNumberDeaths, + CharacterOptionId.DisplayNumberCharacterTitles, + // Group 5 (Character Behavior) — 4 of 7 + CharacterOptionId.AdvancedCombatUI, + CharacterOptionId.UseChargeAttack, + CharacterOptionId.LeadMissileTargets, + CharacterOptionId.UseFastMissiles, + // Group 6 (Chat) — 2 of 8 + CharacterOptionId.StayInChatMode, + CharacterOptionId.HearPkDeathMessages, + ]; + + [Fact] + public void StoreOnlyRows_MatchTheDerivationTableExactly() + { + HashSet actualStoreOnly = AllRows() + .Where(static r => r.StoreOnly) + .Select(static r => r.Id) + .ToHashSet(); + + Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly); + Assert.Equal(35, actualStoreOnly.Count); + Assert.Equal(15, 50 - actualStoreOnly.Count); // the 15 live rows + } + + [Fact] + public void Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows() + { + // A non-null constant resolver (unlike this file's usual BindReal() + // default) so every checkbox actually gets a Label -- LabelColor + // itself is set unconditionally either way, but this keeps the + // built tree representative of a real DAT string table. + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + var fakeBindings = new FakeBindings(); + bool bound = CharacterOptionsPageController.Bind( + layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => "x", + fakeBindings.ToBindings()); + Assert.True(bound); + + var listBox = Assert.IsType( + layout.FindElement(CharacterOptionsPageController.ListBoxElementId)); + UiElement viewport = Assert.Single(listBox.Children); + + // Filter for "contains a checkbox leaf" rather than asserting a + // concrete non-checkbox widget type for headers/separators -- robust + // to either template's underlying DatWidgetFactory class, exactly + // like the controller's own FindCheckbox degrade-gracefully pattern. + const uint ToggleCheckboxElementId = 0x10000219u; + List checkboxesInOrder = viewport.Children + .Select(item => UiElement.FindDescendant(item, ToggleCheckboxElementId) as UiButton) + .Where(static cb => cb is not null) + .Select(static cb => cb!) + .ToList(); + Assert.Equal(50, checkboxesInOrder.Count); + + List specsInOrder = AllRows().ToList(); + Assert.Equal(50, specsInOrder.Count); + + for (int i = 0; i < 50; i++) + { + CharacterOptionsPageController.RowSpec spec = specsInOrder[i]; + Vector4 expected = spec.StoreOnly ? UiRenderContext.StoreOnlyCaptionColor : Vector4.One; + Assert.True( + expected == checkboxesInOrder[i].LabelColor, + $"row {i} ({spec.RetailName}, 0x{(uint)spec.Id:X2}): expected " + + $"{(spec.StoreOnly ? "DIMMED" : "LIVE")} caption color {expected} but the " + + $"built checkbox rendered {checkboxesInOrder[i].LabelColor}."); + } + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs index 47e6aae9..ecf7ad10 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.UI.Abstractions.Panels.Settings; @@ -858,4 +859,108 @@ public sealed class ConfigOptionsPageControllerTests Assert.True(bound); Assert.Equal(30, controller.ConfigPage.Rows.Count); } + + // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── + + private enum RowKind { Toggle, TrioToggle, Slider, Menu } + + /// + /// One literal expectation per option-row-bearing viewport item, in the + /// SAME order builds them + /// (39 stacked items: 6 headers + 6 separators + 27 option rows — see + /// + /// for the index arithmetic this table shares). Wiring a future + /// consumer for any row here means flipping BOTH this literal's + /// StoreOnly AND the matching ConfigOptionsPageController + /// BindXxxSection call site's own storeOnly: argument + /// consciously — leaving either one stale fails this test. + /// + private static readonly (int ViewportIndex, RowKind Kind, bool StoreOnly, string Label)[] + DimmingExpectations = + { + (1, RowKind.Menu, true, "Sound Features"), // AP-199 + (2, RowKind.TrioToggle, false, "Disable Sound Effects"), // LIVE + (3, RowKind.TrioToggle, false, "Disable Ambient Sound"), // LIVE + (4, RowKind.TrioToggle, true, "Disable Interface Sound"), // AP-174/AP-199 + (5, RowKind.Toggle, true, "Play Sound Only When Active"), // AP-199 + (8, RowKind.Slider, true, "Camera Stiffness"), // TS-74 + (9, RowKind.Slider, true, "Camera Adjustment Speed"), // TS-74 + (10, RowKind.Slider, false, "Field Of View"), // NEXT-LAUNCH + (11, RowKind.Toggle, true, "Align To Slope"), // TS-74 + (14, RowKind.Menu, false, "Resolution"), // LIVE + (15, RowKind.Toggle, false, "Full Screen"), // LIVE + (16, RowKind.Toggle, false, "Sync To Refresh"), // NEXT-LAUNCH + (17, RowKind.Slider, true, "Screen Brightness"), // review S2 + (18, RowKind.Toggle, true, "Automatic Degrades"), // AP-198 + (19, RowKind.Slider, true, "Graphics Performance"), // AP-198 + (20, RowKind.Slider, true, "Degrade Distance"), // AP-198 + (23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198 + (24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198 + (25, RowKind.Menu, true, "Texture Filtering"), // AP-198 + (26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198 + (27, RowKind.Toggle, true, "Building Detail Textures"), // AP-198 + (28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198 + (31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74 + (32, RowKind.Toggle, true, "Invert Mouselook Y Axis"), // TS-74 + (33, RowKind.Toggle, true, "Use Mouse Turning"), // TS-74 + (36, RowKind.Menu, true, "Chat Font Face"), // AP-200 + (37, RowKind.Menu, true, "Chat Font Size"), // AP-200 + }; + + private static Vector4? FindTextLineColor(UiElement root, uint elementId) + { + if (UiElement.FindDescendant(root, elementId) is not UiText text) + return null; + IReadOnlyList lines = text.LinesProvider(); + return lines.Count == 0 ? null : lines[0].Color; + } + + [Fact] + public void CaptionDimming_MatchesTheStoreOnlySetExactly() + { + // A non-null constant resolver is required here (unlike most of this + // file's BindReal() default): SetLabelText's dim color is baked into + // the UiText.Line closure only when a label actually resolves, so a + // null resolver would leave every slider/menu label's LinesProvider + // at its empty default and hide the very thing this test checks. + (OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => "x"); + Assert.True(bound); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); + UiElement viewport = Assert.Single(listBox.Children); + IReadOnlyList items = viewport.Children.ToList(); + Assert.Equal(39, items.Count); + + const uint ToggleCheckboxElementId = 0x10000219u; + const uint SliderLabelElementId = 0x1000021Bu; + const uint MenuLabelElementId = 0x10000223u; + + foreach ((int index, RowKind kind, bool storeOnly, string label) in DimmingExpectations) + { + Vector4 expected = storeOnly + ? UiRenderContext.StoreOnlyCaptionColor + : Vector4.One; + Vector4? actual = kind switch + { + RowKind.Toggle => + (UiElement.FindDescendant(items[index], ToggleCheckboxElementId) as UiButton)?.LabelColor, + RowKind.TrioToggle => + Assert.IsType(items[index]).Toggle?.LabelColor, + RowKind.Slider => FindTextLineColor(items[index], SliderLabelElementId), + RowKind.Menu => FindTextLineColor(items[index], MenuLabelElementId), + _ => throw new InvalidOperationException($"unhandled row kind {kind}"), + }; + Assert.True( + actual.HasValue, + $"{label} (viewport index {index}, kind {kind}): could not locate the " + + "caption widget/line to check its color."); + Assert.True( + expected == actual.Value, + $"{label} (viewport index {index}): expected " + + $"{(storeOnly ? "DIMMED" : "LIVE")} caption color {expected} but the " + + $"built widget rendered {actual.Value}."); + } + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs index 05369a20..1d8857bc 100644 --- a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Input; @@ -610,4 +611,49 @@ public sealed class KeyboardConfigControllerTests } Assert.True(seen.Count > 100, $"sanity: only {seen.Count} mapped actions seen"); } + + // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── + + [Fact] + public void UnmappedRows_DimTheirCaption_MappedRowsStayWhite() + { + // AP-203's store-only set: a row whose RetailActionIdentityTable + // lookup fails (MappedAction null -- mostly Emotes/CharacterSettings) + // never reaches the InputDispatcher, so its caption dims. Wiring a + // future mapping for "Bow Deep" (or any other unmapped row) means + // this assertion flips from StoreOnlyCaptionColor to Vector4.One -- + // a conscious edit, not a silent pass. + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward (mapped) + Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> unmapped + }); + + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + var fake = new FakeBindings(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); + Assert.NotNull(forward.MappedAction); + UiText forwardCaption = RowCaption(forward); + Assert.Equal(Vector4.One, forwardCaption.DefaultColor); + + KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u); + Assert.Null(bowDeep.MappedAction); + UiText bowDeepCaption = RowCaption(bowDeep); + Assert.Equal(UiRenderContext.StoreOnlyCaptionColor, bowDeepCaption.DefaultColor); + } + + /// The row's synthesized caption (composed beside the authored key + /// buttons -- see KeyboardConfigController's class doc) has no stable dat + /// element id of its own, so it is located structurally: the ONLY + /// direct child of the key buttons' shared parent + /// (the row container BuildActionRow builds). + private static UiText RowCaption(KeyboardConfigController.RowView row) + { + UiElement parent = row.KeyButtons.First().Parent + ?? throw new InvalidOperationException("row's key button has no parent element"); + return parent.Children.OfType().Single(); + } }