acdream/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
Erik 300d8189f6 fix #FA4-D6-D7,SF-8: revert three of FA4's four dimming un-dims (IgnoreFellowshipRequests, FellowshipAutoAcceptRequests, FellowshipShareLoot)
The corrected plan D6 (docs/plans/2026-08-11-fellowship-allegiance-campaign.md)
established that retail's client reads neither IgnoreFellowshipRequests nor
FellowshipAutoAcceptRequests on the fellowship-invite path -- both are pure
server-side filters with no client consumer, exactly like the two
allegiance bits they were always meant to parallel. Their claimed consumer
(RetailUiRuntime.TryAutoRespondToFellowshipInvite) is deleted in a sibling
commit this fix round. Both rows revert from Live to StoreOnly.

Mechanism review SF-8 additionally found FellowshipShareLoot's claimed
consumer -- "a second live checkbox surface on the fellowship page" -- is
not a consumer at all: nothing in acdream reads the stored value back
(FormatStatsText uses snapshot.ShareXp only; the 0x00A2 Create builder
carries shareXP alone), and the live-DAT dump confirms its checkbox is a
child of the NOT-in-fellowship frame -- invisible whenever you actually
have a fellowship to loot-share within. A second EDITOR of a value is not
a CONSUMER of it under AD-78's own "drives nothing observable client-side"
definition. FellowshipShareLoot reverts too.

Only FellowshipShareXP survives as genuinely live -- the Create-flow click
reads it directly as the sent shareXP wire bit. Net: 35 (pre-FA4) -> FA4
shipped 31 -> fix round reverts three -> 34 of 50 dimmed / 16 live, ONE
net un-dim from the pre-FA4 baseline, not four. Updated the class doc's
derivation table, the conformance test's ExpectedStoreOnlyIds set, and the
31/19 count assertions to 34/16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:33:42 +02:00

