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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 15:52:20 +02:00
parent 8bd7e3b88d
commit 3441a71833
10 changed files with 563 additions and 93 deletions

View file

@ -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-4s 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 retails 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-4s 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 retails 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 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 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-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-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-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) |
--- ---

View file

@ -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 `[options] gameplay button 0x... clicked` — a silent click in that log IS
evidence, report it per-button. 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 This document is the script the user runs against the live connected
client (`ACDREAM_LIVE=1` against the local ACE server) to accept each 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 slice. Each item states what to do and what retail-faithful behavior to

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
@ -82,95 +83,160 @@ public static class CharacterOptionsPageController
private const uint StringTableId = 0x23000003u; private const uint StringTableId = 0x23000003u;
/// <summary>One authored Character-tab row: the wire/storage id plus /// <summary>One authored Character-tab row: the wire/storage id, retail's
/// retail's OWN <c>PlayerOption</c> enum member name (for the /// OWN <c>PlayerOption</c> enum member name (for the
/// <c>ID_PlayerOption_&lt;Name&gt;</c>/<c>_Help</c> string hash — see /// <c>ID_PlayerOption_&lt;Name&gt;</c>/<c>_Help</c> string hash — see
/// the U2 note above).</summary> /// the U2 note above), and <see cref="StoreOnly"/> (AD-78,
public readonly record struct RowSpec(CharacterOptionId Id, string RetailName); /// user-directed, 2026-08-11, gate 2 — see the derivation table below
/// this record).</summary>
public readonly record struct RowSpec(CharacterOptionId Id, string RetailName, bool StoreOnly);
/// <summary>One authored header group: its section string key plus /// <summary>One authored header group: its section string key plus
/// authored-order rows.</summary> /// authored-order rows.</summary>
public readonly record struct GroupSpec(string HeaderKey, RowSpec[] Rows); public readonly record struct GroupSpec(string HeaderKey, RowSpec[] Rows);
/// <summary>
/// AD-78 caption-dimming derivation (user-directed, 2026-08-11, gate 2).
/// <see cref="RowSpec.StoreOnly"/> below is derived from
/// <c>docs/research/2026-08-10-character-options-map.md</c> §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
/// <c>CharacterOptionId.*</c> reference outside
/// <c>CharacterOptionTable</c>/this controller/the wire codec) because
/// the research doc predates OP1/OP4's implementation and Group B was
/// only PARTIALLY wired (confirmed against
/// <c>docs/research/2026-08-11-campaign-op-test-script.md</c> §OP4
/// steps 13-18, written after the code landed):
///
/// <list type="bullet">
/// <item><description><b>NOT dimmed (15 rows, real acdream-side
/// consumer):</b> Group B rows actually bound —
/// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c>
/// (combat: <c>LiveCombatAttackOperations.cs</c>,
/// <c>CombatUiController.cs</c> — Group C re-point),
/// <c>VividTargetingIndicator</c> (<c>InteractionRetainedUiComposition.cs:696</c>
/// — Group C), <c>CoordinatesOnRadar</c>
/// (<c>LivePresentationComposition.cs:845</c> — Group C),
/// <c>DisableDistanceFog</c>/<c>DisplayTimeStamps</c>/<c>ToggleRun</c>
/// (Group B, polled at <c>GameWindow.cs:655-680</c>),
/// <c>DragItemOnPlayerOpensSecureTrade</c> (TS-48,
/// <c>InteractionRetainedUiComposition.cs:326</c>), and all six
/// <c>ListenTo*Chat</c> ids (<c>TurbineChatMembershipGate.cs:105-136</c>
/// gates every Turbine room join on the matching bit).</description></item>
/// <item><description><b>Dimmed (35 rows, store-only):</b> 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" (<c>ShowTooltips</c>,
/// <c>SideBySideVitals</c>, <c>SpellDuration</c>,
/// <c>AdvancedCombatUI</c>, <c>StayInChatMode</c>,
/// <c>DisableMostWeatherEffects</c>, <c>PersistentAtDay</c>,
/// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D
/// deferral (<c>SalvageMultiple</c>,
/// <c>DisableHouseRestrictionEffects</c>, <c>FellowshipShareXP</c>,
/// <c>HearPkDeathMessages</c>).</description></item>
/// <item><description><b>Flagged ambiguity, resolved by code evidence
/// (see final report, not re-litigated here):</b> the research doc's
/// §7.1 lists <c>AcceptLootPermits</c> in BOTH Group A's bullet list AND
/// Group C's bullet list. The actual consumer
/// (<c>LiveSessionRuntimeFactory.cs:497-502</c>, the <c>/consent</c>
/// slash command) is a second SETTER surface for the same server bit,
/// not a behavioral reader — ACE alone decides whether to honor corpse
/// looting (<c>Player_Death.cs:755</c>). Classified Group A / dimmed.
/// <c>IgnoreFellowshipRequests</c>/<c>FellowshipAutoAcceptRequests</c>
/// have a genuine coded mutual-exclusion side effect
/// (<c>RuntimeCharacterState.TrySetOption</c>, 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.</description></item>
/// </list>
/// </summary>
private const bool Live = false;
private const bool StoreOnly = true;
/// <summary> /// <summary>
/// The complete 6-group / 50-row authored table — research doc §2 / /// The complete 6-group / 50-row authored table — research doc §2 /
/// §7, id-for-id, in EXACT authored order. <c>RetailName</c> values /// §7, id-for-id, in EXACT authored order. <c>RetailName</c> values
/// verified against <c>acclient.h:4162-4218</c>'s literal enumerator /// verified against <c>acclient.h:4162-4218</c>'s literal enumerator
/// spelling. /// spelling. See the derivation table above <see cref="Groups"/> for the
/// <c>StoreOnly</c> column's sourcing.
/// </summary> /// </summary>
public static readonly GroupSpec[] Groups = public static readonly GroupSpec[] Groups =
{ {
new("ID_CharacterOption_UIBehavior_Section", new RowSpec[] new("ID_CharacterOption_UIBehavior_Section", new RowSpec[]
{ {
new(CharacterOptionId.ViewCombatTarget, "ViewCombatTarget"), new(CharacterOptionId.ViewCombatTarget, "ViewCombatTarget", Live), // Group C
new(CharacterOptionId.SalvageMultiple, "SalvageMultiple"), new(CharacterOptionId.SalvageMultiple, "SalvageMultiple", StoreOnly), // Group D
new(CharacterOptionId.MainPackPreferred, "MainPackPreferred"), new(CharacterOptionId.MainPackPreferred, "MainPackPreferred", StoreOnly), // Group B, unbound
}), }),
new("ID_CharacterOption_UIDisplay_Section", new RowSpec[] new("ID_CharacterOption_UIDisplay_Section", new RowSpec[]
{ {
new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator"), new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator", Live), // Group C
new(CharacterOptionId.ShowTooltips, "ShowTooltips"), new(CharacterOptionId.ShowTooltips, "ShowTooltips", StoreOnly), // Group B, unbound
new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar"), new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar", Live), // Group C
new(CharacterOptionId.SideBySideVitals, "SideBySideVitals"), new(CharacterOptionId.SideBySideVitals, "SideBySideVitals", StoreOnly), // Group B, unbound
new(CharacterOptionId.SpellDuration, "SpellDuration"), new(CharacterOptionId.SpellDuration, "SpellDuration", StoreOnly), // Group B, unbound
new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects"), new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects", StoreOnly), // Group B, unbound
new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog"), new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog", Live), // Group B, bound (GameWindow.cs:657)
new(CharacterOptionId.PersistentAtDay, "PersistentAtDay"), new(CharacterOptionId.PersistentAtDay, "PersistentAtDay", StoreOnly), // Group B, unbound
new(CharacterOptionId.DisableHouseRestrictionEffects, "DisableHouseRestrictionEffects"), new(CharacterOptionId.DisableHouseRestrictionEffects, "DisableHouseRestrictionEffects", StoreOnly), // Group D
new(CharacterOptionId.UseCraftSuccessDialog, "UseCraftSuccessDialog"), new(CharacterOptionId.UseCraftSuccessDialog, "UseCraftSuccessDialog", StoreOnly), // Group A
new(CharacterOptionId.ConfirmVolatileRareUse, "ConfirmVolatileRareUse"), new(CharacterOptionId.ConfirmVolatileRareUse, "ConfirmVolatileRareUse", StoreOnly), // Group A
new(CharacterOptionId.DisplayTimeStamps, "DisplayTimeStamps"), new(CharacterOptionId.DisplayTimeStamps, "DisplayTimeStamps", Live), // Group B, bound (GameWindow.cs:664)
new(CharacterOptionId.FilterLanguage, "FilterLanguage"), new(CharacterOptionId.FilterLanguage, "FilterLanguage", StoreOnly), // Group B, unbound
new(CharacterOptionId.ShowHelm, "ShowHelm"), new(CharacterOptionId.ShowHelm, "ShowHelm", StoreOnly), // Group A
new(CharacterOptionId.ShowCloak, "ShowCloak"), new(CharacterOptionId.ShowCloak, "ShowCloak", StoreOnly), // Group A
}), }),
new("ID_CharacterOption_Grouping_Section", new RowSpec[] new("ID_CharacterOption_Grouping_Section", new RowSpec[]
{ {
new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests"), new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests", StoreOnly), // Group A
new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests"), new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", StoreOnly), // Group A (see ambiguity note)
new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications"), new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A
new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP"), new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", StoreOnly), // Group D
new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot"), new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot", StoreOnly), // Group A
new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests"), new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests", StoreOnly), // Group A (see ambiguity note)
}), }),
new("ID_CharacterOption_OtherPlayers_Section", new RowSpec[] new("ID_CharacterOption_OtherPlayers_Section", new RowSpec[]
{ {
new(CharacterOptionId.AcceptLootPermits, "AcceptLootPermits"), new(CharacterOptionId.AcceptLootPermits, "AcceptLootPermits", StoreOnly), // Group A (see ambiguity note)
new(CharacterOptionId.UseDeception, "UseDeception"), new(CharacterOptionId.UseDeception, "UseDeception", StoreOnly), // Group A
new(CharacterOptionId.AllowGive, "AllowGive"), new(CharacterOptionId.AllowGive, "AllowGive", StoreOnly), // Group A
new(CharacterOptionId.IgnoreTradeRequests, "IgnoreTradeRequests"), new(CharacterOptionId.IgnoreTradeRequests, "IgnoreTradeRequests", StoreOnly), // Group A
new(CharacterOptionId.DragItemOnPlayerOpensSecureTrade, "DragItemOnPlayerOpensSecureTrade"), new(CharacterOptionId.DragItemOnPlayerOpensSecureTrade, "DragItemOnPlayerOpensSecureTrade", Live), // TS-48, live gate
new(CharacterOptionId.DisplayDateOfBirth, "DisplayDateOfBirth"), new(CharacterOptionId.DisplayDateOfBirth, "DisplayDateOfBirth", StoreOnly), // Group A
new(CharacterOptionId.DisplayAge, "DisplayAge"), new(CharacterOptionId.DisplayAge, "DisplayAge", StoreOnly), // Group A
new(CharacterOptionId.DisplayChessRank, "DisplayChessRank"), new(CharacterOptionId.DisplayChessRank, "DisplayChessRank", StoreOnly), // Group A
new(CharacterOptionId.DisplayFishingSkill, "DisplayFishingSkill"), new(CharacterOptionId.DisplayFishingSkill, "DisplayFishingSkill", StoreOnly), // Group A
new(CharacterOptionId.DisplayNumberDeaths, "DisplayNumberDeaths"), new(CharacterOptionId.DisplayNumberDeaths, "DisplayNumberDeaths", StoreOnly), // Group A
new(CharacterOptionId.DisplayNumberCharacterTitles, "DisplayNumberCharacterTitles"), new(CharacterOptionId.DisplayNumberCharacterTitles, "DisplayNumberCharacterTitles", StoreOnly), // Group A
}), }),
new("ID_CharacterOption_CharacterBehavior_Section", new RowSpec[] new("ID_CharacterOption_CharacterBehavior_Section", new RowSpec[]
{ {
new(CharacterOptionId.ToggleRun, "ToggleRun"), new(CharacterOptionId.ToggleRun, "ToggleRun", Live), // Group B, bound (GameWindow.cs:680)
new(CharacterOptionId.AdvancedCombatUI, "AdvancedCombatUI"), new(CharacterOptionId.AdvancedCombatUI, "AdvancedCombatUI", StoreOnly), // Group B, unbound
new(CharacterOptionId.AutoTarget, "AutoTarget"), new(CharacterOptionId.AutoTarget, "AutoTarget", Live), // Group C
new(CharacterOptionId.AutoRepeatAttack, "AutoRepeatAttack"), new(CharacterOptionId.AutoRepeatAttack, "AutoRepeatAttack", Live), // Group C
new(CharacterOptionId.UseChargeAttack, "UseChargeAttack"), new(CharacterOptionId.UseChargeAttack, "UseChargeAttack", StoreOnly), // Group A
new(CharacterOptionId.LeadMissileTargets, "LeadMissileTargets"), new(CharacterOptionId.LeadMissileTargets, "LeadMissileTargets", StoreOnly), // Group A
new(CharacterOptionId.UseFastMissiles, "UseFastMissiles"), new(CharacterOptionId.UseFastMissiles, "UseFastMissiles", StoreOnly), // Group A
}), }),
new("ID_CharacterOption_Chat_Section", new RowSpec[] new("ID_CharacterOption_Chat_Section", new RowSpec[]
{ {
new(CharacterOptionId.StayInChatMode, "StayInChatMode"), new(CharacterOptionId.StayInChatMode, "StayInChatMode", StoreOnly), // Group B, unbound
new(CharacterOptionId.ListenToAllegianceChat, "HearAllegianceChat"), new(CharacterOptionId.ListenToAllegianceChat, "HearAllegianceChat", Live), // TurbineChatMembershipGate.cs:107-110
new(CharacterOptionId.ListenToGeneralChat, "HearGeneralChat"), new(CharacterOptionId.ListenToGeneralChat, "HearGeneralChat", Live), // TurbineChatMembershipGate.cs:111-114
new(CharacterOptionId.ListenToTradeChat, "HearTradeChat"), new(CharacterOptionId.ListenToTradeChat, "HearTradeChat", Live), // TurbineChatMembershipGate.cs:115-118
new(CharacterOptionId.ListenToLFGChat, "HearLFGChat"), new(CharacterOptionId.ListenToLFGChat, "HearLFGChat", Live), // TurbineChatMembershipGate.cs:119-122
new(CharacterOptionId.ListenToRoleplayChat, "HearRoleplayChat"), new(CharacterOptionId.ListenToRoleplayChat, "HearRoleplayChat", Live), // TurbineChatMembershipGate.cs:123-126
new(CharacterOptionId.ListenToSocietyChat, "HearSocietyChat"), new(CharacterOptionId.ListenToSocietyChat, "HearSocietyChat", Live), // TurbineChatMembershipGate.cs:127-130
// D3: the 50th row. Not in the 2013 build (register row // D3: the 50th row. Not in the 2013 build (register row
// AP-193 covers the ACE-sourced id/mask); the DAT string // AP-193 covers the ACE-sourced id/mask); the DAT string
// 0x0D16E9A3 ("Listen to PK death messages.") IS present, so // 0x0D16E9A3 ("Listen to PK death messages.") IS present, so
// it renders exactly like every other row — wire+store only. // 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 — " $"[D.2b] CharacterOptionsPageController: label '{labelKey}' did not resolve — "
+ "row renders with no caption rather than invented English."); + "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( string? tooltip = resolveString(
StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help")); StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
if (tooltip is not null) if (tooltip is not null)

View file

@ -47,6 +47,18 @@ namespace AcDream.App.UI.Layout;
/// (Campaign CH slice CH6b). <see cref="FilterBlockSpec.CompactWindowId"/> carries the /// (Campaign CH slice CH6b). <see cref="FilterBlockSpec.CompactWindowId"/> carries the
/// mapping so nothing downstream needs to know retail's raw numbering. /// mapping so nothing downstream needs to know retail's raw numbering.
/// </para> /// </para>
///
/// <para>
/// <b>No store-only rows (AD-78 audit, user-directed, 2026-08-11, gate 2).</b>
/// Every row on this tab has a live acdream consumer — the two opacity
/// sliders drive <c>RetailWindowOpacityController</c> (AP-190) through
/// <see cref="Bindings.SetDefaultOpacity"/>/<see cref="Bindings.SetActiveOpacity"/>,
/// and all five filter blocks write <see cref="ChatWindowState"/> 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.
/// </para>
/// </summary> /// </summary>
public static class ChatOptionsPageController public static class ChatOptionsPageController
{ {

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
@ -130,6 +131,24 @@ namespace AcDream.App.UI.Layout;
/// <c>0x004039ed</c>, NOT a per-machine runtime enumeration as the /// <c>0x004039ed</c>, NOT a per-machine runtime enumeration as the
/// rejected slice's comment claimed). /// rejected slice's comment claimed).
/// </para> /// </para>
///
/// <para>
/// <b>Caption dimming (AD-78, user-directed, 2026-08-11, gate 2).</b> 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
/// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> 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 <c>storeOnly</c> parameter
/// at each of the 27 call sites, so wiring a future consumer means flipping
/// that literal to <see langword="false"/> -- the matching conformance test
/// then fails until the change is made consciously.
/// </para>
/// </summary> /// </summary>
public static class ConfigOptionsPageController public static class ConfigOptionsPageController
{ {
@ -317,7 +336,8 @@ public static class ConfigOptionsPageController
AudioSettings updated = bindings.LoadAudio() with { SoundFeatures = value }; AudioSettings updated = bindings.LoadAudio() with { SoundFeatures = value };
bindings.SaveAudio(updated); bindings.SaveAudio(updated);
}, },
defaultValue: 0); defaultValue: 0,
storeOnly: true); // AP-199
// OP6 rework (2026-08-11, review M2): read/apply the ENABLED-sense // OP6 rework (2026-08-11, review M2): read/apply the ENABLED-sense
// fields directly — toggleDefault stays `true` because retail's own // fields directly — toggleDefault stays `true` because retail's own
@ -339,7 +359,8 @@ public static class ConfigOptionsPageController
toggleRead: () => bindings.LoadAudio().SfxEnabled, toggleRead: () => bindings.LoadAudio().SfxEnabled,
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { SfxEnabled = value }), toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { SfxEnabled = value }),
sliderRead: () => bindings.LoadAudio().Sfx, 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( BuildTrioRow(
listBox, "ID_Sound_DisableAmbientSound", sliderTooltipKey: "ID_Sound_AmbientVolume", listBox, "ID_Sound_DisableAmbientSound", sliderTooltipKey: "ID_Sound_AmbientVolume",
@ -349,7 +370,8 @@ public static class ConfigOptionsPageController
toggleRead: () => bindings.LoadAudio().AmbientEnabled, toggleRead: () => bindings.LoadAudio().AmbientEnabled,
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { AmbientEnabled = value }), toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { AmbientEnabled = value }),
sliderRead: () => bindings.LoadAudio().Ambient, 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 // Interface Sound: retail's own dead knob (AP-174 — "interface
// sounds are scaled by the EFFECT knob"; registered and never // sounds are scaled by the EFFECT knob"; registered and never
@ -363,12 +385,14 @@ public static class ConfigOptionsPageController
toggleRead: () => bindings.LoadAudio().InterfaceEnabled, toggleRead: () => bindings.LoadAudio().InterfaceEnabled,
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceEnabled = value }), toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceEnabled = value }),
sliderRead: () => bindings.LoadAudio().InterfaceVolume, 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( BuildToggleRow(
listBox, "ID_Sound_NoFocusNoSound", defaultValue: true, page, resolveString, listBox, "ID_Sound_NoFocusNoSound", defaultValue: true, page, resolveString,
read: () => bindings.LoadAudio().PlaySoundOnlyWhenActive, 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(); audio = bindings.LoadAudio();
} }
@ -393,6 +417,7 @@ public static class ConfigOptionsPageController
min: 0.285714298f, max: 1f, defaultValue: 0.45f, page, resolveString, min: 0.285714298f, max: 1f, defaultValue: 0.45f, page, resolveString,
read: () => bindings.LoadCameraTurning().Stiffness, read: () => bindings.LoadCameraTurning().Stiffness,
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { Stiffness = value }), apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { Stiffness = value }),
storeOnly: true, // TS-74
rangeLowKey: "ID_Graphics_Value_Soft", rangeHighKey: "ID_Graphics_Value_Hard"); rangeLowKey: "ID_Graphics_Value_Soft", rangeHighKey: "ID_Graphics_Value_Hard");
BuildSliderRow( BuildSliderRow(
@ -400,6 +425,7 @@ public static class ConfigOptionsPageController
min: 5f, max: 80f, defaultValue: 40.0f, page, resolveString, min: 5f, max: 80f, defaultValue: 40.0f, page, resolveString,
read: () => bindings.LoadCameraTurning().AdjustmentSpeed, read: () => bindings.LoadCameraTurning().AdjustmentSpeed,
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AdjustmentSpeed = value }), apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AdjustmentSpeed = value }),
storeOnly: true, // TS-74
rangeLowKey: "ID_Graphics_Value_Slow", rangeHighKey: "ID_Graphics_Value_Fast"); rangeLowKey: "ID_Graphics_Value_Slow", rangeHighKey: "ID_Graphics_Value_Fast");
// Field of View: NEXT-LAUNCH via DisplaySettings.FieldOfView + the // 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, min: 10f, max: 160f, defaultValue: 90.0f, page, resolveString,
read: () => bindings.LoadDisplay().FieldOfView, read: () => bindings.LoadDisplay().FieldOfView,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { FieldOfView = value }), 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"); rangeLowKey: "ID_Graphics_Value_Narrow", rangeHighKey: "ID_Graphics_Value_Wide");
BuildToggleRow( BuildToggleRow(
listBox, "ID_Camera_AlignToSlope", defaultValue: true, page, resolveString, listBox, "ID_Camera_AlignToSlope", defaultValue: true, page, resolveString,
read: () => bindings.LoadCameraTurning().AlignToSlope, 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(); cameraTurning = bindings.LoadCameraTurning();
} }
@ -455,19 +483,22 @@ public static class ConfigOptionsPageController
DisplaySettings.AvailableResolutions, page, resolveString, DisplaySettings.AvailableResolutions, page, resolveString,
read: () => bindings.LoadDisplay().Resolution, read: () => bindings.LoadDisplay().Resolution,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Resolution = value }), apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Resolution = value }),
defaultValue: "800x600"); defaultValue: "800x600",
storeOnly: false); // LIVE
BuildToggleRow( BuildToggleRow(
listBox, "ID_Rendering_FullScreen", defaultValue: true, page, resolveString, listBox, "ID_Rendering_FullScreen", defaultValue: true, page, resolveString,
read: () => bindings.LoadDisplay().Fullscreen, 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 // Sync To Refresh: NEXT-LAUNCH (same DisplaySettings.VSync
// pre-existing precedent as FieldOfView above). // pre-existing precedent as FieldOfView above).
BuildToggleRow( BuildToggleRow(
listBox, "ID_Rendering_SyncToDisplayRefresh", defaultValue: false, page, resolveString, listBox, "ID_Rendering_SyncToDisplayRefresh", defaultValue: false, page, resolveString,
read: () => bindings.LoadDisplay().VSync, 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 // Screen Brightness: OP6 rework (2026-08-11, review S2) — its OWN
// DisplaySettings.ScreenBrightness field ([-1,1], default 0), NOT // DisplaySettings.ScreenBrightness field ([-1,1], default 0), NOT
@ -479,18 +510,21 @@ public static class ConfigOptionsPageController
min: -1f, max: 1f, defaultValue: 0f, page, resolveString, min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
read: () => bindings.LoadDisplay().ScreenBrightness, read: () => bindings.LoadDisplay().ScreenBrightness,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { ScreenBrightness = value }), apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { ScreenBrightness = value }),
storeOnly: true, // review S2
rangeLowKey: "ID_Graphics_Value_Dark", rangeHighKey: "ID_Graphics_Value_Bright"); rangeLowKey: "ID_Graphics_Value_Dark", rangeHighKey: "ID_Graphics_Value_Bright");
BuildToggleRow( BuildToggleRow(
listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString, listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString,
read: () => bindings.LoadDisplay().AutomaticDegrades, 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( BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias", listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias",
min: -1f, max: 1f, defaultValue: 0f, page, resolveString, min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
read: () => bindings.LoadDisplay().GraphicsPerformance, read: () => bindings.LoadDisplay().GraphicsPerformance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }), apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }),
storeOnly: true, // AP-198
rangeLowKey: "ID_Graphics_Value_Speed", rangeHighKey: "ID_Graphics_Value_Detail"); rangeLowKey: "ID_Graphics_Value_Speed", rangeHighKey: "ID_Graphics_Value_Detail");
BuildSliderRow( BuildSliderRow(
@ -498,6 +532,7 @@ public static class ConfigOptionsPageController
min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString, min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString,
read: () => bindings.LoadDisplay().DegradeDistance, read: () => bindings.LoadDisplay().DegradeDistance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }), apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }),
storeOnly: true, // AP-198
rangeLowKey: "ID_Graphics_Value_Close", rangeHighKey: "ID_Graphics_Value_Far"); rangeLowKey: "ID_Graphics_Value_Close", rangeHighKey: "ID_Graphics_Value_Far");
display = bindings.LoadDisplay(); display = bindings.LoadDisplay();
@ -539,19 +574,22 @@ public static class ConfigOptionsPageController
listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString, listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeTextureDetail, read: () => bindings.LoadDisplay().LandscapeTextureDetail,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeTextureDetail = value }), apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeTextureDetail = value }),
defaultValue: 2); defaultValue: 2,
storeOnly: true); // AP-198
BuildMenuRow( BuildMenuRow(
listBox, "ID_Graphics_EnvironmentTextureDetail", TextureDetailChoices, page, resolveString, listBox, "ID_Graphics_EnvironmentTextureDetail", TextureDetailChoices, page, resolveString,
read: () => bindings.LoadDisplay().EnvironmentTextureDetail, read: () => bindings.LoadDisplay().EnvironmentTextureDetail,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { EnvironmentTextureDetail = value }), apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { EnvironmentTextureDetail = value }),
defaultValue: 1); defaultValue: 1,
storeOnly: true); // AP-198
BuildMenuRow( BuildMenuRow(
listBox, "ID_Graphics_TextureFiltering", TextureFilteringChoices, page, resolveString, listBox, "ID_Graphics_TextureFiltering", TextureFilteringChoices, page, resolveString,
read: () => bindings.LoadDisplay().TextureFiltering, read: () => bindings.LoadDisplay().TextureFiltering,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { TextureFiltering = value }), 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 // UNRESOLVED (see class doc / register row): retail's own
// SetDefaultValue(8) does not index this 6-entry choice array. // 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, listBox, "ID_Graphics_LandscapeDrawDistance", LandscapeDrawDistanceChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeDrawDistance, read: () => bindings.LoadDisplay().LandscapeDrawDistance,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }), apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }),
defaultValue: 8); defaultValue: 8,
storeOnly: true); // AP-198
BuildToggleRow( BuildToggleRow(
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString, listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
read: () => bindings.LoadDisplay().BuildingDetailTextures, 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( BuildToggleRow(
listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString, listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString,
read: () => bindings.LoadDisplay().MultiPassAlpha, 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(); display = bindings.LoadDisplay();
} }
@ -595,12 +636,14 @@ public static class ConfigOptionsPageController
listBox, SimpleSliderTemplateIndex, "ID_Input_MouseLookSensitivity", listBox, SimpleSliderTemplateIndex, "ID_Input_MouseLookSensitivity",
min: 0.00999999978f, max: 1f, defaultValue: 0.55f, page, resolveString, min: 0.00999999978f, max: 1f, defaultValue: 0.55f, page, resolveString,
read: () => bindings.LoadCameraTurning().MouseLookSensitivity, 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( BuildToggleRow(
listBox, "ID_Input_InvertMouseLookYAxis", defaultValue: false, page, resolveString, listBox, "ID_Input_InvertMouseLookYAxis", defaultValue: false, page, resolveString,
read: () => bindings.LoadCameraTurning().InvertMouseLookYAxis, 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 // Input_UseMouseTurning: the Config tab's OWN client-local
// UIPreference — DISTINCT from the Gameplay tab macro's // UIPreference — DISTINCT from the Gameplay tab macro's
@ -609,7 +652,8 @@ public static class ConfigOptionsPageController
BuildToggleRow( BuildToggleRow(
listBox, "ID_Input_UseMouseTurning", defaultValue: false, page, resolveString, listBox, "ID_Input_UseMouseTurning", defaultValue: false, page, resolveString,
read: () => bindings.LoadCameraTurning().UseMouseTurning, 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(); cameraTurning = bindings.LoadCameraTurning();
} }
@ -662,13 +706,15 @@ public static class ConfigOptionsPageController
listBox, "ID_UI_ChatFontFace", ChatFontFaceChoices, page, resolveString, listBox, "ID_UI_ChatFontFace", ChatFontFaceChoices, page, resolveString,
read: () => bindings.LoadChat().ChatFontFace, read: () => bindings.LoadChat().ChatFontFace,
apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontFace = value }), apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontFace = value }),
defaultValue: 2); defaultValue: 2,
storeOnly: true); // AP-200
BuildMenuRow( BuildMenuRow(
listBox, "ID_UI_ChatFontSize", ChatFontSizeChoices, page, resolveString, listBox, "ID_UI_ChatFontSize", ChatFontSizeChoices, page, resolveString,
read: () => bindings.LoadChat().ChatFontSizeIndex, read: () => bindings.LoadChat().ChatFontSizeIndex,
apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontSizeIndex = value }), apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontSizeIndex = value }),
defaultValue: 1); defaultValue: 1,
storeOnly: true); // AP-200
chat = bindings.LoadChat(); chat = bindings.LoadChat();
} }
@ -712,7 +758,8 @@ public static class ConfigOptionsPageController
OptionPage page, OptionPage page,
Func<uint, uint, string?> resolveString, Func<uint, uint, string?> resolveString,
Func<bool> read, Func<bool> read,
Action<bool> apply) Action<bool> apply,
bool storeOnly)
{ {
UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex); UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex);
if (row is null) if (row is null)
@ -732,7 +779,7 @@ public static class ConfigOptionsPageController
return; return;
} }
ApplyLabelAndTooltip(checkbox, labelKey, resolveString); ApplyLabelAndTooltip(checkbox, labelKey, resolveString, storeOnly);
bool initial = read(); bool initial = read();
checkbox.Selected = initial; checkbox.Selected = initial;
@ -774,6 +821,7 @@ public static class ConfigOptionsPageController
Func<uint, uint, string?> resolveString, Func<uint, uint, string?> resolveString,
Func<float> read, Func<float> read,
Action<float> apply, Action<float> apply,
bool storeOnly,
string? rangeLowKey = null, string? rangeLowKey = null,
string? rangeHighKey = null) string? rangeHighKey = null)
{ {
@ -787,7 +835,7 @@ public static class ConfigOptionsPageController
} }
if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label) if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label)
SetLabelText(label, labelKey, resolveString); SetLabelText(label, labelKey, resolveString, storeOnly);
if (rangeLowKey is not null) if (rangeLowKey is not null)
SetRangeLabel(row, SliderRangeMinElementId, rangeLowKey, resolveString); SetRangeLabel(row, SliderRangeMinElementId, rangeLowKey, resolveString);
@ -870,7 +918,8 @@ public static class ConfigOptionsPageController
Func<bool> toggleRead, Func<bool> toggleRead,
Action<bool> toggleApply, Action<bool> toggleApply,
Func<float> sliderRead, Func<float> sliderRead,
Action<float> sliderApply) Action<float> sliderApply,
bool storeOnly)
{ {
if (listBox.AddItemFromTemplateList(TrioTemplateIndex) is not UiOptionToggleSlider trio) if (listBox.AddItemFromTemplateList(TrioTemplateIndex) is not UiOptionToggleSlider trio)
{ {
@ -891,7 +940,7 @@ public static class ConfigOptionsPageController
return; return;
} }
ApplyLabelAndTooltip(checkbox, toggleLabelKey, resolveString); ApplyLabelAndTooltip(checkbox, toggleLabelKey, resolveString, storeOnly);
bool toggleInitial = toggleRead(); bool toggleInitial = toggleRead();
checkbox.Selected = toggleInitial; checkbox.Selected = toggleInitial;
@ -940,7 +989,8 @@ public static class ConfigOptionsPageController
Func<uint, uint, string?> resolveString, Func<uint, uint, string?> resolveString,
Func<int> read, Func<int> read,
Action<int> apply, Action<int> apply,
int defaultValue) int defaultValue,
bool storeOnly)
{ {
UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
if (row is null) if (row is null)
@ -952,7 +1002,7 @@ public static class ConfigOptionsPageController
} }
if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label) 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) if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
{ {
@ -1022,7 +1072,8 @@ public static class ConfigOptionsPageController
Func<uint, uint, string?> resolveString, Func<uint, uint, string?> resolveString,
Func<string> read, Func<string> read,
Action<string> apply, Action<string> apply,
string defaultValue) string defaultValue,
bool storeOnly)
{ {
UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
if (row is null) if (row is null)
@ -1034,7 +1085,7 @@ public static class ConfigOptionsPageController
} }
if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label) 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) if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
{ {
@ -1079,7 +1130,7 @@ public static class ConfigOptionsPageController
} }
private static void ApplyLabelAndTooltip( private static void ApplyLabelAndTooltip(
UiButton checkbox, string labelKey, Func<uint, uint, string?> resolveString) UiButton checkbox, string labelKey, Func<uint, uint, string?> resolveString, bool storeOnly)
{ {
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey)); string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
if (label is not null) if (label is not null)
@ -1089,6 +1140,13 @@ public static class ConfigOptionsPageController
$"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — " $"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — "
+ "row renders with no caption rather than invented English."); + "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); string? tooltip = ResolveTooltip(labelKey, resolveString);
if (tooltip is not null) if (tooltip is not null)
checkbox.TooltipText = tooltip; checkbox.TooltipText = tooltip;
@ -1106,7 +1164,8 @@ public static class ConfigOptionsPageController
private static string? ResolveTooltip(string labelKey, Func<uint, uint, string?> resolveString) private static string? ResolveTooltip(string labelKey, Func<uint, uint, string?> resolveString)
=> resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help")); => resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
private static void SetLabelText(UiText label, string labelKey, Func<uint, uint, string?> resolveString) private static void SetLabelText(
UiText label, string labelKey, Func<uint, uint, string?> resolveString, bool storeOnly)
{ {
string? text = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey)); string? text = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
if (text is null) if (text is null)
@ -1116,7 +1175,10 @@ public static class ConfigOptionsPageController
+ "row renders with no caption rather than invented English."); + "row renders with no caption rather than invented English.");
return; 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) private static UiButton? FindCheckbox(UiElement root)

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Input; using AcDream.Core.Input;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
@ -120,6 +121,18 @@ namespace AcDream.App.UI.Layout;
/// retail's <c>OpenOverwriteBindingDialog(&amp;conflicts)</c> — BEFORE reassigning; /// retail's <c>OpenOverwriteBindingDialog(&amp;conflicts)</c> — BEFORE reassigning;
/// only on accept are the losing rows' slots erased and the new chord applied. /// only on accept are the losing rows' slots erased and the new chord applied.
/// </para> /// </para>
///
/// <para>
/// <b>Caption dimming (AD-78, user-directed, 2026-08-11, gate 2).</b> A row
/// whose <see cref="RowView.MappedAction"/> 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
/// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> in
/// <see cref="BuildActionRow"/>. 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.
/// </para>
/// </summary> /// </summary>
public sealed class KeyboardConfigController public sealed class KeyboardConfigController
{ {
@ -332,9 +345,16 @@ public sealed class KeyboardConfigController
string? label = resolveString(RetailInputMapHeaders.StringTableId, row.LabelHash); string? label = resolveString(RetailInputMapHeaders.StringTableId, row.LabelHash);
string? tooltip = resolveString(RetailInputMapHeaders.StringTableId, row.TooltipHash); 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 // The row's own caption — synthesized, composed beside the authored key
// buttons (UiText is sealed; see class doc). Occupies the "Command" column // 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 var captionText = new UiText
{ {
Left = 0f, Left = 0f,
@ -346,14 +366,12 @@ public sealed class KeyboardConfigController
RightAligned = false, RightAligned = false,
Padding = 2f, Padding = 2f,
Anchors = AnchorEdges.Left | AnchorEdges.Top, Anchors = AnchorEdges.Left | AnchorEdges.Top,
DefaultColor = mapped ? Vector4.One : UiRenderContext.StoreOnlyCaptionColor,
}; };
if (label is not null) if (label is not null)
captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) }; captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) };
built.AddChild(captionText); 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 // M1: capture this row's live Activation/Scope ONCE, from the first
// existing binding for the action (every multi-chord action in // existing binding for the action (every multi-chord action in
// KeyBindings.RetailDefaults() shares one Activation/Scope pair across // KeyBindings.RetailDefaults() shares one Activation/Scope pair across

