Root cause: DatWidgetFactory builds Config-tab Type-0x10000038 menu leaves as bare UiMenu instances (matching the vendor/chat channel menu pattern), but unlike those two controllers, ConfigOptionsPageController never wired the menu's sprite/font/geometry properties after Bind — so every dropdown rendered as plain text with no button well, no arrow cap, and opened no popup on click (#374's fix only corrected click ROUTING, not the missing chrome). Fix: ConfigOptionsPageController.ApplyMenuChrome wires every Config-tab menu row with the SAME retail sprite ids VendorUiController/ ChatWindowController's channel menu already use for this shared popup catalog (LayoutDesc 0x21000043), verified against the live DAT via OptionsPanelLiveMountProbeTests' ProbeConfigMenuChrome/ ProbeConfigMenuPopupChrome probes. Regressed by ConfigOptionsPageControllerTests.MenuRow_SoundFeatures_OpensAndSelects ThroughRealHitPath_UsingAuthoredPopupGeometry, which drives the real click-to-open + item-pick path through the authored popup geometry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1348 lines
65 KiB
C#
1348 lines
65 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.UI;
|
|
using AcDream.UI.Abstractions.Panels.Settings;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign OP slice OP6 (2026-08-11): binds the Config tab (LayoutDesc
|
|
/// <c>0x21000029</c>, root <c>0x100001FF</c>, ListBox <c>0x10000200</c>) —
|
|
/// retail's <c>gmConfigUI::InitOptions @0x0049E400</c> — through OP2's
|
|
/// template-list mechanism and OP3's per-page <see cref="OptionPage"/>
|
|
/// model, exactly like <see cref="CharacterOptionsPageController"/>/
|
|
/// <see cref="ChatOptionsPageController"/>.
|
|
///
|
|
/// <para>
|
|
/// <b>The row table below is transcribed directly from the decompiled
|
|
/// registration, not from the research docs' own row table.</b> Two
|
|
/// grep-named-first sources supersede
|
|
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §4/§9's
|
|
/// summary (which itself flagged the slider-caption pairing as UNVERIFIED —
|
|
/// its own "U4"):
|
|
/// <list type="bullet">
|
|
/// <item><description><c>gmConfigUI::InitOptions @0x0049E400</c> — the
|
|
/// authored row ORDER, widget shape per row (<c>AddMenuOption</c>/
|
|
/// <c>AddToggleOption</c>/<c>AddSliderOption</c>/
|
|
/// <c>AddToggleWithSliderOption</c>), and every <c>SetDefaultValue</c>
|
|
/// literal.</description></item>
|
|
/// <item><description><c>gmClient::InitUIPreferences @0x004035b0</c> — the
|
|
/// COMPLETE <c>UIPreferences::AttachPreference</c> registration table:
|
|
/// every row's exact label string key (<c>ID_<Key></c>, hashed
|
|
/// against string table <c>0x23000003</c>, table-enum <c>0x10000003</c>
|
|
/// matching <c>AddHeader</c>'s own convention), its tooltip key
|
|
/// (<c><label>_Help</c> — verified at every non-truncated call site,
|
|
/// applied uniformly to every toggle/trio/slider/menu row's own
|
|
/// interactive widget as of the OP6 rework round, review S3 — the
|
|
/// rejected slice had wired it onto toggle/trio rows only), every slider's
|
|
/// real-unit
|
|
/// <c>UIPreferences::SetPreferenceRange</c> (NOT the widget's [0,1] scalar
|
|
/// space — <see cref="ToNormalized"/>/<see cref="FromNormalized"/> convert
|
|
/// at the build site), and every menu's <c>UIPreferences::SetEnumChoices</c>
|
|
/// string array.</description></item>
|
|
/// </list>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>U4 CORRECTED at the OP6 rework round (2026-08-11, review
|
|
/// <c>docs/research/2026-08-11-op6-review.md</c> finding M1): retail DOES
|
|
/// ship six slider range captions on this tab — the rejected slice's "zero
|
|
/// captions" claim was a Binary Ninja constant-folding artifact, the SAME
|
|
/// class of bug the header-string globals a few lines above already had to
|
|
/// route around.</b> BN renders every
|
|
/// <c>PlayerOptionPage::SetSliderLabel(this, slot, 0, 0)</c> call with
|
|
/// literal zero operands, but the raw bytes at each call site
|
|
/// (<c>0x0049E4C6</c>/<c>0x0049E51D</c>/.../<c>0x0049E68E</c>, PE-byte-
|
|
/// verified against <c>C:\Users\erikn\Downloads\acclient.exe</c>) are
|
|
/// <c>mov ecx, [ID_Graphics_Value_<High>]</c> /
|
|
/// <c>mov edx, [ID_Graphics_Value_<Low>]</c> — reads of the SAME
|
|
/// runtime-filled string-id globals the structure doc's §4 already found,
|
|
/// not immediates. All six labelled sliders pair as: Camera Stiffness
|
|
/// (Soft/Hard), Camera Adjustment Speed (Slow/Fast), Field Of View
|
|
/// (Narrow/Wide), Screen Brightness (Dark/Bright), Graphics Performance
|
|
/// (Speed/Detail), Degrade Distance (Close/Far) — declaration order,
|
|
/// semantically obvious pairing, cross-checked against OP5's own PORTED
|
|
/// mechanism for the exact same BN artifact
|
|
/// (<c>ChatOptionsPageController.BuildOpacitySliders</c>'s
|
|
/// Transparent/Opaque pair, <c>0x0049FD37</c>). Mouse Look Sensitivity (the
|
|
/// SEVENTH slider, template idx3) genuinely gets NO <c>SetSliderLabel</c>
|
|
/// call — that ONE omission is real and stays un-captioned; every idx6 row
|
|
/// gets its low/high range-caption children (<c>0x1000021E</c>/
|
|
/// <c>0x1000021F</c>) populated via <see cref="BuildSliderRow"/>'s optional
|
|
/// <c>rangeLowKey</c>/<c>rangeHighKey</c> parameters.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>The row-template array (8 entries, Config's OWN ListBox property
|
|
/// <c>0x64</c>, cross-checked against the committed
|
|
/// <c>options_config_21000029.json</c>/<c>options_2100002B.json</c>
|
|
/// fixtures):</b> idx0 header (Type 12 text, <c>0x10000216</c>); idx1
|
|
/// separator (Type 3, <c>0x10000217</c>); idx2 toggle row (Type 3,
|
|
/// <c>0x10000218</c>, one <c>UIOption_Checkbox</c> child <c>0x10000219</c>);
|
|
/// idx3 simple slider row (Type 3, <c>0x1000021A</c>, name text
|
|
/// <c>0x1000021B</c> + slider <c>0x1000021C</c> — used ONLY for Mouse Look
|
|
/// Sensitivity, retail's own <c>arg3=0</c> row); idx4 menu row (Type 3,
|
|
/// <c>0x10000222</c>, name text <c>0x10000223</c> + menu
|
|
/// <c>0x10000224</c>); idx5 toggle+slider trio (Type
|
|
/// <c>0x10000036</c> = <see cref="UiOptionToggleSlider"/>,
|
|
/// <c>0x10000220</c>, checkbox <c>0x10000219</c> + slider
|
|
/// <c>0x1000021C</c>, no separate name text — the checkbox's OWN label
|
|
/// carries the row); idx6 range-captioned slider row (Type 3,
|
|
/// <c>0x1000021D</c>, name text <c>0x1000021B</c> + slider
|
|
/// <c>0x1000021C</c> + POPULATED range-caption texts <c>0x1000021E</c>/
|
|
/// <c>0x1000021F</c> (OP6 rework, review M1 — see class doc's U4 note) —
|
|
/// the six <c>arg3=1</c> sliders); idx7 a SECOND
|
|
/// toggle+slider trio template with range-caption children
|
|
/// (<c>0x10000221</c>) that retail's own <c>InitOptions</c> never
|
|
/// invokes — authored but dead, matching Chat's own unused-index pattern.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Consumer disposition (OP6 contract §4):</b> LIVE — the Sound/Ambient
|
|
/// volume-trio sliders (<c>AudioSettings.Sfx</c>/<c>Ambient</c>, already
|
|
/// live via <c>ApplyAudio</c>) and their toggle halves
|
|
/// (<see cref="AudioSettings.SfxEnabled"/>/<see cref="AudioSettings.AmbientEnabled"/>
|
|
/// — OP6 rework, review M2: retail's own ENABLED-sense fields, not
|
|
/// "Disabled" — gating the SAME engine write); Resolution/Full Screen
|
|
/// (<c>DisplaySettings.Resolution</c>/<c>Fullscreen</c>, immediately live —
|
|
/// resizes the window on Save). NEXT-LAUNCH (existing precedent, not a new
|
|
/// gap — neither the pre-existing dev-tools Settings panel nor this
|
|
/// controller re-applies these outside <c>ApplyStartup</c>): Sync To
|
|
/// Refresh, Field of View. STORE-ONLY (register rows, cited per group below):
|
|
/// Sound Features menu, Interface Sound trio (AP-174 — retail's own
|
|
/// registered-and-never-read knob), Play Sound Only When Active; Screen
|
|
/// Brightness (OP6 rework, review S2: its OWN <c>DisplaySettings.ScreenBrightness</c>
|
|
/// field, range [-1,1] default 0 — NOT the pre-existing <c>Gamma</c>
|
|
/// multiplier, a different unit system with its own live legacy-panel
|
|
/// consumer; no gamma-correction render pass exists for either); Automatic
|
|
/// Degrades/Graphics Performance/Degrade Distance/the four texture-detail-
|
|
/// family menus/Building Detail Textures/Multi-Pass Alpha (the renderer is
|
|
/// Vulkan + one aggregate <c>QualityPreset</c>, no per-feature knobs);
|
|
/// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look
|
|
/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no
|
|
/// persistent mouse-turning camera mode exists for ANY of these six to
|
|
/// drive, already registered before this slice); Chat Font Face/Size
|
|
/// (distinct NEW fields from the existing live <c>ChatSettings.FontSize</c>
|
|
/// — no verified index-to-point/face mapping; OP6 rework, review M3: Chat
|
|
/// Font Face ships all FIVE of retail's authored choices — Arial,
|
|
/// CourierNew, PalatinoLinotype, Tahoma, TimesNewRoman, a fixed
|
|
/// compile-time array at <c>gmClient::InitUIPreferences @0x00403885</c>-
|
|
/// <c>0x004039ed</c>, NOT a per-machine runtime enumeration as the
|
|
/// rejected slice's comment claimed).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Caption dimming (AD-78, user-directed, 2026-08-11, gate 2).</b> Every
|
|
/// STORE-ONLY row above (the 21 rows named in the paragraph before this one
|
|
/// -- AP-198's ten, AP-199's three, and TS-74's six Camera/Input rows plus
|
|
/// AP-200's two Chat-font rows) renders its caption in
|
|
/// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> instead of the normal
|
|
/// white/DAT-authored color. The row stays fully interactive -- it still
|
|
/// persists -- the dim is ONLY a visual marker so the four-tab panel makes
|
|
/// "does this actually do anything yet" legible at a glance, per the
|
|
/// campaign's gate-2 directive. LIVE rows (Sound/Ambient trios, Resolution,
|
|
/// Full Screen) and NEXT-LAUNCH rows (Sync To Refresh, Field of View -- they
|
|
/// DO apply, just not until the next process start) are NOT dimmed. Threaded
|
|
/// through every row builder below as an explicit <c>storeOnly</c> parameter
|
|
/// at each of the 27 call sites, so wiring a future consumer means flipping
|
|
/// that literal to <see langword="false"/> -- the matching conformance test
|
|
/// then fails until the change is made consciously.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class ConfigOptionsPageController
|
|
{
|
|
/// <summary>Config page root — <c>gmConfigUI</c> — the STANDALONE
|
|
/// <c>0x21000029</c> layout's own root id. Only present as its own
|
|
/// distinct node when <c>0x21000029</c> is imported directly (e.g.
|
|
/// <c>FixtureLoader.LoadOptionsConfig()</c>); when mounted through the
|
|
/// tab host (<c>0x2100006E</c>/<c>0x2100002B</c>, what <see cref="Bind"/>
|
|
/// actually operates on), <c>ElementReader.Merge</c>'s "derived id wins"
|
|
/// rule means the PAGE SLOT keeps its own id
|
|
/// (<see cref="PageSlotElementId"/>) instead — use that one for any
|
|
/// host-tree lookup (same split <see cref="ChatOptionsPageController"/>'s
|
|
/// own <c>RootElementId</c> doc explains).</summary>
|
|
public const uint RootElementId = 0x100001FFu;
|
|
|
|
/// <summary>The Config page's SLOT element within the tab host
|
|
/// (<c>OptionsPanelController</c>'s own private <c>ConfigPageId</c>) —
|
|
/// the id that actually survives base-merge in the host-mounted tree
|
|
/// <see cref="Bind"/> operates on. Used to SCOPE the scrollbar lookup
|
|
/// below (the shared-id hazard with the Chat tab).</summary>
|
|
private const uint PageSlotElementId = 0x10000213u;
|
|
|
|
/// <summary>The row ListBox (dat Type 5) — <c>m_pOptionBox</c>.</summary>
|
|
public const uint ListBoxElementId = 0x10000200u;
|
|
|
|
/// <summary>The ListBox's linked scrollbar — SHARED with the Chat tab
|
|
/// (research doc §10.1: both tabs author the same scrollbar element
|
|
/// id). Scoped from <see cref="RootElementId"/>'s page slot, exactly
|
|
/// like <see cref="ChatOptionsPageController"/>'s own scrollbar lookup —
|
|
/// the shared-id hazard this campaign's binding pattern exists for.</summary>
|
|
public const uint ScrollbarElementId = 0x10000201u;
|
|
|
|
private const int HeaderTemplateIndex = 0;
|
|
private const int SeparatorTemplateIndex = 1;
|
|
private const int ToggleTemplateIndex = 2;
|
|
private const int SimpleSliderTemplateIndex = 3;
|
|
private const int MenuTemplateIndex = 4;
|
|
private const int TrioTemplateIndex = 5;
|
|
private const int RangedSliderTemplateIndex = 6;
|
|
|
|
private const uint StringTableId = 0x23000003u;
|
|
|
|
/// <summary>Slider row templates' (idx3/idx6) name-label text child.</summary>
|
|
private const uint SliderLabelElementId = 0x1000021Bu;
|
|
|
|
/// <summary>Slider leaf shared by every slider-bearing template
|
|
/// (idx3/idx5/idx6) — retail's own single <c>0x1000021C</c> convention,
|
|
/// same id CharacterOptions/ChatOptions controllers already cite.</summary>
|
|
private const uint SliderElementId = 0x1000021Cu;
|
|
|
|
/// <summary>idx6 range-captioned slider template's low/high range-caption
|
|
/// children (OP6 rework, review M1) — the SAME ids
|
|
/// <see cref="ChatOptionsPageController"/> already cites for the exact
|
|
/// same BN artifact on the Chat tab's own labelled slider.</summary>
|
|
private const uint SliderRangeMinElementId = 0x1000021Eu;
|
|
private const uint SliderRangeMaxElementId = 0x1000021Fu;
|
|
|
|
/// <summary>Menu row template's (idx4) name-label text child.</summary>
|
|
private const uint MenuLabelElementId = 0x10000223u;
|
|
|
|
/// <summary>Menu row template's (idx4) <see cref="UiMenu"/> leaf.</summary>
|
|
private const uint MenuElementId = 0x10000224u;
|
|
|
|
/// <summary>Toggle checkbox leaf shared by the plain toggle row (idx2)
|
|
/// and the trio row's toggle half (idx5) — retail's own single
|
|
/// <c>0x10000219</c> convention.</summary>
|
|
private const uint ToggleCheckboxElementId = 0x10000219u;
|
|
|
|
/// <summary>
|
|
/// #378 fix (2026-08-11, gate 4): every Config-tab dropdown
|
|
/// (<see cref="MenuElementId"/>, retail class <c>UIOption_Menu</c>,
|
|
/// <c>Type 0x10000038</c>) rendered bare — no button well, no arrow, no
|
|
/// popup — because NOTHING wired its sprites, the same class of bug
|
|
/// <see cref="VendorUiController"/>'s own precedent already fixed for
|
|
/// the vendor category dropdown. A live-DAT probe (menuprobe2,
|
|
/// <c>OptionsPanelLiveMountProbeTests</c>) walked the retail inheritance
|
|
/// chain the built widget tree collapses away
|
|
/// (<c>0x10000224</c> → base <c>0x10000353</c> in LayoutDesc
|
|
/// <c>0x21000043</c> — retail's SHARED popup/dropdown catalog, the exact
|
|
/// same layout <see cref="VendorUiController"/>'s own <c>0x100000BF</c>
|
|
/// also derives from, base <c>0x1000034B</c>) and found the two
|
|
/// prototypes BYTE-IDENTICAL in every sprite id: arrow cap
|
|
/// <c>0x060012B1</c>/<c>B2</c>, face/row sprite <c>0x060012B3</c>/<c>B4</c>,
|
|
/// and the full 6-sprite scrollbar chrome
|
|
/// <c>0x06004C5F/60/63/66/69/6C</c> — Config's own popup root/ListBox
|
|
/// (<c>0x10000357</c>/<c>0x10000358</c>, attrs 6/2 on <c>0x10000353</c>)
|
|
/// carry the SAME <c>0x59</c>=true/<c>0x5D</c>=1-col/<c>0x5E</c>=6-row/
|
|
/// <c>0x61</c>=true properties as vendor's own <c>0x1000034F</c>/
|
|
/// <c>0x10000350</c>, and Config's row template (attr 9 →
|
|
/// <c>0x1000035A</c>) resolves the IDENTICAL Normal/Highlight media as
|
|
/// vendor's own <c>0x10000352</c>. Attribute 7 (the popup catalog
|
|
/// LayoutDesc) is <c>0x21000043</c> for BOTH — not an approximation
|
|
/// choice, a measured fact; no register row needed. No property "5"
|
|
/// (open-upward) is authored on <c>0x10000353</c> either, matching
|
|
/// vendor's own absent-defaults-false case — the popup opens DOWNWARD.
|
|
/// These constants are therefore transcribed from the SAME live values
|
|
/// <see cref="VendorUiController"/> already hardcodes, not duplicated by
|
|
/// coincidence — a genuine shared retail asset, verified independently
|
|
/// rather than assumed from the precedent.
|
|
/// </summary>
|
|
private static class MenuChromeSprites
|
|
{
|
|
public const uint Normal = 0x060012B3u;
|
|
public const uint Pressed = 0x060012B4u;
|
|
public const uint ItemNormal = 0x060012B3u;
|
|
public const uint ItemHighlight = 0x060012B4u;
|
|
public const uint ArrowCapClosed = 0x060012B1u;
|
|
public const uint ArrowCapOpen = 0x060012B2u;
|
|
|
|
public const int RowsPerColumn = 6;
|
|
public const float RowHeight = 18f;
|
|
public const float ColumnWidth = 100f;
|
|
|
|
public const float ScrollbarWidth = 16f;
|
|
public const float ScrollButtonExtent = 16f;
|
|
public const uint ScrollTrack = 0x06004C5Fu;
|
|
public const uint ScrollThumbTop = 0x06004C60u;
|
|
public const uint ScrollThumb = 0x06004C63u;
|
|
public const uint ScrollThumbBottom = 0x06004C66u;
|
|
public const uint ScrollUp = 0x06004C69u;
|
|
public const uint ScrollDown = 0x06004C6Cu;
|
|
}
|
|
|
|
/// <summary>Applies <see cref="MenuChromeSprites"/> + geometry to a
|
|
/// freshly-built <see cref="UiMenu"/> leaf — the SAME wiring shape
|
|
/// <see cref="VendorUiController"/> performs inline for its own dropdown
|
|
/// (font/sprite resolvers, scrollable single-column popup, arrow-cap
|
|
/// overlay, zero text indents since neither the row template nor the
|
|
/// face carry a checkbox/LED glyph to clear — see the class's own
|
|
/// checked live-dat properties).</summary>
|
|
private static void ApplyMenuChrome(
|
|
UiMenu menu,
|
|
Func<uint, (uint tex, int w, int h)>? resolveSprite,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont)
|
|
{
|
|
menu.SpriteResolve = resolveSprite;
|
|
menu.DatFont = datFont;
|
|
menu.Font = debugFont;
|
|
menu.NormalSprite = MenuChromeSprites.Normal;
|
|
menu.PressedSprite = MenuChromeSprites.Pressed;
|
|
menu.ItemNormalSprite = MenuChromeSprites.ItemNormal;
|
|
menu.ItemHighlightSprite = MenuChromeSprites.ItemHighlight;
|
|
menu.RowsPerColumn = MenuChromeSprites.RowsPerColumn;
|
|
menu.RowHeight = MenuChromeSprites.RowHeight;
|
|
menu.ColumnWidth = MenuChromeSprites.ColumnWidth;
|
|
menu.Scrollable = true;
|
|
menu.ScrollbarWidth = MenuChromeSprites.ScrollbarWidth;
|
|
menu.ScrollButtonExtent = MenuChromeSprites.ScrollButtonExtent;
|
|
menu.ScrollTrackSprite = MenuChromeSprites.ScrollTrack;
|
|
menu.ScrollThumbTopSprite = MenuChromeSprites.ScrollThumbTop;
|
|
menu.ScrollThumbSprite = MenuChromeSprites.ScrollThumb;
|
|
menu.ScrollThumbBottomSprite = MenuChromeSprites.ScrollThumbBottom;
|
|
menu.ScrollUpSprite = MenuChromeSprites.ScrollUp;
|
|
menu.ScrollDownSprite = MenuChromeSprites.ScrollDown;
|
|
menu.ArrowCapClosedSprite = MenuChromeSprites.ArrowCapClosed;
|
|
menu.ArrowCapOpenSprite = MenuChromeSprites.ArrowCapOpen;
|
|
// No property "5" authored on the retail prototype — absent-defaults-
|
|
// false, opens DOWNWARD (see MenuChromeSprites' own doc).
|
|
menu.OpenUpward = false;
|
|
// Neither the row template nor the face child carry a checkbox/LED
|
|
// glyph to clear space for (see MenuChromeSprites' own doc).
|
|
menu.TextIndent = 0f;
|
|
menu.ButtonTextIndent = 0f;
|
|
}
|
|
|
|
/// <summary>The live read/write seam every row on this page writes/reads
|
|
/// through — four settings groups, each read once per row-build and
|
|
/// mutated read-modify-write per change (the SAME per-change persistence
|
|
/// shape OP3's mouse-turning macro and OP4's Character rows use).</summary>
|
|
public sealed record Bindings(
|
|
Func<DisplaySettings> LoadDisplay,
|
|
Action<DisplaySettings> SaveDisplay,
|
|
Func<AudioSettings> LoadAudio,
|
|
Action<AudioSettings> SaveAudio,
|
|
Func<CameraTurningSettings> LoadCameraTurning,
|
|
Action<CameraTurningSettings> SaveCameraTurning,
|
|
Func<ChatSettings> LoadChat,
|
|
Action<ChatSettings> SaveChat);
|
|
|
|
/// <summary>
|
|
/// Builds the six authored sections (Sound/Camera/Graphics/Rendering
|
|
/// Quality/Input/UI Options) into <paramref name="layout"/>'s Config
|
|
/// ListBox, links its scrollbar, seeds every row's current/default
|
|
/// state, and registers each row into <paramref name="page"/>.
|
|
/// </summary>
|
|
/// <param name="resolveSprite">#378: RenderSurface id → (GL tex handle,
|
|
/// pixel width, pixel height), threaded onto every dropdown's
|
|
/// <see cref="UiMenu.SpriteResolve"/> — see <see cref="MenuChromeSprites"/>'s
|
|
/// own doc. Null (fixture/conformance callers that don't exercise
|
|
/// dropdown chrome) leaves every menu's sprite ids populated but drawing
|
|
/// nothing, matching <see cref="UiMenu"/>'s own null-safety.</param>
|
|
/// <param name="datFont">#378: the retail dat-driven font every dropdown
|
|
/// draws its button/row labels with.</param>
|
|
/// <param name="debugFont">#378: fallback bitmap font when
|
|
/// <paramref name="datFont"/> is unavailable — same convention
|
|
/// <see cref="VendorUiController"/> uses.</param>
|
|
public static bool Bind(
|
|
ImportedLayout layout,
|
|
OptionPage page,
|
|
Func<uint, uint, UiElement?> templateResolver,
|
|
Func<uint, uint, string?> resolveString,
|
|
Bindings bindings,
|
|
Func<uint, (uint tex, int w, int h)>? resolveSprite = null,
|
|
UiDatFont? datFont = null,
|
|
BitmapFont? debugFont = null)
|
|
{
|
|
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] ConfigOptionsPageController: ListBox 0x{ListBoxElementId:X8} "
|
|
+ "not found (or not a UiTemplateListBox) in the built Options panel tree — "
|
|
+ "the Config tab will have no rows.");
|
|
return false;
|
|
}
|
|
|
|
listBox.TemplateResolver = templateResolver;
|
|
|
|
// OP6 rework (2026-08-11, review N2): read the scrollbar id the DAT
|
|
// itself authors (UiTemplateListBox.ScrollbarElementId, dat property
|
|
// 0x72) rather than the hardcoded ScrollbarElementId constant —
|
|
// feedback_prefer_dat_field_over_geometry. The constant above still
|
|
// documents retail's known value (0x10000201, SHARED with the Chat
|
|
// tab — 0x1000050D's own scrollbar lookup cites the same hazard) for
|
|
// callers that need it without a built listBox in hand, but Bind
|
|
// itself now trusts the authored field. ImportedLayout.FindElement is
|
|
// a flat id->widget dictionary (last-build-wins on a collision), so a
|
|
// plain lookup here could silently wire THIS scroll model onto the
|
|
// Chat tab's own scrollbar instance — scope the search to the Config
|
|
// page's own subtree instead. Scoped from PageSlotElementId, NOT
|
|
// RootElementId — see that field's own doc for why the standalone
|
|
// layout's root id does not survive base-merge here.
|
|
uint scrollbarElementId = listBox.ScrollbarElementId;
|
|
UiElement? configPageSlot = layout.FindElement(PageSlotElementId);
|
|
UiElement? scrollbarElement = configPageSlot is null || scrollbarElementId == 0
|
|
? null
|
|
: UiElement.FindDescendant(configPageSlot, scrollbarElementId);
|
|
if (scrollbarElement is UiScrollbar scrollbar)
|
|
scrollbar.Model = listBox.Scroll;
|
|
else
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: scrollbar 0x{scrollbarElementId:X8} "
|
|
+ $"not found under Config page slot 0x{PageSlotElementId:X8} — the Config "
|
|
+ "tab's row list will not scroll.");
|
|
|
|
DisplaySettings display = bindings.LoadDisplay();
|
|
AudioSettings audio = bindings.LoadAudio();
|
|
CameraTurningSettings cameraTurning = bindings.LoadCameraTurning();
|
|
ChatSettings chat = bindings.LoadChat();
|
|
|
|
BindSoundSection(listBox, page, resolveString, bindings, ref audio, resolveSprite, datFont, debugFont);
|
|
BuildSeparatorRow(listBox);
|
|
BindCameraSection(listBox, page, resolveString, bindings, ref cameraTurning);
|
|
BuildSeparatorRow(listBox);
|
|
BindGraphicsSection(listBox, page, resolveString, bindings, ref display, resolveSprite, datFont, debugFont);
|
|
BuildSeparatorRow(listBox);
|
|
BindRenderingQualitySection(listBox, page, resolveString, bindings, ref display, resolveSprite, datFont, debugFont);
|
|
BuildSeparatorRow(listBox);
|
|
BindInputSection(listBox, page, resolveString, bindings, ref cameraTurning);
|
|
BuildSeparatorRow(listBox);
|
|
BindUiSection(listBox, page, resolveString, bindings, ref chat, resolveSprite, datFont, debugFont);
|
|
// OP6 rework (2026-08-11, review S1): retail's own InitOptions ends
|
|
// with a SIXTH AddSeperator tailcall (0x0049e80d) — a trailing
|
|
// separator after the LAST section, not just the five INTERIOR ones
|
|
// between sections. 27 option rows + 6 headers + 6 separators = 39
|
|
// stacked ListBox items, matching retail exactly (the rejected slice
|
|
// built 38 — five interior separators, no trailing one).
|
|
BuildSeparatorRow(listBox);
|
|
|
|
return true;
|
|
}
|
|
|
|
// ── Section 1: Sound Options ────────────────────────────────────────
|
|
|
|
private static void BindSoundSection(
|
|
UiTemplateListBox listBox,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Bindings bindings,
|
|
ref AudioSettings audio,
|
|
Func<uint, (uint tex, int w, int h)>? resolveSprite,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont)
|
|
{
|
|
BuildHeaderRow(listBox, "ID_Sound_SoundSection", resolveString);
|
|
|
|
BuildMenuRow(
|
|
listBox, "ID_Sound_SoundFeatures",
|
|
new[] { "ID_Sound_Stereo", "ID_Sound_Mono" },
|
|
page, resolveString,
|
|
read: () => bindings.LoadAudio().SoundFeatures,
|
|
apply: value =>
|
|
{
|
|
AudioSettings updated = bindings.LoadAudio() with { SoundFeatures = value };
|
|
bindings.SaveAudio(updated);
|
|
},
|
|
defaultValue: 0,
|
|
storeOnly: true, // AP-199
|
|
resolveSprite, datFont, debugFont);
|
|
|
|
// OP6 rework (2026-08-11, review M2): read/apply the ENABLED-sense
|
|
// fields directly — toggleDefault stays `true` because retail's own
|
|
// SetDefaultValue(1) is checked-by-default AND checked means
|
|
// enabled (see AudioSettings' class doc for the byte evidence); only
|
|
// the FIELD's meaning changed, not this literal. sliderTooltipKey
|
|
// is the retail preference key for the SLIDER half specifically
|
|
// (Sound_SoundVolume/Sound_AmbientSoundVolume/
|
|
// Sound_InterfaceSoundVolume's own AttachPreference calls,
|
|
// gmClient::InitUIPreferences @0x0040360a/:00403676/:004036e0) —
|
|
// used ONLY to resolve its own "_Help" tooltip (review S3); the row
|
|
// has no separate slider name text, so this key never becomes
|
|
// visible label text.
|
|
BuildTrioRow(
|
|
listBox, "ID_Sound_DisableSound", sliderTooltipKey: "ID_Sound_EffectVolume",
|
|
toggleDefault: true,
|
|
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
|
page, resolveString,
|
|
toggleRead: () => bindings.LoadAudio().SfxEnabled,
|
|
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { SfxEnabled = value }),
|
|
sliderRead: () => bindings.LoadAudio().Sfx,
|
|
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Sfx = value }),
|
|
storeOnly: false); // LIVE
|
|
|
|
BuildTrioRow(
|
|
listBox, "ID_Sound_DisableAmbientSound", sliderTooltipKey: "ID_Sound_AmbientVolume",
|
|
toggleDefault: true,
|
|
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
|
page, resolveString,
|
|
toggleRead: () => bindings.LoadAudio().AmbientEnabled,
|
|
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { AmbientEnabled = value }),
|
|
sliderRead: () => bindings.LoadAudio().Ambient,
|
|
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Ambient = value }),
|
|
storeOnly: false); // LIVE
|
|
|
|
// Interface Sound: retail's own dead knob (AP-174 — "interface
|
|
// sounds are scaled by the EFFECT knob"; registered and never
|
|
// read). Store-only, same shape as every other row here — this
|
|
// trio simply has no live consumer to gate.
|
|
BuildTrioRow(
|
|
listBox, "ID_Sound_DisableInterfaceSound", sliderTooltipKey: "ID_Sound_InterfaceVolume",
|
|
toggleDefault: true,
|
|
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
|
page, resolveString,
|
|
toggleRead: () => bindings.LoadAudio().InterfaceEnabled,
|
|
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceEnabled = value }),
|
|
sliderRead: () => bindings.LoadAudio().InterfaceVolume,
|
|
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceVolume = value }),
|
|
storeOnly: true); // AP-174 / AP-199
|
|
|
|
BuildToggleRow(
|
|
listBox, "ID_Sound_NoFocusNoSound", defaultValue: true, page, resolveString,
|
|
read: () => bindings.LoadAudio().PlaySoundOnlyWhenActive,
|
|
apply: value => bindings.SaveAudio(bindings.LoadAudio() with { PlaySoundOnlyWhenActive = value }),
|
|
storeOnly: true); // AP-199
|
|
|
|
audio = bindings.LoadAudio();
|
|
}
|
|
|
|
// ── Section 2: Camera Options ───────────────────────────────────────
|
|
|
|
private static void BindCameraSection(
|
|
UiTemplateListBox listBox,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Bindings bindings,
|
|
ref CameraTurningSettings cameraTurning)
|
|
{
|
|
BuildHeaderRow(listBox, "ID_Camera_CameraSection", resolveString);
|
|
|
|
// Camera Stiffness / Adjustment Speed / Align To Slope: TS-74 —
|
|
// store-only, no persistent mouse-turning camera mode exists.
|
|
// rangeLowKey/rangeHighKey (OP6 rework, review M1): retail's own
|
|
// SetSliderLabel pair, byte-verified — see class doc's U4 note.
|
|
BuildSliderRow(
|
|
listBox, RangedSliderTemplateIndex, "ID_Camera_Stiffness",
|
|
min: 0.285714298f, max: 1f, defaultValue: 0.45f, page, resolveString,
|
|
read: () => bindings.LoadCameraTurning().Stiffness,
|
|
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { Stiffness = value }),
|
|
storeOnly: true, // TS-74
|
|
rangeLowKey: "ID_Graphics_Value_Soft", rangeHighKey: "ID_Graphics_Value_Hard");
|
|
|
|
BuildSliderRow(
|
|
listBox, RangedSliderTemplateIndex, "ID_Camera_AdjustmentSpeed",
|
|
min: 5f, max: 80f, defaultValue: 40.0f, page, resolveString,
|
|
read: () => bindings.LoadCameraTurning().AdjustmentSpeed,
|
|
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AdjustmentSpeed = value }),
|
|
storeOnly: true, // TS-74
|
|
rangeLowKey: "ID_Graphics_Value_Slow", rangeHighKey: "ID_Graphics_Value_Fast");
|
|
|
|
// Field of View: NEXT-LAUNCH via DisplaySettings.FieldOfView + the
|
|
// existing RuntimeSettingsController.ApplyStartup path — matches
|
|
// the pre-existing (dev-tools Settings panel era) behaviour, not a
|
|
// new gap this slice introduces.
|
|
BuildSliderRow(
|
|
listBox, RangedSliderTemplateIndex, "ID_Graphics_FieldOfView",
|
|
min: 10f, max: 160f, defaultValue: 90.0f, page, resolveString,
|
|
read: () => bindings.LoadDisplay().FieldOfView,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { FieldOfView = value }),
|
|
storeOnly: false, // NEXT-LAUNCH, not store-only
|
|
rangeLowKey: "ID_Graphics_Value_Narrow", rangeHighKey: "ID_Graphics_Value_Wide");
|
|
|
|
BuildToggleRow(
|
|
listBox, "ID_Camera_AlignToSlope", defaultValue: true, page, resolveString,
|
|
read: () => bindings.LoadCameraTurning().AlignToSlope,
|
|
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AlignToSlope = value }),
|
|
storeOnly: true); // TS-74
|
|
|
|
cameraTurning = bindings.LoadCameraTurning();
|
|
}
|
|
|
|
// ── Section 3: Graphics Options ─────────────────────────────────────
|
|
|
|
private static void BindGraphicsSection(
|
|
UiTemplateListBox listBox,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Bindings bindings,
|
|
ref DisplaySettings display,
|
|
Func<uint, (uint tex, int w, int h)>? resolveSprite,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont)
|
|
{
|
|
BuildHeaderRow(listBox, "ID_Graphics_GraphicsSection", resolveString);
|
|
|
|
// Resolution: LIVE — DisplaySettings.Resolution already resizes
|
|
// the window immediately on Save (ApplyDisplayWindowState). The
|
|
// menu's payload is the resolution STRING itself (not an index —
|
|
// retail's Display_Resolution is the ONE row on this tab built
|
|
// via arg3=0/SetUserPreference, a genuinely different code path
|
|
// from every other menu here; this controller resolves its label
|
|
// directly rather than through UIPreferences::InqPreference's
|
|
// generic path, matching that divergence honestly). SetConfirmChange
|
|
// ships when a resolution-change confirmation flow exists — not
|
|
// this slice (plan §4 OP6). Retail's literal default ("800x600",
|
|
// preserved below as the value Defaults restores, byte-verified
|
|
// SetDefaultValue(0x03200258) @0x0049e5ac) IS now a selectable
|
|
// DisplaySettings.AvailableResolutions entry (OP6 rework, review
|
|
// S4 — it is a genuine retail display mode,
|
|
// Device::ForceDisplayResolution(1, 0x320, 0x258)
|
|
// @gmClient::Init 0x004047af, not an invented preset), so clicking
|
|
// Defaults both resizes the window AND leaves the dropdown showing
|
|
// a highlighted, re-selectable row.
|
|
BuildStringMenuRow(
|
|
listBox, "ID_Rendering_DisplayResolution",
|
|
DisplaySettings.AvailableResolutions, page, resolveString,
|
|
read: () => bindings.LoadDisplay().Resolution,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Resolution = value }),
|
|
defaultValue: "800x600",
|
|
storeOnly: false, // LIVE
|
|
resolveSprite, datFont, debugFont);
|
|
|
|
BuildToggleRow(
|
|
listBox, "ID_Rendering_FullScreen", defaultValue: true, page, resolveString,
|
|
read: () => bindings.LoadDisplay().Fullscreen,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Fullscreen = value }),
|
|
storeOnly: false); // LIVE
|
|
|
|
// Sync To Refresh: NEXT-LAUNCH (same DisplaySettings.VSync
|
|
// pre-existing precedent as FieldOfView above).
|
|
BuildToggleRow(
|
|
listBox, "ID_Rendering_SyncToDisplayRefresh", defaultValue: false, page, resolveString,
|
|
read: () => bindings.LoadDisplay().VSync,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { VSync = value }),
|
|
storeOnly: false); // NEXT-LAUNCH, not store-only
|
|
|
|
// Screen Brightness: OP6 rework (2026-08-11, review S2) — its OWN
|
|
// DisplaySettings.ScreenBrightness field ([-1,1], default 0), NOT
|
|
// the pre-existing Gamma multiplier (a different unit system with
|
|
// its own live legacy Settings-panel consumer). No gamma-correction
|
|
// render pass exists for either — inert, store-only.
|
|
BuildSliderRow(
|
|
listBox, RangedSliderTemplateIndex, "ID_Graphics_ScreenBrightness",
|
|
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
|
|
read: () => bindings.LoadDisplay().ScreenBrightness,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { ScreenBrightness = value }),
|
|
storeOnly: true, // review S2
|
|
rangeLowKey: "ID_Graphics_Value_Dark", rangeHighKey: "ID_Graphics_Value_Bright");
|
|
|
|
BuildToggleRow(
|
|
listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString,
|
|
read: () => bindings.LoadDisplay().AutomaticDegrades,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value }),
|
|
storeOnly: true); // AP-198
|
|
|
|
BuildSliderRow(
|
|
listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias",
|
|
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
|
|
read: () => bindings.LoadDisplay().GraphicsPerformance,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }),
|
|
storeOnly: true, // AP-198
|
|
rangeLowKey: "ID_Graphics_Value_Speed", rangeHighKey: "ID_Graphics_Value_Detail");
|
|
|
|
BuildSliderRow(
|
|
listBox, RangedSliderTemplateIndex, "ID_Graphics_DegradeDistance",
|
|
min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString,
|
|
read: () => bindings.LoadDisplay().DegradeDistance,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }),
|
|
storeOnly: true, // AP-198
|
|
rangeLowKey: "ID_Graphics_Value_Close", rangeHighKey: "ID_Graphics_Value_Far");
|
|
|
|
display = bindings.LoadDisplay();
|
|
}
|
|
|
|
// ── Section 4: Rendering Quality Options ────────────────────────────
|
|
|
|
private static readonly string[] TextureDetailChoices =
|
|
{
|
|
"ID_Graphics_Value_VeryLow", "ID_Graphics_Value_Low", "ID_Graphics_Value_Medium",
|
|
"ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh",
|
|
};
|
|
|
|
private static readonly string[] TextureFilteringChoices =
|
|
{
|
|
"ID_Graphics_TextureFiltering_Bilinear", "ID_Graphics_TextureFiltering_Trilinear",
|
|
"ID_Graphics_TextureFiltering_Sharp", "ID_Graphics_TextureFiltering_Anisotropic",
|
|
};
|
|
|
|
private static readonly string[] LandscapeDrawDistanceChoices =
|
|
{
|
|
"ID_Graphics_Value_VeryLow", "ID_Graphics_Value_Low", "ID_Graphics_Value_Medium",
|
|
"ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh", "ID_Graphics_Value_Extreme",
|
|
};
|
|
|
|
private static void BindRenderingQualitySection(
|
|
UiTemplateListBox listBox,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Bindings bindings,
|
|
ref DisplaySettings display,
|
|
Func<uint, (uint tex, int w, int h)>? resolveSprite,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont)
|
|
{
|
|
BuildHeaderRow(listBox, "ID_Graphics_TextureSection", resolveString);
|
|
|
|
// The whole section is store-only: the world renderer is
|
|
// Vulkan + one aggregate QualityPreset, not per-feature knobs
|
|
// (register row, OP6).
|
|
BuildMenuRow(
|
|
listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString,
|
|
read: () => bindings.LoadDisplay().LandscapeTextureDetail,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeTextureDetail = value }),
|
|
defaultValue: 2,
|
|
storeOnly: true, // AP-198
|
|
resolveSprite, datFont, debugFont);
|
|
|
|
BuildMenuRow(
|
|
listBox, "ID_Graphics_EnvironmentTextureDetail", TextureDetailChoices, page, resolveString,
|
|
read: () => bindings.LoadDisplay().EnvironmentTextureDetail,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { EnvironmentTextureDetail = value }),
|
|
defaultValue: 1,
|
|
storeOnly: true, // AP-198
|
|
resolveSprite, datFont, debugFont);
|
|
|
|
BuildMenuRow(
|
|
listBox, "ID_Graphics_TextureFiltering", TextureFilteringChoices, page, resolveString,
|
|
read: () => bindings.LoadDisplay().TextureFiltering,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { TextureFiltering = value }),
|
|
defaultValue: 1,
|
|
storeOnly: true, // AP-198
|
|
resolveSprite, datFont, debugFont);
|
|
|
|
// UNRESOLVED (see class doc / register row): retail's own
|
|
// SetDefaultValue(8) does not index this 6-entry choice array.
|
|
// Reproduced as an opaque int; the menu simply shows no
|
|
// highlighted item at the default (no crash, no invented mapping).
|
|
BuildMenuRow(
|
|
listBox, "ID_Graphics_LandscapeDrawDistance", LandscapeDrawDistanceChoices, page, resolveString,
|
|
read: () => bindings.LoadDisplay().LandscapeDrawDistance,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }),
|
|
defaultValue: 8,
|
|
storeOnly: true, // AP-198
|
|
resolveSprite, datFont, debugFont);
|
|
|
|
BuildToggleRow(
|
|
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
|
|
read: () => bindings.LoadDisplay().BuildingDetailTextures,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }),
|
|
storeOnly: true); // AP-198
|
|
|
|
BuildToggleRow(
|
|
listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString,
|
|
read: () => bindings.LoadDisplay().MultiPassAlpha,
|
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { MultiPassAlpha = value }),
|
|
storeOnly: true); // AP-198
|
|
|
|
display = bindings.LoadDisplay();
|
|
}
|
|
|
|
// ── Section 5: Input Options ────────────────────────────────────────
|
|
|
|
private static void BindInputSection(
|
|
UiTemplateListBox listBox,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Bindings bindings,
|
|
ref CameraTurningSettings cameraTurning)
|
|
{
|
|
BuildHeaderRow(listBox, "ID_Input_InputSection", resolveString);
|
|
|
|
// Retail's own arg3=0 row (AddSliderOption(this,
|
|
// &Input_MouseLookSensitivity, 0)) — template idx3, the ONLY row
|
|
// that uses it (structural cross-check: idx3 appears exactly once
|
|
// in Config's authored template array). TS-74 — store-only.
|
|
BuildSliderRow(
|
|
listBox, SimpleSliderTemplateIndex, "ID_Input_MouseLookSensitivity",
|
|
min: 0.00999999978f, max: 1f, defaultValue: 0.55f, page, resolveString,
|
|
read: () => bindings.LoadCameraTurning().MouseLookSensitivity,
|
|
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { MouseLookSensitivity = value }),
|
|
storeOnly: true); // TS-74
|
|
|
|
BuildToggleRow(
|
|
listBox, "ID_Input_InvertMouseLookYAxis", defaultValue: false, page, resolveString,
|
|
read: () => bindings.LoadCameraTurning().InvertMouseLookYAxis,
|
|
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { InvertMouseLookYAxis = value }),
|
|
storeOnly: true); // TS-74
|
|
|
|
// Input_UseMouseTurning: the Config tab's OWN client-local
|
|
// UIPreference — DISTINCT from the Gameplay tab macro's
|
|
// server-synced PlayerOption.UseMouseTurning bit (see
|
|
// CameraTurningSettings.UseMouseTurning's own doc). TS-74.
|
|
BuildToggleRow(
|
|
listBox, "ID_Input_UseMouseTurning", defaultValue: false, page, resolveString,
|
|
read: () => bindings.LoadCameraTurning().UseMouseTurning,
|
|
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { UseMouseTurning = value }),
|
|
storeOnly: true); // TS-74
|
|
|
|
cameraTurning = bindings.LoadCameraTurning();
|
|
}
|
|
|
|
// ── Section 6: UI Options ───────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// OP6 rework (2026-08-11, review M3): all FIVE of retail's authored
|
|
/// choices, not the one the rejected slice shipped. The rejected
|
|
/// slice's "per-machine runtime enumeration" justification was
|
|
/// contradicted by the decompile it cited:
|
|
/// <c>gmClient::InitUIPreferences</c> builds a FIXED five-entry array
|
|
/// (<c>0x00403885</c>-<c>0x004039ed</c>) — Arial, CourierNew,
|
|
/// PalatinoLinotype, Tahoma, TimesNewRoman, in that order, then ONE
|
|
/// <c>SetEnumChoices</c> call — the exact same
|
|
/// <c>SmartArray<unsigned long,1>::grow</c> push idiom
|
|
/// <see cref="ChatFontSizeChoices"/> already uses for its five entries;
|
|
/// there is no unbounded/per-machine loop. All five strings are present
|
|
/// verbatim in the binary's <c>.rdata</c> (PE-byte-verified against
|
|
/// <c>C:\Users\erikn\Downloads\acclient.exe</c>). Default index 2
|
|
/// (PalatinoLinotype, <c>SetDefaultValue(2) @0x0049e7de</c>) now indexes
|
|
/// a real entry instead of falling past a 1-item array.
|
|
/// </summary>
|
|
private static readonly string[] ChatFontFaceChoices =
|
|
{
|
|
"ID_UI_Value_Arial", "ID_UI_Value_CourierNew", "ID_UI_Value_PalatinoLinotype",
|
|
"ID_UI_Value_Tahoma", "ID_UI_Value_TimesNewRoman",
|
|
};
|
|
|
|
private static readonly string[] ChatFontSizeChoices =
|
|
{
|
|
"ID_UI_Value_Tiny", "ID_UI_Value_Small", "ID_UI_Value_Medium",
|
|
"ID_UI_Value_Large", "ID_UI_Value_XLarge",
|
|
};
|
|
|
|
private static void BindUiSection(
|
|
UiTemplateListBox listBox,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Bindings bindings,
|
|
ref ChatSettings chat,
|
|
Func<uint, (uint tex, int w, int h)>? resolveSprite,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont)
|
|
{
|
|
BuildHeaderRow(listBox, "ID_UI_UISection", resolveString);
|
|
|
|
// Chat Font Face/Size: store-only, distinct from the existing live
|
|
// ChatSettings.FontSize (see that field's own doc for why). The
|
|
// face-choice array ships all five retail-authored choices — see
|
|
// ChatFontFaceChoices' own doc (OP6 rework, review M3).
|
|
BuildMenuRow(
|
|
listBox, "ID_UI_ChatFontFace", ChatFontFaceChoices, page, resolveString,
|
|
read: () => bindings.LoadChat().ChatFontFace,
|
|
apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontFace = value }),
|
|
defaultValue: 2,
|
|
storeOnly: true, // AP-200
|
|
resolveSprite, datFont, debugFont);
|
|
|
|
BuildMenuRow(
|
|
listBox, "ID_UI_ChatFontSize", ChatFontSizeChoices, page, resolveString,
|
|
read: () => bindings.LoadChat().ChatFontSizeIndex,
|
|
apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontSizeIndex = value }),
|
|
defaultValue: 1,
|
|
storeOnly: true, // AP-200
|
|
resolveSprite, datFont, debugFont);
|
|
|
|
chat = bindings.LoadChat();
|
|
}
|
|
|
|
// ── Row builders (shared shapes) ────────────────────────────────────
|
|
|
|
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] ConfigOptionsPageController: 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] ConfigOptionsPageController: 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] ConfigOptionsPageController: separator template did not build.");
|
|
}
|
|
|
|
private static void BuildToggleRow(
|
|
UiTemplateListBox listBox,
|
|
string labelKey,
|
|
bool defaultValue,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Func<bool> read,
|
|
Action<bool> apply,
|
|
bool storeOnly)
|
|
{
|
|
UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex);
|
|
if (row is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: toggle template did not build for "
|
|
+ $"'{labelKey}'.");
|
|
return;
|
|
}
|
|
|
|
UiButton? checkbox = FindCheckbox(row);
|
|
if (checkbox is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: no checkbox child found in the "
|
|
+ $"toggle row for '{labelKey}'.");
|
|
return;
|
|
}
|
|
|
|
ApplyLabelAndTooltip(checkbox, labelKey, resolveString, storeOnly);
|
|
|
|
bool initial = read();
|
|
checkbox.Selected = initial;
|
|
|
|
var row_ = new BoolOptionRow(
|
|
initial,
|
|
defaultValue,
|
|
apply: value =>
|
|
{
|
|
checkbox.Selected = value;
|
|
apply(value);
|
|
},
|
|
read: read,
|
|
refresh: value => checkbox.Selected = value);
|
|
page.Register(row_);
|
|
|
|
checkbox.OnClick = () => row_.SetCurrentValue(checkbox.Selected);
|
|
}
|
|
|
|
/// <summary>Builds a slider row from EITHER <see cref="SimpleSliderTemplateIndex"/>
|
|
/// (idx3, Mouse Look Sensitivity only) or <see cref="RangedSliderTemplateIndex"/>
|
|
/// (idx6, the other six) — structurally identical leaves for this
|
|
/// controller's purposes (name text + slider). <paramref name="rangeLowKey"/>/
|
|
/// <paramref name="rangeHighKey"/> (OP6 rework, review M1) populate idx6's
|
|
/// extra range-caption children when supplied — left null (the default)
|
|
/// for Mouse Look Sensitivity, retail's own genuine no-caption exception
|
|
/// (see class doc's U4 note). Converts between the row's REAL-unit
|
|
/// current/default (what <paramref name="read"/>/<paramref name="apply"/>
|
|
/// traffic in — the settings-store unit) and the widget's normalized
|
|
/// [0,1] scalar space.</summary>
|
|
private static void BuildSliderRow(
|
|
UiTemplateListBox listBox,
|
|
int templateIndex,
|
|
string labelKey,
|
|
float min,
|
|
float max,
|
|
float defaultValue,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Func<float> read,
|
|
Action<float> apply,
|
|
bool storeOnly,
|
|
string? rangeLowKey = null,
|
|
string? rangeHighKey = null)
|
|
{
|
|
UiElement? row = listBox.AddItemFromTemplateList(templateIndex);
|
|
if (row is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: slider template did not build for "
|
|
+ $"'{labelKey}'.");
|
|
return;
|
|
}
|
|
|
|
if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label)
|
|
SetLabelText(label, labelKey, resolveString, storeOnly);
|
|
|
|
if (rangeLowKey is not null)
|
|
SetRangeLabel(row, SliderRangeMinElementId, rangeLowKey, resolveString);
|
|
if (rangeHighKey is not null)
|
|
SetRangeLabel(row, SliderRangeMaxElementId, rangeHighKey, resolveString);
|
|
|
|
if (UiElement.FindDescendant(row, SliderElementId) is not UiScrollbar slider)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: no slider leaf found in the row for "
|
|
+ $"'{labelKey}'.");
|
|
return;
|
|
}
|
|
|
|
// OP6 rework (review S3): the slider IS the interactive/hoverable
|
|
// widget for this row — the label text has no hit-test surface.
|
|
string? tooltip = ResolveTooltip(labelKey, resolveString);
|
|
if (tooltip is not null)
|
|
slider.TooltipText = tooltip;
|
|
|
|
float initial = read();
|
|
slider.SetScalarPosition(ToNormalized(initial, min, max));
|
|
|
|
var row_ = new FloatOptionRow(
|
|
initial,
|
|
defaultValue,
|
|
apply: value =>
|
|
{
|
|
slider.SetScalarPosition(ToNormalized(value, min, max));
|
|
apply(value);
|
|
},
|
|
read: read,
|
|
refresh: value => slider.SetScalarPosition(ToNormalized(value, min, max)));
|
|
page.Register(row_);
|
|
|
|
slider.ScalarChanged = normalized => row_.SetCurrentValue(FromNormalized(normalized, min, max));
|
|
}
|
|
|
|
/// <summary>Populates one range-caption text child (idx6 template only —
|
|
/// <see cref="SliderRangeMinElementId"/>/<see cref="SliderRangeMaxElementId"/>)
|
|
/// — the same shape <see cref="ChatOptionsPageController.SetRangeLabel"/>
|
|
/// already ports for the identical BN artifact on the Chat tab's own
|
|
/// labelled slider (OP6 rework, review M1).</summary>
|
|
private static void SetRangeLabel(
|
|
UiElement row, uint elementId, string labelKey, Func<uint, uint, string?> resolveString)
|
|
{
|
|
if (UiElement.FindDescendant(row, elementId) is not UiText text)
|
|
return;
|
|
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
|
|
if (label is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: range label '{labelKey}' did not "
|
|
+ "resolve — rendered with no text rather than invented English.");
|
|
return;
|
|
}
|
|
text.LinesProvider = () => new[] { new UiText.Line(label, text.DefaultColor) };
|
|
}
|
|
|
|
/// <summary>Builds the toggle+slider trio (idx5) — the checkbox half
|
|
/// carries the ONLY row label (retail's own row shape has no separate
|
|
/// name text; see class doc). Both halves are [0,1]-ranged in this
|
|
/// campaign (every trio here is a volume knob), so no unit conversion
|
|
/// is needed for the slider half. <paramref name="sliderTooltipKey"/>
|
|
/// (OP6 rework, review S3) is the retail preference key for the SLIDER
|
|
/// half's OWN <c>AttachPreference</c> registration (e.g.
|
|
/// <c>Sound_SoundVolume</c>'s <c>ID_Sound_EffectVolume</c>) — used ONLY
|
|
/// to resolve its "_Help" tooltip, never as visible label text (the row
|
|
/// has none for the slider half).</summary>
|
|
private static void BuildTrioRow(
|
|
UiTemplateListBox listBox,
|
|
string toggleLabelKey,
|
|
string sliderTooltipKey,
|
|
bool toggleDefault,
|
|
float sliderMin,
|
|
float sliderMax,
|
|
float sliderDefault,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Func<bool> toggleRead,
|
|
Action<bool> toggleApply,
|
|
Func<float> sliderRead,
|
|
Action<float> sliderApply,
|
|
bool storeOnly)
|
|
{
|
|
if (listBox.AddItemFromTemplateList(TrioTemplateIndex) is not UiOptionToggleSlider trio)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: trio template did not build as "
|
|
+ $"UiOptionToggleSlider for '{toggleLabelKey}'.");
|
|
return;
|
|
}
|
|
|
|
UiButton? checkbox = trio.Toggle;
|
|
UiScrollbar? slider = trio.Slider;
|
|
if (checkbox is null || slider is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: trio row for '{toggleLabelKey}' is "
|
|
+ $"missing its toggle or slider child (toggle={checkbox is not null}, "
|
|
+ $"slider={slider is not null}).");
|
|
return;
|
|
}
|
|
|
|
ApplyLabelAndTooltip(checkbox, toggleLabelKey, resolveString, storeOnly);
|
|
|
|
bool toggleInitial = toggleRead();
|
|
checkbox.Selected = toggleInitial;
|
|
var toggleRow = new BoolOptionRow(
|
|
toggleInitial,
|
|
toggleDefault,
|
|
apply: value =>
|
|
{
|
|
checkbox.Selected = value;
|
|
toggleApply(value);
|
|
},
|
|
read: toggleRead,
|
|
refresh: value => checkbox.Selected = value);
|
|
page.Register(toggleRow);
|
|
checkbox.OnClick = () => toggleRow.SetCurrentValue(checkbox.Selected);
|
|
|
|
string? sliderTooltip = ResolveTooltip(sliderTooltipKey, resolveString);
|
|
if (sliderTooltip is not null)
|
|
slider.TooltipText = sliderTooltip;
|
|
|
|
float sliderInitial = sliderRead();
|
|
slider.SetScalarPosition(ToNormalized(sliderInitial, sliderMin, sliderMax));
|
|
var sliderRow = new FloatOptionRow(
|
|
sliderInitial,
|
|
sliderDefault,
|
|
apply: value =>
|
|
{
|
|
slider.SetScalarPosition(ToNormalized(value, sliderMin, sliderMax));
|
|
sliderApply(value);
|
|
},
|
|
read: sliderRead,
|
|
refresh: value => slider.SetScalarPosition(ToNormalized(value, sliderMin, sliderMax)));
|
|
page.Register(sliderRow);
|
|
slider.ScalarChanged = normalized =>
|
|
sliderRow.SetCurrentValue(FromNormalized(normalized, sliderMin, sliderMax));
|
|
}
|
|
|
|
/// <summary>Builds a menu row (idx4) over an <see cref="int"/> payload —
|
|
/// every Config-tab menu except Resolution (see
|
|
/// <see cref="BuildStringMenuRow"/>).</summary>
|
|
private static void BuildMenuRow(
|
|
UiTemplateListBox listBox,
|
|
string labelKey,
|
|
string[] choiceKeys,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Func<int> read,
|
|
Action<int> apply,
|
|
int defaultValue,
|
|
bool storeOnly,
|
|
Func<uint, (uint tex, int w, int h)>? resolveSprite,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont)
|
|
{
|
|
UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
|
|
if (row is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: menu template did not build for "
|
|
+ $"'{labelKey}'.");
|
|
return;
|
|
}
|
|
|
|
if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label)
|
|
SetLabelText(label, labelKey, resolveString, storeOnly);
|
|
|
|
if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: no UiMenu leaf found in the row for "
|
|
+ $"'{labelKey}'.");
|
|
return;
|
|
}
|
|
|
|
// #378: wire the dropdown chrome — the button-well face, arrow cap,
|
|
// and scrollable popup — see MenuChromeSprites' own doc.
|
|
ApplyMenuChrome(menu, resolveSprite, datFont, debugFont);
|
|
|
|
// OP6 rework (review S3): the menu button IS the interactive/
|
|
// hoverable widget for this row.
|
|
string? tooltip = ResolveTooltip(labelKey, resolveString);
|
|
if (tooltip is not null)
|
|
menu.TooltipText = tooltip;
|
|
|
|
string[] choiceLabels = new string[choiceKeys.Length];
|
|
var items = new UiMenu.MenuItem[choiceKeys.Length];
|
|
for (int i = 0; i < choiceKeys.Length; i++)
|
|
{
|
|
string? choiceLabel = resolveString(StringTableId, DatStringResolver.ComputeHash(choiceKeys[i]));
|
|
choiceLabels[i] = choiceLabel ?? string.Empty;
|
|
if (choiceLabel is null)
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: menu choice '{choiceKeys[i]}' "
|
|
+ $"(for '{labelKey}') did not resolve — item renders with no caption "
|
|
+ "rather than invented English.");
|
|
items[i] = new UiMenu.MenuItem(choiceLabels[i], i);
|
|
}
|
|
menu.Items = items;
|
|
|
|
int initial = read();
|
|
menu.Selected = initial;
|
|
menu.ButtonLabelProvider = () =>
|
|
{
|
|
int current = menu.Selected is int selected ? selected : initial;
|
|
return current >= 0 && current < choiceLabels.Length ? choiceLabels[current] : string.Empty;
|
|
};
|
|
|
|
var row_ = new IntOptionRow(
|
|
initial,
|
|
defaultValue,
|
|
apply: value =>
|
|
{
|
|
menu.Selected = value;
|
|
apply(value);
|
|
},
|
|
read: read,
|
|
refresh: value => menu.Selected = value);
|
|
page.Register(row_);
|
|
|
|
menu.OnSelect = payload =>
|
|
{
|
|
if (payload is int value)
|
|
row_.SetCurrentValue(value);
|
|
};
|
|
}
|
|
|
|
/// <summary>Resolution's own menu — the one Config-tab row built via
|
|
/// retail's <c>arg3=0</c>/<c>SetUserPreference</c> path rather than the
|
|
/// generic <c>UIPreferences::InqPreference</c> label lookup every other
|
|
/// menu here uses; its payload is the resolution string itself.</summary>
|
|
private static void BuildStringMenuRow(
|
|
UiTemplateListBox listBox,
|
|
string labelKey,
|
|
IReadOnlyList<string> choices,
|
|
OptionPage page,
|
|
Func<uint, uint, string?> resolveString,
|
|
Func<string> read,
|
|
Action<string> apply,
|
|
string defaultValue,
|
|
bool storeOnly,
|
|
Func<uint, (uint tex, int w, int h)>? resolveSprite,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont)
|
|
{
|
|
UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
|
|
if (row is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: menu template did not build for "
|
|
+ $"'{labelKey}'.");
|
|
return;
|
|
}
|
|
|
|
if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label)
|
|
SetLabelText(label, labelKey, resolveString, storeOnly);
|
|
|
|
if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: no UiMenu leaf found in the row for "
|
|
+ $"'{labelKey}'.");
|
|
return;
|
|
}
|
|
|
|
// #378: wire the dropdown chrome — the button-well face, arrow cap,
|
|
// and scrollable popup — see MenuChromeSprites' own doc.
|
|
ApplyMenuChrome(menu, resolveSprite, datFont, debugFont);
|
|
|
|
// OP6 rework (review S3): the menu button IS the interactive/
|
|
// hoverable widget for this row.
|
|
string? tooltip = ResolveTooltip(labelKey, resolveString);
|
|
if (tooltip is not null)
|
|
menu.TooltipText = tooltip;
|
|
|
|
var items = new UiMenu.MenuItem[choices.Count];
|
|
for (int i = 0; i < choices.Count; i++)
|
|
items[i] = new UiMenu.MenuItem(choices[i], choices[i]);
|
|
menu.Items = items;
|
|
|
|
string initial = read();
|
|
menu.Selected = initial;
|
|
menu.ButtonLabelProvider = () => menu.Selected as string ?? initial;
|
|
|
|
var stringRow = new StringOptionRow(
|
|
initial,
|
|
defaultValue,
|
|
apply: value =>
|
|
{
|
|
menu.Selected = value;
|
|
apply(value);
|
|
},
|
|
read: read,
|
|
refresh: value => menu.Selected = value);
|
|
page.Register(stringRow);
|
|
|
|
menu.OnSelect = payload =>
|
|
{
|
|
if (payload is string value)
|
|
stringRow.SetCurrentValue(value);
|
|
};
|
|
}
|
|
|
|
private static void ApplyLabelAndTooltip(
|
|
UiButton checkbox, string labelKey, Func<uint, uint, string?> resolveString, bool storeOnly)
|
|
{
|
|
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
|
|
if (label is not null)
|
|
checkbox.Label = label;
|
|
else
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — "
|
|
+ "row renders with no caption rather than invented English.");
|
|
|
|
// AD-78: store-only rows keep their full interactivity (see class
|
|
// doc) — only the caption color changes, matching the existing
|
|
// disabled/ghosted convention (UiRenderContext.StoreOnlyCaptionColor).
|
|
checkbox.LabelColor = storeOnly
|
|
? UiRenderContext.StoreOnlyCaptionColor
|
|
: Vector4.One;
|
|
|
|
string? tooltip = ResolveTooltip(labelKey, resolveString);
|
|
if (tooltip is not null)
|
|
checkbox.TooltipText = tooltip;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves one <c><key>_Help</c> tooltip string — the SAME suffix
|
|
/// convention <see cref="ApplyLabelAndTooltip"/> already used for toggle
|
|
/// rows, byte-verified as universal across all 27+ Config-tab
|
|
/// <c>AttachPreference</c> sites (OP6 rework, review S3): every row's
|
|
/// tooltip key is its own label key with <c>_Help</c> appended, no
|
|
/// exceptions found. Missing silently (no invented English), matching
|
|
/// every other string lookup on this page.
|
|
/// </summary>
|
|
private static string? ResolveTooltip(string labelKey, Func<uint, uint, string?> resolveString)
|
|
=> resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
|
|
|
|
private static void SetLabelText(
|
|
UiText label, string labelKey, Func<uint, uint, string?> resolveString, bool storeOnly)
|
|
{
|
|
string? text = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
|
|
if (text is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — "
|
|
+ "row renders with no caption rather than invented English.");
|
|
return;
|
|
}
|
|
// AD-78: store-only rows dim their caption instead of the DAT-authored
|
|
// (or default white) color — see class doc.
|
|
Vector4 color = storeOnly ? UiRenderContext.StoreOnlyCaptionColor : label.DefaultColor;
|
|
label.LinesProvider = () => new[] { new UiText.Line(text, color) };
|
|
}
|
|
|
|
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 UiElement.FindDescendant(root, ToggleCheckboxElementId) as UiButton;
|
|
}
|
|
|
|
/// <summary>Real-unit value → the widget's normalized [0,1] scalar
|
|
/// space (<see cref="UiScrollbar.SetScalarPosition"/>'s own clamp).</summary>
|
|
private static float ToNormalized(float real, float min, float max)
|
|
=> max > min ? (real - min) / (max - min) : 0f;
|
|
|
|
/// <summary>Inverse of <see cref="ToNormalized"/>.</summary>
|
|
private static float FromNormalized(float normalized, float min, float max)
|
|
=> min + normalized * (max - min);
|
|
}
|