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; /// /// Campaign OP slice OP4 (2026-08-11): binds the Character tab /// (LayoutDesc 0x21000028, root 0x100001F9, ListBox /// 0x100001FA) — retail's gmCharacterSettingsUI::InitOptions /// @0x004A02F0 — through OP2's template-list mechanism /// () and OP3's per-page /// model. Six authored group headers, 49 toggle /// rows from the 2013 build plus D3's 50th ("Listen to PK death /// messages", — /// wire+store only, register row AP-193), in /// docs/research/2026-08-10-character-options-map.md §2's authored /// order (byte-verified against the same header-boundary decomp §7 of /// docs/research/2026-08-10-options-panel-structure.md cites). /// /// /// Defaults (U1). Retail's UIOption_Checkbox::SetPlayerOption /// @0x00486e80 — the constructor path every AddToggleOption(enum /// PlayerOption) call reaches — sets this->m_default = /// PlayerModule::GetDefaultOptionValue(playerModule, this->m_playerOption) /// (pseudo-C line 147375) as a DIRECT side effect of construction; retail /// never calls the separate SetDefaultValue API for this tab (which /// is why InitOptions itself shows no such call — options-panel- /// structure.md §7 flagged this as the tab's biggest open unknown). This /// IS CharacterOptionTable.ClientDefault — OP1 already ported the /// exact same byte-verified GetDefaultOptionValue @0x005D2A30 /// table. Coordinator correction: the plan's own "U1 closed" note /// (§1) points at UIOption::InqDefaultGameplayOptionProperty /// @0x004ef8d0 reading a DAT DBPropertyCollection at /// DBCache::GetDIDFromEnumStatic(0x16, 2) — that mechanism is real /// (empirically confirmed against the installed DATs: DID /// 0x78000001, three entries — 0x1000007F the Chat tab's /// filter-bitfield property, 0x10000080/0x10000081 = 0.5/1.0 /// the Chat tab's two opacity-slider defaults) but it is /// UIOption_Slider::SetGameplayOptionProperty @0x00485030's /// m_propName-bound path — the Chat/Config tabs' PREFERENCE rows, /// which the structure doc's own §1.2 already established have ZERO /// PlayerOption 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. /// /// /// /// Labels/tooltips (U2). Retail hashes ID_PlayerOption_<Name> /// / ID_PlayerOption_<Name>_Help against string table /// 0x23000003 at row-build time (SetToggleLabel) — never /// hard-coded English. <Name> is retail's OWN enum /// PlayerOption member name (acclient.h:4162-4218), which for /// the six Hear*Chat ids and D3's HearPKDeaths differs from /// acdream's own spelling (ACE's /// ListenTo*Chat convention) — /// carries the correct retail token for hashing, kept deliberately /// separate from the wire/storage id. /// /// public static class CharacterOptionsPageController { /// Character page root — gmCharacterSettingsUI. public const uint RootElementId = 0x100001F9u; /// The row ListBox (dat Type 5) — m_pOptionBox. public const uint ListBoxElementId = 0x100001FAu; /// The ListBox's linked scrollbar. public const uint ScrollbarElementId = 0x100001FBu; /// Template-list index of the header row (Type 12 text). private const int HeaderTemplateIndex = 0; /// Template-list index of the separator row (Type 3 image). private const int SeparatorTemplateIndex = 1; /// Template-list index of the toggle-option row. private const int ToggleTemplateIndex = 2; private const uint StringTableId = 0x23000003u; /// One authored Character-tab row: the wire/storage id, retail's /// OWN PlayerOption enum member name (for the /// ID_PlayerOption_<Name>/_Help string hash — see /// the U2 note above), and (AD-78, /// user-directed, 2026-08-11, gate 2 — see the derivation table below /// this record). public readonly record struct RowSpec(CharacterOptionId Id, string RetailName, bool StoreOnly); /// One authored header group: its section string key plus /// authored-order rows. public readonly record struct GroupSpec(string HeaderKey, RowSpec[] Rows); /// /// AD-78 caption-dimming derivation (user-directed, 2026-08-11, gate 2). /// below is derived from /// docs/research/2026-08-10-character-options-map.md §2/§7.1's /// four-group split (A = wire+store only, no client consumer needed; /// B = needs a client consumer; C = already live, re-pointed to the /// server bit; D = deferred with a register row), CROSS-CHECKED against /// the actual shipped code (a source grep for every /// CharacterOptionId.* reference outside /// CharacterOptionTable/this controller/the wire codec) because /// the research doc predates OP1/OP4's implementation and Group B was /// only PARTIALLY wired (confirmed against /// docs/research/2026-08-11-campaign-op-test-script.md §OP4 /// steps 13-18, written after the code landed): /// /// /// NOT dimmed (16 rows, real acdream-side /// consumer): Group B rows actually bound — /// ViewCombatTarget/AutoTarget/AutoRepeatAttack /// (combat: LiveCombatAttackOperations.cs, /// CombatUiController.cs — Group C re-point), /// VividTargetingIndicator (InteractionRetainedUiComposition.cs:696 /// — Group C), CoordinatesOnRadar /// (LivePresentationComposition.cs:845 — Group C), /// DisableDistanceFog/DisplayTimeStamps/ToggleRun /// (Group B, polled at GameWindow.cs:655-680), /// DragItemOnPlayerOpensSecureTrade (TS-48, /// InteractionRetainedUiComposition.cs:326), all six /// ListenTo*Chat ids (TurbineChatMembershipGate.cs:105-136 /// gates every Turbine room join on the matching bit), and — landed at /// Campaign FA slice FA4, D7, SURVIVING the fix-round correction below /// — FellowshipShareXP (the Create-flow click genuinely reads /// it as the sent shareXP bit, SocialFellowshipPageController.WireButtons). /// Dimmed (34 rows, store-only): every /// remaining Group A row (wire+store only — ACE, not acdream, is the /// consumer) and every remaining Group B row the OP4 gate script's own /// step 16 lists as "no consumer surface" (ShowTooltips, /// SideBySideVitals, SpellDuration, /// AdvancedCombatUI, StayInChatMode, /// DisableMostWeatherEffects, PersistentAtDay, /// FilterLanguage, MainPackPreferred), plus every Group D /// deferral (SalvageMultiple, /// DisableHouseRestrictionEffects, /// HearPkDeathMessages). /// /// /// FA4 fix-round correction, 2026-08-12 (D6/D7 plan correction + /// mechanism SF-8). FA4 originally un-dimmed FOUR rows here: /// IgnoreFellowshipRequests/FellowshipAutoAcceptRequests /// (claiming a consumer in a client-side invite auto-respond /// interceptor) and FellowshipShareXP/FellowshipShareLoot /// (claiming the fellowship page's own checkbox surface as a /// consumer). Both claims were wrong, for two SEPARATE reasons: /// /// /// D6's correction (the plan doc, 2026-08-12): /// retail's client reads NEITHER option bit on the invite/confirmation /// path — Handle_Character__ConfirmationRequest @0x005640A0, /// RecvNotice_FellowshipRequest @0x00490880, and /// MakeFellowRequestDialog @0x00490620 (whose ONLY guard is /// m_fellowRequestContext) 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 /// (RetailUiRuntime.TryAutoRespondToFellowshipInvite) was /// therefore deleted outright — the fellow-invite dialog always shows, /// exactly like retail. IgnoreFellowshipRequests/ /// FellowshipAutoAcceptRequests go back to store-only, exactly /// like the two allegiance bits (IgnoreAllegianceRequests/ /// DisplayAllegianceLogonNotifications) they were always meant /// to parallel — both pairs are pure server-side filters with NO client /// consumer. /// Mechanism SF-8 (the review, same date): /// FellowshipShareLoot'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 /// (FormatStatsText uses snapshot.ShareXp only; the /// 0x00A2 Create builder carries shareXP alone), and the /// live-DAT dump confirms its checkbox (0x10000273) 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 FellowshipShareXP survives — /// its value is genuinely READ by the Create click. /// /// /// Net: 35 (pre-FA4) → FA4 shipped 31 → fix round reverts three of the /// four un-dims (Ignore, AutoAccept, ShareLoot) → 34 of 50 dimmed / /// 16 live, ONE net un-dim from pre-FA4 baseline /// (FellowshipShareXP only). /// /// Flagged ambiguity, resolved by code evidence /// (see final report, not re-litigated here): the research doc's /// §7.1 lists AcceptLootPermits in BOTH Group A's bullet list AND /// Group C's bullet list. The actual consumer /// (LiveSessionRuntimeFactory.cs:497-502, the /consent /// slash command) is a second SETTER surface for the same server bit, /// not a behavioral reader — ACE alone decides whether to honor corpse /// looting (Player_Death.cs:755). Classified Group A / dimmed. /// IgnoreFellowshipRequests/FellowshipAutoAcceptRequests /// had a genuine coded mutual-exclusion side effect /// (RuntimeCharacterState.TrySetOption, 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. /// /// private const bool Live = false; private const bool StoreOnly = true; /// /// The complete 6-group / 50-row authored table — research doc §2 / /// §7, id-for-id, in EXACT authored order. RetailName values /// verified against acclient.h:4162-4218's literal enumerator /// spelling. See the derivation table above for the /// StoreOnly column's sourcing. /// public static readonly GroupSpec[] Groups = { new("ID_CharacterOption_UIBehavior_Section", new RowSpec[] { new(CharacterOptionId.ViewCombatTarget, "ViewCombatTarget", 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 }), }; /// Total authored row count across every group — 50 (49 from /// the 2013 build + D3's HearPKDeaths). Exposed for conformance /// tests. public static int TotalRowCount { get { int count = 0; foreach (GroupSpec group in Groups) count += group.Rows.Length; return count; } } /// The seam this page writes/reads live character-option /// state through — via the /// shared Runtime command surface, exactly like OP3's mouse-turning /// macro and every other Options-panel consumer. public sealed record Bindings( Func CurrentValue, Action SetOption); /// /// Builds all 6 headers + 50 toggle rows + 6 separators (5 interior + /// 1 trailing, matching retail's own AddSeperator call pattern /// — structure doc §7) into 's Character /// ListBox, links its scrollbar, seeds every row's current/default /// triple, and registers each row into . /// /// The already-built Options panel tree (the /// SAME import used) — the /// Character page's ListBox is a descendant of it via the tab host's /// page-slot base-merge. /// The Character tab's /// model () — every /// row registers into this SAME instance. /// Builds one row template's subtree — /// wired directly onto the ListBox's (property 0x64). The production caller /// (RetailUiRuntime.MountOptionsPanel) closes over live DAT /// access exactly like 's shared /// dialog catalog; a hermetic test closes over a committed fixture's /// already-resolved tree instead — this /// controller has no DAT dependency of its own. /// Resolves ONE ID_PlayerOption_*/ /// ID_CharacterOption_*_Section string by (table id, hash) — /// never hard-coded English. Production passes /// DatStringResolver.Resolve; tests can inject a small fake /// table or a constant-null resolver to exercise the "string absent" /// no-invented-text path. /// The live read/write seam for every row. /// if the Character page's ListBox /// did not resolve as a (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. public static bool Bind( ImportedLayout layout, OptionPage page, Func templateResolver, Func 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 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 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; } }