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>
215 lines
11 KiB
C#
215 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// Retail <c>UIElement_Panel</c> (dat class Type <c>8</c>) — the tab-strip host used by
|
|
/// the Options panel and, in principle, any panel that authors a tab table (dat property
|
|
/// <c>0x2E</c>, see <see cref="UiTabTableEntry"/>).
|
|
///
|
|
/// <para>
|
|
/// <b>Naming correction (OP2 rework):</b> the OP2 slice named this class/mechanism
|
|
/// <c>UIElement_TabControl</c>. No such class exists in the named-retail PDB — Type 8 is
|
|
/// registered as <c>UIElement_Panel::Create</c> (<c>UIElement::RegisterElementClass(8,
|
|
/// UIElement_Panel::Create) @0x0046C6B7</c>). Correct anchors:
|
|
/// <c>UIElement_Panel::SetupTabPageHash @0x0046C2E0</c> (tab-table read + default
|
|
/// selection), <c>::Update @0x0046BD00</c> (the one-visible-page switch + tab
|
|
/// Open/Closed state write), <c>::OpenTab @0x0046BE20</c> / <c>::InqTabFromPage
|
|
/// @0x0046BEB0</c> (the click entry points), <c>::ListenToElementMessage @0x0046BF90</c>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Shape correction (OP2 rework, `docs/research/2026-08-11-op2-review-blast.md` +
|
|
/// `docs/research/2026-08-11-op2-review-mechanism.md`):</b> OP2 mapped every dat Type-8
|
|
/// element unconditionally to this class and wired its tab table AT IMPORT TIME
|
|
/// (<c>OnChildrenAttached</c>). Four already-shipped panels (vendor `0x100000B8`,
|
|
/// character sheet root `0x10000227`, spellbook root `0x100002A8`, combat
|
|
/// `0x100000A2`) are Type 8 and authored a tab table, so they silently gained a SECOND,
|
|
/// import-time tab-switcher racing their own existing C# controllers
|
|
/// (<c>CharacterStatController</c>/<c>SpellbookWindowController</c>/
|
|
/// <c>VendorUiController</c> already own this exact switching for their panels; combat
|
|
/// has no re-binder at all — the table would have taken over outright). A fifth Type-8
|
|
/// element, vendor's media-bearing backdrop `0x1000008D`, has NO tab table at all and
|
|
/// simply lost its authored fill because the old bare-<see cref="UiElement"/> base drew
|
|
/// nothing and defaulted <see cref="UiElement.ClickThrough"/> to <c>false</c>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// This class now derives from <see cref="UiDatElement"/> and is DORMANT by default:
|
|
/// importing a Type-8 element only gets authored-media drawing, retail's
|
|
/// <c>ClickThrough = true</c> generic-decoration default, and <see cref="IUiDatStateful"/>
|
|
/// state propagation — identical to the pre-OP2 fallback for every element that doesn't
|
|
/// opt in. The tab-table switching mechanism (button ↔ page ↔ default, Open/Closed state)
|
|
/// only activates when a controller explicitly calls <see cref="ActivateTabBehavior"/> —
|
|
/// today, nobody does (OP2 ships the mechanism only; the Options panel controller,
|
|
/// Campaign OP slice OP3+, is the first caller). This is filed as an intentional
|
|
/// deviation from retail's unconditional per-instance activation — see the register row
|
|
/// added in this same commit.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The tab buttons and page-slot children are ORDINARY imported dat elements — this
|
|
/// widget does <b>not</b> set <see cref="UiElement.ConsumesDatChildren"/>, so
|
|
/// <c>LayoutImporter</c> builds them the normal recursive way (buttons build as
|
|
/// <see cref="UiText"/> with Open/Closed states — retail's tab buttons are Type 0xC
|
|
/// text elements, not Type-1 buttons; page slots build as generic containers whose
|
|
/// OWN children are the mounted page's content via <c>BaseLayoutId</c>/
|
|
/// <c>BaseElement</c> inheritance).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retail anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.3
|
|
/// (the tab table property <c>0x2E</c> struct shape), §10.1 (structural inventory —
|
|
/// tab buttons `0x1000020D..0x1000020F`/`0x1000050B`, page slots
|
|
/// `0x10000211..0x10000213`/`0x1000050C`, Gameplay the authored default). Verified
|
|
/// byte-for-byte against the regenerated <c>options_2100002B.json</c> fixture: four
|
|
/// entries, Gameplay (`0x1000020D`/`0x10000212`) is the sole <c>IsDefault</c> row.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retail's <c>OnVisibilityChanged</c> auto-apply/auto-revert semantics (research doc
|
|
/// §3.6 — hiding a page reverts uncommitted edits, showing one applies + commits) are
|
|
/// OUT of this widget's scope: they belong to the <c>OptionPage</c>/
|
|
/// <c>PlayerOptionPage</c> model a page controller owns (Campaign OP slice OP3+).
|
|
/// This widget only switches which page slot is <see cref="UiElement.Visible"/>.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
|
|
{
|
|
/// <summary>Retail element id this class was ported for: Type 8 = 8.</summary>
|
|
public const uint RetailTypeId = 8u;
|
|
|
|
private readonly IReadOnlyList<UiTabTableEntry> _tabs;
|
|
private readonly List<UiTabTableEntry> _unresolved = new();
|
|
private bool _behaviorActive;
|
|
|
|
public UiTabPanel(
|
|
ElementInfo info,
|
|
Func<uint, (uint tex, int w, int h)> resolve,
|
|
IReadOnlyList<UiTabTableEntry> tabs)
|
|
: base(info, resolve)
|
|
{
|
|
_tabs = tabs;
|
|
}
|
|
|
|
/// <summary>The authored tab table this control was built from, in authored array order.</summary>
|
|
public IReadOnlyList<UiTabTableEntry> Tabs => _tabs;
|
|
|
|
/// <summary>Dat element id of the currently active page slot. 0 before the first
|
|
/// switch (either <see cref="ActivateTabBehavior"/>'s default-entry switch, or a
|
|
/// direct <see cref="SwitchTo"/> call).</summary>
|
|
public uint ActivePageElementId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Fires at the end of every <see cref="SwitchTo"/> call that actually
|
|
/// changes the active page — including the FIRST switch, from
|
|
/// <see cref="ActivateTabBehavior"/>'s default-entry activation (old = 0).
|
|
/// A page-model owner (Campaign OP slice OP3's <c>OptionsPanelController</c>)
|
|
/// subscribes here to drive retail's per-page
|
|
/// <c>OnVisibilityChanged(false)</c>/<c>OnVisibilityChanged(true)</c> pair
|
|
/// (research doc §3.6) on the leaving/entering page — this widget only
|
|
/// owns which page slot is <see cref="UiElement.Visible"/>, not the
|
|
/// page-model semantics layered on top of that switch.
|
|
/// </summary>
|
|
public event Action<uint, uint>? ActivePageChanged;
|
|
|
|
/// <summary>
|
|
/// True once <see cref="ActivateTabBehavior"/> has run. Dormant instances (every
|
|
/// pre-existing Type-8 host today) never flip this.
|
|
/// </summary>
|
|
public bool BehaviorActive => _behaviorActive;
|
|
|
|
/// <summary>
|
|
/// Tab-table entries whose button, page, or both did not resolve against this
|
|
/// element's built subtree the last time <see cref="ActivateTabBehavior"/> ran — a
|
|
/// silently-empty tab table and one that entirely fails to resolve are otherwise
|
|
/// indistinguishable (round-2 review SHOULD-FIX 3), so this is loud instead of a
|
|
/// mere no-op. Each miss is also logged via <see cref="Console.WriteLine"/>.
|
|
/// </summary>
|
|
public IReadOnlyList<UiTabTableEntry> UnresolvedEntries => _unresolved;
|
|
|
|
/// <summary>
|
|
/// Dormant by design (see class doc) — importing a Type-8 element performs no
|
|
/// wiring. <see cref="ActivateTabBehavior"/> is the explicit, controller-driven
|
|
/// opt-in that does the equivalent work once the subtree exists AND a controller
|
|
/// actually wants this element to own tab switching.
|
|
/// </summary>
|
|
void IUiChildrenAttachedListener.OnChildrenAttached()
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opts this instance into retail's tab-table switching mechanism: binds every tab
|
|
/// button's click to switch to its paired page (<c>UIElement_Panel::OpenTab</c>),
|
|
/// then activates the authored default entry — the entry with
|
|
/// <see cref="UiTabTableEntry.IsDefault"/> true. Idempotent; a second call is a
|
|
/// no-op. Retail resolves both the button and the page via
|
|
/// <c>GetChildRecursive</c> (a descendant search, not direct-children-only), which
|
|
/// this ports so cross-layout mounts (a tab page's content built from a different
|
|
/// LayoutDesc, e.g. Configure Keyboard) still resolve.
|
|
/// </summary>
|
|
public void ActivateTabBehavior()
|
|
{
|
|
if (_behaviorActive) return;
|
|
_behaviorActive = true;
|
|
|
|
_unresolved.Clear();
|
|
UiTabTableEntry? defaultEntry = null;
|
|
foreach (UiTabTableEntry entry in _tabs)
|
|
{
|
|
UiElement? button = FindDescendant(this, entry.ButtonElementId);
|
|
UiElement? page = FindDescendant(this, entry.PageElementId);
|
|
if (button is null || page is null)
|
|
{
|
|
_unresolved.Add(entry);
|
|
Console.WriteLine(
|
|
$"[D.2b] UiTabPanel 0x{Info.Id:X8}: tab entry button=0x{entry.ButtonElementId:X8} "
|
|
+ $"page=0x{entry.PageElementId:X8} did not resolve against the built subtree "
|
|
+ $"(button {(button is null ? "MISSING" : "ok")}, page {(page is null ? "MISSING" : "ok")}).");
|
|
}
|
|
|
|
uint pageId = entry.PageElementId;
|
|
RetailTabBinding.SetClick(button, () => SwitchTo(pageId));
|
|
|
|
if (entry.IsDefault)
|
|
defaultEntry = entry;
|
|
}
|
|
|
|
// Retail UIElement_Panel::Update(0, 0) — the guard when neither
|
|
// m_OpenPageToken nor m_OpenTabToken has been seeded — performs no switch at
|
|
// all. No entry authoring 0x32==true means retail never activates a page here;
|
|
// do not fabricate a fallback to the first entry (the OP2 REJECT-review
|
|
// SHOULD-FIX 2 finding).
|
|
if (defaultEntry is { } def)
|
|
SwitchTo(def.PageElementId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Activates the page slot named <paramref name="pageElementId"/>: shows it, hides
|
|
/// every other authored page slot, and sets each tab button's Open/Closed state to
|
|
/// match (<see cref="RetailTabBinding.SetOpen"/>). No-op if the page is already
|
|
/// active. Safe to call directly (e.g. from a test or a controller that wants
|
|
/// programmatic navigation) without going through <see cref="ActivateTabBehavior"/>
|
|
/// first, though the authored click bindings only exist after activation.
|
|
/// </summary>
|
|
public void SwitchTo(uint pageElementId)
|
|
{
|
|
if (ActivePageElementId == pageElementId) return;
|
|
uint previousPageElementId = ActivePageElementId;
|
|
|
|
foreach (UiTabTableEntry entry in _tabs)
|
|
{
|
|
bool active = entry.PageElementId == pageElementId;
|
|
UiElement? page = FindDescendant(this, entry.PageElementId);
|
|
if (page is not null) page.Visible = active;
|
|
|
|
UiElement? button = FindDescendant(this, entry.ButtonElementId);
|
|
RetailTabBinding.SetOpen(button, active);
|
|
}
|
|
|
|
ActivePageElementId = pageElementId;
|
|
ActivePageChanged?.Invoke(previousPageElementId, pageElementId);
|
|
}
|
|
}
|