feat(ui): Campaign OP slice OP4 — the Character tab
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's template-list mechanism and OP3's OptionPage model: 6 authored group headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK death messages", AP-193) in research doc §2's authored order, each row resolved by PlayerOption id through CharacterOptionTable, seeded from live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable. ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption @0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm the directive was followed), labels/tooltips resolved by name from string table 0x23000003 (never hard-coded English), and registered with OptionsPanelController.CharacterPage. Apply/Reset/Defaults (0x100001FC/FD/FE) are now wired per-page via a scoped subtree search (UiElement.FindDescendant, promoted from UiTabPanel) since Character/ Chat/Config each author their own physical instance under the SAME element ids. Consumers: Group A (29 ids) wire+store only via the existing SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display Timestamps prefixes new transcript lines (RuntimeCommunicationState. DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off (WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as Default Movement inverts the walk-mode modifier's default (RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget (CharacterOptionCombatSettingsSource), VividTargetingIndicator/ CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local GameplaySettings record to the canonical server bit — closing two previously-unfiled divergences where AutoRepeatAttack and AcceptCorpseLootingPermissions never reached the wire despite being retail auto-save ids. TS-73 narrowed to its two still-open cases; TS-75..TS-80 file the genuine gaps (no day/night force, no weather- particle/profanity-filter/salvage/housing/pickup-preference subsystem, fellowship-create's unaudited client-sourced field) rather than inventing stand-ins. Conformance: CharacterOptionsPageControllerTests pins all 50 rows against CharacterOptionTable in both directions (an invented or dropped row fails the build), the authored group/order row-by-row, and the build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against the committed fixture. 52 new tests; full solution suite 13,008 passed / 4 skipped / 0 failed (was 12,956/4/0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7e9f372ce8
commit
22b86b9ff4
25 changed files with 1674 additions and 41 deletions
392
src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
Normal file
392
src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP4 (2026-08-11): binds the Character tab
|
||||
/// (LayoutDesc <c>0x21000028</c>, root <c>0x100001F9</c>, ListBox
|
||||
/// <c>0x100001FA</c>) — retail's <c>gmCharacterSettingsUI::InitOptions
|
||||
/// @0x004A02F0</c> — through OP2's template-list mechanism
|
||||
/// (<see cref="UiTemplateListBox"/>) and OP3's per-page
|
||||
/// <see cref="OptionPage"/> model. Six authored group headers, 49 toggle
|
||||
/// rows from the 2013 build plus D3's 50th ("Listen to PK death
|
||||
/// messages", <see cref="CharacterOptionId.HearPkDeathMessages"/> —
|
||||
/// wire+store only, register row AP-193), in
|
||||
/// <c>docs/research/2026-08-10-character-options-map.md</c> §2's authored
|
||||
/// order (byte-verified against the same header-boundary decomp §7 of
|
||||
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> cites).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Defaults (U1).</b> Retail's <c>UIOption_Checkbox::SetPlayerOption
|
||||
/// @0x00486e80</c> — the constructor path every <c>AddToggleOption(enum
|
||||
/// PlayerOption)</c> call reaches — sets <c>this->m_default =
|
||||
/// PlayerModule::GetDefaultOptionValue(playerModule, this->m_playerOption)</c>
|
||||
/// (pseudo-C line 147375) as a DIRECT side effect of construction; retail
|
||||
/// never calls the separate <c>SetDefaultValue</c> API for this tab (which
|
||||
/// is why <c>InitOptions</c> itself shows no such call — options-panel-
|
||||
/// structure.md §7 flagged this as the tab's biggest open unknown). This
|
||||
/// IS <c>CharacterOptionTable.ClientDefault</c> — OP1 already ported the
|
||||
/// exact same byte-verified <c>GetDefaultOptionValue @0x005D2A30</c>
|
||||
/// table. <b>Coordinator correction:</b> the plan's own "U1 closed" note
|
||||
/// (§1) points at <c>UIOption::InqDefaultGameplayOptionProperty
|
||||
/// @0x004ef8d0</c> reading a DAT <c>DBPropertyCollection</c> at
|
||||
/// <c>DBCache::GetDIDFromEnumStatic(0x16, 2)</c> — that mechanism is real
|
||||
/// (empirically confirmed against the installed DATs: DID
|
||||
/// <c>0x78000001</c>, three entries — <c>0x1000007F</c> the Chat tab's
|
||||
/// filter-bitfield property, <c>0x10000080</c>/<c>0x10000081</c> = 0.5/1.0
|
||||
/// the Chat tab's two opacity-slider defaults) but it is
|
||||
/// <c>UIOption_Slider::SetGameplayOptionProperty @0x00485030</c>'s
|
||||
/// <c>m_propName</c>-bound path — the Chat/Config tabs' PREFERENCE rows,
|
||||
/// which the structure doc's own §1.2 already established have ZERO
|
||||
/// <c>PlayerOption</c> rows. It does not apply to this tab; the zero
|
||||
/// PlayerOption-range keys in that DBProperties collection confirm it by
|
||||
/// construction. See this slice's final report for the full trace.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Labels/tooltips (U2).</b> Retail hashes <c>ID_PlayerOption_<Name></c>
|
||||
/// / <c>ID_PlayerOption_<Name>_Help</c> against string table
|
||||
/// <c>0x23000003</c> at row-build time (<c>SetToggleLabel</c>) — never
|
||||
/// hard-coded English. <c><Name></c> is retail's OWN <c>enum
|
||||
/// PlayerOption</c> member name (<c>acclient.h:4162-4218</c>), which for
|
||||
/// the six <c>Hear*Chat</c> ids and D3's <c>HearPKDeaths</c> differs from
|
||||
/// acdream's own <see cref="CharacterOptionId"/> spelling (ACE's
|
||||
/// <c>ListenTo*Chat</c> convention) — <see cref="RowSpec.RetailName"/>
|
||||
/// carries the correct retail token for hashing, kept deliberately
|
||||
/// separate from the wire/storage id.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CharacterOptionsPageController
|
||||
{
|
||||
/// <summary>Character page root — <c>gmCharacterSettingsUI</c>.</summary>
|
||||
public const uint RootElementId = 0x100001F9u;
|
||||
|
||||
/// <summary>The row ListBox (dat Type 5) — <c>m_pOptionBox</c>.</summary>
|
||||
public const uint ListBoxElementId = 0x100001FAu;
|
||||
|
||||
/// <summary>The ListBox's linked scrollbar.</summary>
|
||||
public const uint ScrollbarElementId = 0x100001FBu;
|
||||
|
||||
/// <summary>Template-list index of the header row (Type 12 text).</summary>
|
||||
private const int HeaderTemplateIndex = 0;
|
||||
|
||||
/// <summary>Template-list index of the separator row (Type 3 image).</summary>
|
||||
private const int SeparatorTemplateIndex = 1;
|
||||
|
||||
/// <summary>Template-list index of the toggle-option row.</summary>
|
||||
private const int ToggleTemplateIndex = 2;
|
||||
|
||||
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
|
||||
/// <c>ID_PlayerOption_<Name></c>/<c>_Help</c> string hash — see
|
||||
/// the U2 note above).</summary>
|
||||
public readonly record struct RowSpec(CharacterOptionId Id, string RetailName);
|
||||
|
||||
/// <summary>One authored header group: its section string key plus
|
||||
/// authored-order rows.</summary>
|
||||
public readonly record struct GroupSpec(string HeaderKey, RowSpec[] Rows);
|
||||
|
||||
/// <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.
|
||||
/// </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("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("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("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("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("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"),
|
||||
// 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"),
|
||||
}),
|
||||
};
|
||||
|
||||
/// <summary>Total authored row count across every group — 50 (49 from
|
||||
/// the 2013 build + D3's HearPKDeaths). Exposed for conformance
|
||||
/// tests.</summary>
|
||||
public static int TotalRowCount
|
||||
{
|
||||
get
|
||||
{
|
||||
int count = 0;
|
||||
foreach (GroupSpec group in Groups) count += group.Rows.Length;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The seam this page writes/reads live character-option
|
||||
/// state through — <see cref="RuntimeCharacterOptionsState"/> via the
|
||||
/// shared Runtime command surface, exactly like OP3's mouse-turning
|
||||
/// macro and every other Options-panel consumer.</summary>
|
||||
public sealed record Bindings(
|
||||
Func<CharacterOptionId, bool> CurrentValue,
|
||||
Action<CharacterOptionId, bool> SetOption);
|
||||
|
||||
/// <summary>
|
||||
/// Builds all 6 headers + 50 toggle rows + 6 separators (5 interior +
|
||||
/// 1 trailing, matching retail's own <c>AddSeperator</c> call pattern
|
||||
/// — structure doc §7) into <paramref name="layout"/>'s Character
|
||||
/// ListBox, links its scrollbar, seeds every row's current/default
|
||||
/// triple, and registers each row into <paramref name="page"/>.
|
||||
/// </summary>
|
||||
/// <param name="layout">The already-built Options panel tree (the
|
||||
/// SAME import <see cref="OptionsPanelController.Bind"/> used) — the
|
||||
/// Character page's ListBox is a descendant of it via the tab host's
|
||||
/// page-slot base-merge.</param>
|
||||
/// <param name="page">The Character tab's <see cref="OptionPage"/>
|
||||
/// model (<see cref="OptionsPanelController.CharacterPage"/>) — every
|
||||
/// row registers into this SAME instance.</param>
|
||||
/// <param name="templateResolver">Builds one row template's subtree —
|
||||
/// wired directly onto the ListBox's <see cref="UiTemplateListBox.
|
||||
/// TemplateResolver"/> (property <c>0x64</c>). The production caller
|
||||
/// (<c>RetailUiRuntime.MountOptionsPanel</c>) closes over live DAT
|
||||
/// access exactly like <see cref="RetailDialogFactory"/>'s shared
|
||||
/// dialog catalog; a hermetic test closes over a committed fixture's
|
||||
/// already-resolved <see cref="ElementInfo"/> tree instead — this
|
||||
/// controller has no DAT dependency of its own.</param>
|
||||
/// <param name="resolveString">Resolves ONE <c>ID_PlayerOption_*</c>/
|
||||
/// <c>ID_CharacterOption_*_Section</c> string by (table id, hash) —
|
||||
/// never hard-coded English. Production passes
|
||||
/// <c>DatStringResolver.Resolve</c>; tests can inject a small fake
|
||||
/// table or a constant-null resolver to exercise the "string absent"
|
||||
/// no-invented-text path.</param>
|
||||
/// <param name="bindings">The live read/write seam for every row.</param>
|
||||
/// <returns><see langword="false"/> if the Character page's ListBox
|
||||
/// did not resolve as a <see cref="UiTemplateListBox"/> (a missing or
|
||||
/// malformed import) — the caller logs and the Character tab simply
|
||||
/// has no rows, matching every other "controller could not find its
|
||||
/// root" degradation in this codebase.</returns>
|
||||
public static bool Bind(
|
||||
ImportedLayout layout,
|
||||
OptionPage page,
|
||||
Func<uint, uint, UiElement?> templateResolver,
|
||||
Func<uint, uint, string?> resolveString,
|
||||
Bindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(page);
|
||||
ArgumentNullException.ThrowIfNull(templateResolver);
|
||||
ArgumentNullException.ThrowIfNull(resolveString);
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
|
||||
if (layout.FindElement(ListBoxElementId) is not UiTemplateListBox listBox)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterOptionsPageController: ListBox 0x{ListBoxElementId:X8} "
|
||||
+ "not found (or not a UiTemplateListBox) in the built Options panel tree — "
|
||||
+ "the Character tab will have no rows.");
|
||||
return false;
|
||||
}
|
||||
|
||||
listBox.TemplateResolver = templateResolver;
|
||||
|
||||
if (layout.FindElement(ScrollbarElementId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = listBox.Scroll;
|
||||
else
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterOptionsPageController: scrollbar 0x{ScrollbarElementId:X8} "
|
||||
+ "not found — the Character tab's row list will not scroll.");
|
||||
|
||||
for (int groupIndex = 0; groupIndex < Groups.Length; groupIndex++)
|
||||
{
|
||||
GroupSpec group = Groups[groupIndex];
|
||||
BuildHeaderRow(listBox, group.HeaderKey, resolveString);
|
||||
|
||||
foreach (RowSpec spec in group.Rows)
|
||||
BuildToggleRow(listBox, spec, page, resolveString, bindings);
|
||||
|
||||
// Structure doc §7: "6 headers, 5 interior separators + 1
|
||||
// trailing" — one separator after EVERY group, including the
|
||||
// last (the trailing separator before the ListBox's own
|
||||
// bottom padding; Apply/Reset/Defaults are separate elements
|
||||
// outside the ListBox, not part of this row sequence).
|
||||
BuildSeparatorRow(listBox);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void BuildHeaderRow(
|
||||
UiTemplateListBox listBox, string headerKey, Func<uint, uint, string?> resolveString)
|
||||
{
|
||||
if (listBox.AddItemFromTemplateList(HeaderTemplateIndex) is not UiText header)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterOptionsPageController: header template did not build as "
|
||||
+ $"UiText for '{headerKey}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(headerKey));
|
||||
if (label is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterOptionsPageController: header string '{headerKey}' did not "
|
||||
+ "resolve from the DAT string table — the row renders with no text rather "
|
||||
+ "than an invented label.");
|
||||
return;
|
||||
}
|
||||
|
||||
header.LinesProvider = () => new[] { new UiText.Line(label, header.DefaultColor) };
|
||||
}
|
||||
|
||||
private static void BuildSeparatorRow(UiTemplateListBox listBox)
|
||||
{
|
||||
if (listBox.AddItemFromTemplateList(SeparatorTemplateIndex) is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[D.2b] CharacterOptionsPageController: separator template did not build.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildToggleRow(
|
||||
UiTemplateListBox listBox,
|
||||
RowSpec spec,
|
||||
OptionPage page,
|
||||
Func<uint, uint, string?> resolveString,
|
||||
Bindings bindings)
|
||||
{
|
||||
UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex);
|
||||
if (row is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterOptionsPageController: toggle template did not build for "
|
||||
+ $"{spec.RetailName} (0x{(uint)spec.Id:X2}).");
|
||||
return;
|
||||
}
|
||||
|
||||
// The toggle template's root (0x10000218) is a plain Type-3
|
||||
// container; its one real child is the checkbox leaf
|
||||
// (0x10000219, UIOption_Checkbox -> BuildCheckbox -> UiButton).
|
||||
UiButton? checkbox = FindCheckbox(row);
|
||||
if (checkbox is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterOptionsPageController: no checkbox child found in the "
|
||||
+ $"toggle row for {spec.RetailName} (0x{(uint)spec.Id:X2}).");
|
||||
return;
|
||||
}
|
||||
|
||||
string labelKey = $"ID_PlayerOption_{spec.RetailName}";
|
||||
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
|
||||
if (label is not null)
|
||||
checkbox.Label = label;
|
||||
else
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterOptionsPageController: label '{labelKey}' did not resolve — "
|
||||
+ "row renders with no caption rather than invented English.");
|
||||
|
||||
string? tooltip = resolveString(
|
||||
StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
|
||||
if (tooltip is not null)
|
||||
checkbox.TooltipText = tooltip;
|
||||
|
||||
if (!CharacterOptionTable.TryGet(spec.Id, out CharacterOptionTableEntry entry))
|
||||
{
|
||||
// Table completeness is conformance-tested — this can only
|
||||
// fire if the table and this authored list disagree, which
|
||||
// the test suite catches before it ever reaches a live DAT.
|
||||
Console.WriteLine(
|
||||
$"[D.2b] CharacterOptionsPageController: {spec.RetailName} "
|
||||
+ $"(0x{(uint)spec.Id:X2}) is not in CharacterOptionTable.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool initial = bindings.CurrentValue(spec.Id);
|
||||
checkbox.Selected = initial;
|
||||
|
||||
var row_ = new BoolOptionRow(
|
||||
initial,
|
||||
entry.ClientDefault,
|
||||
apply: value =>
|
||||
{
|
||||
checkbox.Selected = value;
|
||||
bindings.SetOption(spec.Id, value);
|
||||
});
|
||||
page.Register(row_);
|
||||
|
||||
checkbox.OnClick = () => row_.SetCurrentValue(checkbox.Selected);
|
||||
}
|
||||
|
||||
private static UiButton? FindCheckbox(UiElement root)
|
||||
{
|
||||
if (root is UiButton direct) return direct;
|
||||
foreach (UiElement child in root.Children)
|
||||
if (child is UiButton button)
|
||||
return button;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue