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();
+ }
}