521 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
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-&gt;m_default =
/// PlayerModule::GetDefaultOptionValue(playerModule, this-&gt;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_&lt;Name&gt;</c>
/// / <c>ID_PlayerOption_&lt;Name&gt;_Help</c> against string table
/// <c>0x23000003</c> at row-build time (<c>SetToggleLabel</c>) — never
/// hard-coded English. <c>&lt;Name&gt;</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, 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), 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 (16 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>), all six
/// <c>ListenTo*Chat</c> ids (<c>TurbineChatMembershipGate.cs:105-136</c>
/// gates every Turbine room join on the matching bit), and — landed at
/// Campaign FA slice FA4, D7, SURVIVING the fix-round correction below
/// — <c>FellowshipShareXP</c> (the Create-flow click genuinely reads
/// it as the sent <c>shareXP</c> bit, <c>SocialFellowshipPageController.WireButtons</c>).</description></item>
/// <item><description><b>Dimmed (34 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>HearPkDeathMessages</c>).
///
/// <para>
/// <b>FA4 fix-round correction, 2026-08-12 (D6/D7 plan correction +
/// mechanism SF-8).</b> FA4 originally un-dimmed FOUR rows here:
/// <c>IgnoreFellowshipRequests</c>/<c>FellowshipAutoAcceptRequests</c>
/// (claiming a consumer in a client-side invite auto-respond
/// interceptor) and <c>FellowshipShareXP</c>/<c>FellowshipShareLoot</c>
/// (claiming the fellowship page's own checkbox surface as a
/// consumer). Both claims were wrong, for two SEPARATE reasons:
/// </para>
/// <list type="number">
/// <item><description><b>D6's correction (the plan doc, 2026-08-12):</b>
/// retail's client reads NEITHER option bit on the invite/confirmation
/// path — <c>Handle_Character__ConfirmationRequest @0x005640A0</c>,
/// <c>RecvNotice_FellowshipRequest @0x00490880</c>, and
/// <c>MakeFellowRequestDialog @0x00490620</c> (whose ONLY guard is
/// <c>m_fellowRequestContext</c>) were read in full; a whole-file sweep
/// of both accessors finds zero reads on any confirmation path. ACE
/// filters both bits SERVER-SIDE. The client-side interceptor
/// (<c>RetailUiRuntime.TryAutoRespondToFellowshipInvite</c>) was
/// therefore deleted outright — the fellow-invite dialog always shows,
/// exactly like retail. <c>IgnoreFellowshipRequests</c>/
/// <c>FellowshipAutoAcceptRequests</c> go back to store-only, exactly
/// like the two allegiance bits (<c>IgnoreAllegianceRequests</c>/
/// <c>DisplayAllegianceLogonNotifications</c>) they were always meant
/// to parallel — both pairs are pure server-side filters with NO client
/// consumer.</description></item>
/// <item><description><b>Mechanism SF-8 (the review, same date):</b>
/// <c>FellowshipShareLoot</c>'s claimed consumer — "a second live
/// checkbox surface on the fellowship page" — is not a consumer at
/// all: nothing in acdream ever READS the stored value back
/// (<c>FormatStatsText</c> uses <c>snapshot.ShareXp</c> only; the
/// <c>0x00A2</c> Create builder carries <c>shareXP</c> alone), and the
/// live-DAT dump confirms its checkbox (<c>0x10000273</c>) is a child
/// of the NOT-in-fellowship frame — invisible whenever you actually
/// have a fellowship to loot-share within. A second EDITOR of a stored
/// value is not a consumer by AD-78's own definition ("drives nothing
/// OBSERVABLE client-side"). Only <c>FellowshipShareXP</c> survives —
/// its value is genuinely READ by the Create click.</description></item>
/// </list>
/// <para>
/// Net: 35 (pre-FA4) → FA4 shipped 31 → fix round reverts three of the
/// four un-dims (Ignore, AutoAccept, ShareLoot) → <b>34 of 50 dimmed /
/// 16 live</b>, ONE net un-dim from pre-FA4 baseline
/// (<c>FellowshipShareXP</c> only).
/// </para></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>
/// had a genuine coded mutual-exclusion side effect
/// (<c>RuntimeCharacterState.TrySetOption</c>, MF-2) that the research
/// doc's Group A classification did not capture — this remains true and
/// harmless (the mutual exclusion is enforced regardless of which
/// surface's caption is dimmed), but it is NOT itself a client-visible
/// CONSUMER of the option's VALUE, so both rows stay dimmed per the
/// fix-round correction above.</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. 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", 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", 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", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum
new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests", StoreOnly), // fix-round D6 correction: pure server-side filter, NO client consumer — reverted from FA4's Live (see class doc)
new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications", StoreOnly), // Group A — stays dimmed, D7/AD-78 addendum
new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP", Live), // D7 (Campaign FA slice FA4): consumed by the fellowship Create flow (its value IS the sent shareXP bit)
new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot", StoreOnly), // fix-round mechanism SF-8: no real acdream-side READER of the stored value — reverted from FA4's Live (see class doc)
new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests", StoreOnly), // fix-round D6 correction: pure server-side filter, NO client consumer — reverted from FA4's Live (see class doc)
}),
new("ID_CharacterOption_OtherPlayers_Section", new RowSpec[]
{
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", 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", 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", StoreOnly), // Group D
}),
};
/// <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.");
// 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)
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;
// MUST-FIX 1 (OP4 review-fix round, 2026-08-11): `read` re-seeds
// this row from the LIVE option word on every OnShown (panel
// open, tab switch in) — retail's SaveCurrentValue/GetValue.
// `refresh` pushes the re-read value onto the checkbox WITHOUT
// going through `apply` (which would send it back over the wire).
var row_ = new BoolOptionRow(
initial,
entry.ClientDefault,
apply: value =>
{
checkbox.Selected = value;
bindings.SetOption(spec.Id, value);
},
read: () => bindings.CurrentValue(spec.Id),
refresh: value => checkbox.Selected = 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;
}
}