View file

@ -209,6 +209,23 @@ public sealed class UiRenderContext
/// 9 elements in the whole DAT set — every other outlined element uses this).</summary> /// 9 elements in the whole DAT set — every other outlined element uses this).</summary>
public static readonly Vector4 DefaultOutlineColor = new(0f, 0f, 0f, 1f); public static readonly Vector4 DefaultOutlineColor = new(0f, 0f, 0f, 1f);
/// <summary>
/// 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 (<see cref="UiMenu.TextColorGhosted"/>,
/// 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).
/// </summary>
public static readonly Vector4 StoreOnlyCaptionColor = new(0.5f, 0.5f, 0.5f, 1f);
/// <summary> /// <summary>
/// Draw a single line of text with a retail dat font (<see cref="UiDatFont"/>), /// Draw a single line of text with a retail dat font (<see cref="UiDatFont"/>),
/// at <paramref name="x"/>,<paramref name="y"/> = the top-left of the /// at <paramref name="x"/>,<paramref name="y"/> = the top-left of the

View file

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
@ -693,4 +694,127 @@ public sealed class CharacterOptionsPageControllerTests
// degrades to "no text", never a fabricated label. // degrades to "no text", never a fabricated label.
Assert.Equal(50, controller.CharacterPage.Rows.Count); Assert.Equal(50, controller.CharacterPage.Rows.Count);
} }
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
/// <summary>
/// The exact 35 <see cref="CharacterOptionId"/> values this slice dims
/// (<see cref="RowSpec.StoreOnly"/> == true), transcribed independently
/// of <c>CharacterOptionsPageController.Groups</c> from the derivation
/// table in that class's own doc comment. <b>Wiring a future consumer
/// for any of these means removing it from this literal set AND
/// flipping its <c>Groups</c> table entry from <c>StoreOnly</c> to
/// <c>Live</c> consciously — leaving either one stale fails this
/// test.</b>
/// </summary>
private static readonly HashSet<CharacterOptionId> 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<CharacterOptionId> 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<UiTemplateListBox>(
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<UiButton> 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<CharacterOptionsPageController.RowSpec> 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}.");
}
}
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
@ -858,4 +859,108 @@ public sealed class ConfigOptionsPageControllerTests
Assert.True(bound); Assert.True(bound);
Assert.Equal(30, controller.ConfigPage.Rows.Count); 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 }
/// <summary>
/// One literal expectation per option-row-bearing viewport item, in the
/// SAME order <see cref="ConfigOptionsPageController.Bind"/> builds them
/// (39 stacked items: 6 headers + 6 separators + 27 option rows — see
/// <see cref="Bind_ListBoxStacks39Items_SixHeaders_SixSeparators_27OptionRows"/>
/// for the index arithmetic this table shares). <b>Wiring a future
/// consumer for any row here means flipping BOTH this literal's
/// <c>StoreOnly</c> AND the matching <c>ConfigOptionsPageController</c>
/// <c>BindXxxSection</c> call site's own <c>storeOnly:</c> argument
/// consciously — leaving either one stale fails this test.</b>
/// </summary>
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<UiText.Line> 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<UiTemplateListBox>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
UiElement viewport = Assert.Single(listBox.Children);
IReadOnlyList<UiElement> 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<UiOptionToggleSlider>(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}.");
}
}
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Input; using AcDream.Core.Input;
@ -610,4 +611,49 @@ public sealed class KeyboardConfigControllerTests
} }
Assert.True(seen.Count > 100, $"sanity: only {seen.Count} mapped actions seen"); 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);
}
/// <summary>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
/// <see cref="UiText"/> direct child of the key buttons' shared parent
/// (the row container <c>BuildActionRow</c> builds).</summary>
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<UiText>().Single();
}
} }