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.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) ───────────
/// <summary>
/// The exact 35 <see cref="CharacterOptionId"/> values this slice dims
/// (<see cref="RowSpec.StoreOnly"/> == true), transcribed independently
/// of <c>CharacterOptionsPageController.Groups</c> from the derivation
/// table in that class's own doc comment. <b>Wiring a future consumer
/// for any of these means removing it from this literal set AND
/// flipping its <c>Groups</c> table entry from <c>StoreOnly</c> to
/// <c>Live</c> consciously — leaving either one stale fails this
/// test.</b>
/// </summary>
private static readonly HashSet<CharacterOptionId> ExpectedStoreOnlyIds =
[
// Group 1 (UI Behavior) — SalvageMultiple (D), MainPackPreferred (B, unbound)
CharacterOptionId.SalvageMultiple,
CharacterOptionId.MainPackPreferred,
// Group 2 (UI Display) — 11 of 15
CharacterOptionId.ShowTooltips,
CharacterOptionId.SideBySideVitals,
CharacterOptionId.SpellDuration,
CharacterOptionId.DisableMostWeatherEffects,
CharacterOptionId.PersistentAtDay,
CharacterOptionId.DisableHouseRestrictionEffects,
CharacterOptionId.UseCraftSuccessDialog,
CharacterOptionId.ConfirmVolatileRareUse,
CharacterOptionId.FilterLanguage,
CharacterOptionId.ShowHelm,
CharacterOptionId.ShowCloak,
// Group 3 (Grouping) — all 6
CharacterOptionId.IgnoreAllegianceRequests,
CharacterOptionId.IgnoreFellowshipRequests,
CharacterOptionId.DisplayAllegianceLogonNotifications,
CharacterOptionId.FellowshipShareXP,
CharacterOptionId.FellowshipShareLoot,
CharacterOptionId.FellowshipAutoAcceptRequests,
// Group 4 (Other Players) — 10 of 11
CharacterOptionId.AcceptLootPermits,
CharacterOptionId.UseDeception,
CharacterOptionId.AllowGive,
CharacterOptionId.IgnoreTradeRequests,
CharacterOptionId.DisplayDateOfBirth,
CharacterOptionId.DisplayAge,
CharacterOptionId.DisplayChessRank,
CharacterOptionId.DisplayFishingSkill,
CharacterOptionId.DisplayNumberDeaths,
CharacterOptionId.DisplayNumberCharacterTitles,
// Group 5 (Character Behavior) — 4 of 7
CharacterOptionId.AdvancedCombatUI,
CharacterOptionId.UseChargeAttack,
CharacterOptionId.LeadMissileTargets,
CharacterOptionId.UseFastMissiles,
// Group 6 (Chat) — 2 of 8
CharacterOptionId.StayInChatMode,
CharacterOptionId.HearPkDeathMessages,
];
[Fact]
public void StoreOnlyRows_MatchTheDerivationTableExactly()
{
HashSet<CharacterOptionId> actualStoreOnly = AllRows()
.Where(static r => r.StoreOnly)
.Select(static r => r.Id)
.ToHashSet();
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
Assert.Equal(35, actualStoreOnly.Count);
Assert.Equal(15, 50 - actualStoreOnly.Count); // the 15 live rows
}
[Fact]
public void Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows()
{
// A non-null constant resolver (unlike this file's usual BindReal()
// default) so every checkbox actually gets a Label -- LabelColor
// itself is set unconditionally either way, but this keeps the
// built tree representative of a real DAT string table.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fakeBindings = new FakeBindings();
bool bound = CharacterOptionsPageController.Bind(
layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => "x",
fakeBindings.ToBindings());
Assert.True(bound);
var listBox = Assert.IsType<UiTemplateListBox>(
layout.FindElement(CharacterOptionsPageController.ListBoxElementId));
UiElement viewport = Assert.Single(listBox.Children);
// Filter for "contains a checkbox leaf" rather than asserting a
// concrete non-checkbox widget type for headers/separators -- robust
// to either template's underlying DatWidgetFactory class, exactly
// like the controller's own FindCheckbox degrade-gracefully pattern.
const uint ToggleCheckboxElementId = 0x10000219u;
List<UiButton> checkboxesInOrder = viewport.Children
.Select(item => UiElement.FindDescendant(item, ToggleCheckboxElementId) as UiButton)
.Where(static cb => cb is not null)
.Select(static cb => cb!)
.ToList();
Assert.Equal(50, checkboxesInOrder.Count);
List<CharacterOptionsPageController.RowSpec> specsInOrder = AllRows().ToList();
Assert.Equal(50, specsInOrder.Count);
for (int i = 0; i < 50; i++)
{
CharacterOptionsPageController.RowSpec spec = specsInOrder[i];
Vector4 expected = spec.StoreOnly ? UiRenderContext.StoreOnlyCaptionColor : Vector4.One;
Assert.True(
expected == checkboxesInOrder[i].LabelColor,
$"row {i} ({spec.RetailName}, 0x{(uint)spec.Id:X2}): expected "
+ $"{(spec.StoreOnly ? "DIMMED" : "LIVE")} caption color {expected} but the "
+ $"built checkbox rendered {checkboxesInOrder[i].LabelColor}.");
}
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.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 }
/// <summary>
/// One literal expectation per option-row-bearing viewport item, in the
/// SAME order <see cref="ConfigOptionsPageController.Bind"/> builds them
/// (39 stacked items: 6 headers + 6 separators + 27 option rows — see
/// <see cref="Bind_ListBoxStacks39Items_SixHeaders_SixSeparators_27OptionRows"/>
/// for the index arithmetic this table shares). <b>Wiring a future
/// consumer for any row here means flipping BOTH this literal's
/// <c>StoreOnly</c> AND the matching <c>ConfigOptionsPageController</c>
/// <c>BindXxxSection</c> call site's own <c>storeOnly:</c> argument
/// consciously — leaving either one stale fails this test.</b>
/// </summary>
private static readonly (int ViewportIndex, RowKind Kind, bool StoreOnly, string Label)[]
DimmingExpectations =
{
(1, RowKind.Menu, true, "Sound Features"), // AP-199
(2, RowKind.TrioToggle, false, "Disable Sound Effects"), // LIVE
(3, RowKind.TrioToggle, false, "Disable Ambient Sound"), // LIVE
(4, RowKind.TrioToggle, true, "Disable Interface Sound"), // AP-174/AP-199
(5, RowKind.Toggle, true, "Play Sound Only When Active"), // AP-199
(8, RowKind.Slider, true, "Camera Stiffness"), // TS-74
(9, RowKind.Slider, true, "Camera Adjustment Speed"), // TS-74
(10, RowKind.Slider, false, "Field Of View"), // NEXT-LAUNCH
(11, RowKind.Toggle, true, "Align To Slope"), // TS-74
(14, RowKind.Menu, false, "Resolution"), // LIVE
(15, RowKind.Toggle, false, "Full Screen"), // LIVE
(16, RowKind.Toggle, false, "Sync To Refresh"), // NEXT-LAUNCH
(17, RowKind.Slider, true, "Screen Brightness"), // review S2
(18, RowKind.Toggle, true, "Automatic Degrades"), // AP-198
(19, RowKind.Slider, true, "Graphics Performance"), // AP-198
(20, RowKind.Slider, true, "Degrade Distance"), // AP-198
(23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198
(24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198
(25, RowKind.Menu, true, "Texture Filtering"), // AP-198
(26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198
(27, RowKind.Toggle, true, "Building Detail Textures"), // AP-198
(28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198
(31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74
(32, RowKind.Toggle, true, "Invert Mouselook Y Axis"), // TS-74
(33, RowKind.Toggle, true, "Use Mouse Turning"), // TS-74
(36, RowKind.Menu, true, "Chat Font Face"), // AP-200
(37, RowKind.Menu, true, "Chat Font Size"), // AP-200
};
private static Vector4? FindTextLineColor(UiElement root, uint elementId)
{
if (UiElement.FindDescendant(root, elementId) is not UiText text)
return null;
IReadOnlyList<UiText.Line> lines = text.LinesProvider();
return lines.Count == 0 ? null : lines[0].Color;
}
[Fact]
public void CaptionDimming_MatchesTheStoreOnlySetExactly()
{
// A non-null constant resolver is required here (unlike most of this
// file's BindReal() default): SetLabelText's dim color is baked into
// the UiText.Line closure only when a label actually resolves, so a
// null resolver would leave every slider/menu label's LinesProvider
// at its empty default and hide the very thing this test checks.
(OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => "x");
Assert.True(bound);
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
UiElement viewport = Assert.Single(listBox.Children);
IReadOnlyList<UiElement> items = viewport.Children.ToList();
Assert.Equal(39, items.Count);
const uint ToggleCheckboxElementId = 0x10000219u;
const uint SliderLabelElementId = 0x1000021Bu;
const uint MenuLabelElementId = 0x10000223u;
foreach ((int index, RowKind kind, bool storeOnly, string label) in DimmingExpectations)
{
Vector4 expected = storeOnly
? UiRenderContext.StoreOnlyCaptionColor
: Vector4.One;
Vector4? actual = kind switch
{
RowKind.Toggle =>
(UiElement.FindDescendant(items[index], ToggleCheckboxElementId) as UiButton)?.LabelColor,
RowKind.TrioToggle =>
Assert.IsType<UiOptionToggleSlider>(items[index]).Toggle?.LabelColor,
RowKind.Slider => FindTextLineColor(items[index], SliderLabelElementId),
RowKind.Menu => FindTextLineColor(items[index], MenuLabelElementId),
_ => throw new InvalidOperationException($"unhandled row kind {kind}"),
};
Assert.True(
actual.HasValue,
$"{label} (viewport index {index}, kind {kind}): could not locate the "
+ "caption widget/line to check its color.");
Assert.True(
expected == actual.Value,
$"{label} (viewport index {index}): expected "
+ $"{(storeOnly ? "DIMMED" : "LIVE")} caption color {expected} but the "
+ $"built widget rendered {actual.Value}.");
}
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.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);
}
/// <summary>The row's synthesized caption (composed beside the authored key
/// buttons -- see KeyboardConfigController's class doc) has no stable dat
/// element id of its own, so it is located structurally: the ONLY
/// <see cref="UiText"/> direct child of the key buttons' shared parent
/// (the row container <c>BuildActionRow</c> builds).</summary>
private static UiText RowCaption(KeyboardConfigController.RowView row)
{
UiElement parent = row.KeyButtons.First().Parent
?? throw new InvalidOperationException("row's key button has no parent element");
return parent.Children.OfType<UiText>().Single();
}
}