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

@ -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;
/// <summary>One authored Character-tab row: the wire/storage id plus
/// retail's OWN <c>PlayerOption</c> enum member name (for the
/// <summary>One authored Character-tab row: the wire/storage id, retail's
/// OWN <c>PlayerOption</c> enum member name (for the
/// <c>ID_PlayerOption_&lt;Name&gt;</c>/<c>_Help</c> string hash — see
/// the U2 note above).</summary>
public readonly record struct RowSpec(CharacterOptionId Id, string RetailName);
/// the U2 note above), and <see cref="StoreOnly"/> (AD-78,
/// 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
/// authored-order rows.</summary>
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>
/// The complete 6-group / 50-row authored table — research doc §2 /
/// §7, id-for-id, in EXACT authored order. <c>RetailName</c> values
/// 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>
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)

View file

@ -47,6 +47,18 @@ namespace AcDream.App.UI.Layout;
/// (Campaign CH slice CH6b). <see cref="FilterBlockSpec.CompactWindowId"/> carries the
/// mapping so nothing downstream needs to know retail's raw numbering.
/// </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>
public static class ChatOptionsPageController
{

View file

@ -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;
/// <c>0x004039ed</c>, NOT a per-machine runtime enumeration as the
/// rejected slice's comment claimed).
/// </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>
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<uint, uint, string?> resolveString,
Func<bool> read,
Action<bool> apply)
Action<bool> 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<uint, uint, string?> resolveString,
Func<float> read,
Action<float> 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<bool> toggleRead,
Action<bool> toggleApply,
Func<float> sliderRead,
Action<float> sliderApply)
Action<float> 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<uint, uint, string?> resolveString,
Func<int> read,
Action<int> 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<uint, uint, string?> resolveString,
Func<string> read,
Action<string> 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<uint, uint, string?> resolveString)
UiButton checkbox, string labelKey, Func<uint, uint, string?> 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<uint, uint, string?> resolveString)
=> 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));
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)

View file

@ -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 <c>OpenOverwriteBindingDialog(&amp;conflicts)</c> — BEFORE reassigning;
/// only on accept are the losing rows' slots erased and the new chord applied.
/// </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>
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

View file

@ -209,6 +209,23 @@ public sealed class UiRenderContext
/// 9 elements in the whole DAT set — every other outlined element uses this).</summary>
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>
/// 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