feat(ui): Campaign OP slice OP6 — the Config tab
Binds the retail Options panel's Config tab (LayoutDesc 0x21000029, 27 authored rows across 6 sections) through OP2's template mechanism and OP3's per-page OptionPage model, matching the Character/Chat tab controllers' established pattern. The row table is transcribed directly from two decompiled sources — gmConfigUI::InitOptions @0x0049E400 (row order, widget shape, defaults) and gmClient::InitUIPreferences @0x004035b0 (the complete UIPreferences::AttachPreference registration: every label/tooltip key, every slider's real-unit range, every menu's enum choices) — which resolves the research docs' own "U4" unverified slider-caption pairing: retail ships ZERO range captions on this tab (every SetSliderLabel call passes literal string id 0). Consumer disposition: LIVE — Sound/Ambient volume-trio sliders and their toggle halves (AudioSettings.SfxDisabled/AmbientDisabled now gate the already-live engine write; RuntimeSettingsController.SaveAudio newly pushes into OpenAlAudioEngine on every change, not just at startup), Resolution/Full Screen (immediate window resize on save). NEXT-LAUNCH (pre-existing precedent): Sync To Refresh, Field of View. STORE-ONLY (register rows AP-198/199/200, TS-74 extended): Sound Features/Interface trio/Play-Only-When-Active, the nine Graphics/Rendering-Quality rows (Vulkan has no per-feature render knobs), Camera/Input's six rows and Use Mouse Turning (no persistent mouse-turning camera mode), Chat Font Face/Size (distinct new fields from the existing live ChatSettings.FontSize). AudioSettings/DisplaySettings/CameraTurningSettings/ChatSettings each gain new fields for their slice of the 27 rows, backed by SettingsStore round-trips. A real bug caught by testing: the scrollbar scope lookup used the standalone-layout root id (0x100001FF), which does not survive base-merge into the host-mounted tree — fixed to scope from the tab host's own page-slot id (0x10000213), matching Chat's established pattern for the same shared-scrollbar-id hazard (0x10000201, authored by both the Chat and Config ListBoxes). 30 new tests (27 authored rows register as 30 IOptionRow instances — the three toggle+slider trios each register two). Full Release suite: 13,107 passed / 4 skipped / 0 failed (was 13,083/4/0 — net +24, the one existing RuntimeSettingsControllerTests case updated for SaveAudio's new live-apply call, not a regression). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
527a3a4056
commit
f5ac1742ba
16 changed files with 2188 additions and 30 deletions
987
src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs
Normal file
987
src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs
Normal file
|
|
@ -0,0 +1,987 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
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), 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 resolved: retail ships ZERO slider range captions on this tab.</b>
|
||||
/// Every one of the seven <c>PlayerOptionPage::SetSliderLabel</c> calls this
|
||||
/// tab makes passes literal <c>(0, 0)</c> for the low/high string ids
|
||||
/// (<c>0x0049E4E0</c>/<c>0x0049E51D</c>/<c>0x0049E556</c>/<c>0x0049E614</c>/
|
||||
/// <c>0x0049E65D</c>/<c>0x0049E68E</c> — six calls; the seventh slider,
|
||||
/// Mouse Look Sensitivity, gets NO <c>SetSliderLabel</c> call at all).
|
||||
/// <c>SetSliderLabel</c>'s own body (<c>0x004F2B80</c>) has no <c>IsValid</c>
|
||||
/// guard before writing — unlike <c>AddHeader</c> — so <c>StringId=0</c>
|
||||
/// resolves to blank text either way. The <c>ID_Graphics_Value_*</c> globals
|
||||
/// (Dark/Bright, Speed/Detail, ...) the structure doc's §4 found ARE real
|
||||
/// strings elsewhere in the binary, but THIS tab's <c>InitOptions</c> never
|
||||
/// wires them to any row — they are dead literals for this build. This
|
||||
/// controller therefore never touches the range-caption text children
|
||||
/// (<c>0x1000021E</c>/<c>0x1000021F</c>) at all — omitting them is the
|
||||
/// faithful port, not a shortcut.
|
||||
/// </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> + UNUSED range-caption texts <c>0x1000021E</c>/
|
||||
/// <c>0x1000021F</c> — 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.SfxDisabled"/>/<see cref="AudioSettings.AmbientDisabled"/>,
|
||||
/// 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 (reuses the existing inert <c>DisplaySettings.Gamma</c> — no
|
||||
/// gamma-correction pass exists); 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).
|
||||
/// </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>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>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>
|
||||
public static bool Bind(
|
||||
ImportedLayout layout,
|
||||
OptionPage page,
|
||||
Func<uint, uint, UiElement?> templateResolver,
|
||||
Func<uint, uint, string?> resolveString,
|
||||
Bindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(page);
|
||||
ArgumentNullException.ThrowIfNull(templateResolver);
|
||||
ArgumentNullException.ThrowIfNull(resolveString);
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
|
||||
if (layout.FindElement(ListBoxElementId) is not UiTemplateListBox listBox)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] 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;
|
||||
|
||||
// ScrollbarElementId (0x10000201) is SHARED with the Chat tab
|
||||
// (0x1000050D's own scrollbar lookup cites the same hazard) —
|
||||
// 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.
|
||||
UiElement? configPageSlot = layout.FindElement(PageSlotElementId);
|
||||
UiElement? scrollbarElement = configPageSlot is null
|
||||
? 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);
|
||||
BuildSeparatorRow(listBox);
|
||||
BindCameraSection(listBox, page, resolveString, bindings, ref cameraTurning);
|
||||
BuildSeparatorRow(listBox);
|
||||
BindGraphicsSection(listBox, page, resolveString, bindings, ref display);
|
||||
BuildSeparatorRow(listBox);
|
||||
BindRenderingQualitySection(listBox, page, resolveString, bindings, ref display);
|
||||
BuildSeparatorRow(listBox);
|
||||
BindInputSection(listBox, page, resolveString, bindings, ref cameraTurning);
|
||||
BuildSeparatorRow(listBox);
|
||||
BindUiSection(listBox, page, resolveString, bindings, ref chat);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Section 1: Sound Options ────────────────────────────────────────
|
||||
|
||||
private static void BindSoundSection(
|
||||
UiTemplateListBox listBox,
|
||||
OptionPage page,
|
||||
Func<uint, uint, string?> resolveString,
|
||||
Bindings bindings,
|
||||
ref AudioSettings audio)
|
||||
{
|
||||
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);
|
||||
|
||||
BuildTrioRow(
|
||||
listBox, "ID_Sound_DisableSound", toggleDefault: true,
|
||||
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
||||
page, resolveString,
|
||||
toggleRead: () => bindings.LoadAudio().SfxDisabled,
|
||||
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { SfxDisabled = value }),
|
||||
sliderRead: () => bindings.LoadAudio().Sfx,
|
||||
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Sfx = value }));
|
||||
|
||||
BuildTrioRow(
|
||||
listBox, "ID_Sound_DisableAmbientSound", toggleDefault: true,
|
||||
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
||||
page, resolveString,
|
||||
toggleRead: () => bindings.LoadAudio().AmbientDisabled,
|
||||
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { AmbientDisabled = value }),
|
||||
sliderRead: () => bindings.LoadAudio().Ambient,
|
||||
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Ambient = value }));
|
||||
|
||||
// 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", toggleDefault: true,
|
||||
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
||||
page, resolveString,
|
||||
toggleRead: () => bindings.LoadAudio().InterfaceDisabled,
|
||||
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceDisabled = value }),
|
||||
sliderRead: () => bindings.LoadAudio().InterfaceVolume,
|
||||
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceVolume = value }));
|
||||
|
||||
BuildToggleRow(
|
||||
listBox, "ID_Sound_NoFocusNoSound", defaultValue: true, page, resolveString,
|
||||
read: () => bindings.LoadAudio().PlaySoundOnlyWhenActive,
|
||||
apply: value => bindings.SaveAudio(bindings.LoadAudio() with { PlaySoundOnlyWhenActive = value }));
|
||||
|
||||
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.
|
||||
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 }));
|
||||
|
||||
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 }));
|
||||
|
||||
// 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 }));
|
||||
|
||||
BuildToggleRow(
|
||||
listBox, "ID_Camera_AlignToSlope", defaultValue: true, page, resolveString,
|
||||
read: () => bindings.LoadCameraTurning().AlignToSlope,
|
||||
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AlignToSlope = value }));
|
||||
|
||||
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)
|
||||
{
|
||||
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) is NOT one of
|
||||
// DisplaySettings.AvailableResolutions' modern presets — the same
|
||||
// "opaque default the menu may not highlight" shape as
|
||||
// LandscapeDrawDistance (register row AP-198's own sub-note), NOT
|
||||
// a functional gap: TryParseResolution accepts any "WxH" string,
|
||||
// so clicking Defaults still resizes the window correctly to
|
||||
// 800x600, it just may not show a highlighted dropdown row. A
|
||||
// FRESH profile's own current value (DisplaySettings.Default =
|
||||
// "1280x720") IS in the preset list and highlights normally.
|
||||
BuildStringMenuRow(
|
||||
listBox, "ID_Rendering_DisplayResolution",
|
||||
DisplaySettings.AvailableResolutions, page, resolveString,
|
||||
read: () => bindings.LoadDisplay().Resolution,
|
||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Resolution = value }),
|
||||
defaultValue: "800x600");
|
||||
|
||||
BuildToggleRow(
|
||||
listBox, "ID_Rendering_FullScreen", defaultValue: true, page, resolveString,
|
||||
read: () => bindings.LoadDisplay().Fullscreen,
|
||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Fullscreen = value }));
|
||||
|
||||
// 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 }));
|
||||
|
||||
// Screen Brightness: reuses the existing DisplaySettings.Gamma
|
||||
// field (no gamma-correction render pass exists — inert, store-only).
|
||||
BuildSliderRow(
|
||||
listBox, RangedSliderTemplateIndex, "ID_Graphics_ScreenBrightness",
|
||||
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
|
||||
read: () => bindings.LoadDisplay().Gamma,
|
||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Gamma = value }));
|
||||
|
||||
BuildToggleRow(
|
||||
listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString,
|
||||
read: () => bindings.LoadDisplay().AutomaticDegrades,
|
||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value }));
|
||||
|
||||
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 }));
|
||||
|
||||
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 }));
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
BuildMenuRow(
|
||||
listBox, "ID_Graphics_EnvironmentTextureDetail", TextureDetailChoices, page, resolveString,
|
||||
read: () => bindings.LoadDisplay().EnvironmentTextureDetail,
|
||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { EnvironmentTextureDetail = value }),
|
||||
defaultValue: 1);
|
||||
|
||||
BuildMenuRow(
|
||||
listBox, "ID_Graphics_TextureFiltering", TextureFilteringChoices, page, resolveString,
|
||||
read: () => bindings.LoadDisplay().TextureFiltering,
|
||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { TextureFiltering = value }),
|
||||
defaultValue: 1);
|
||||
|
||||
// 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);
|
||||
|
||||
BuildToggleRow(
|
||||
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
|
||||
read: () => bindings.LoadDisplay().BuildingDetailTextures,
|
||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }));
|
||||
|
||||
BuildToggleRow(
|
||||
listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString,
|
||||
read: () => bindings.LoadDisplay().MultiPassAlpha,
|
||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { MultiPassAlpha = value }));
|
||||
|
||||
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 }));
|
||||
|
||||
BuildToggleRow(
|
||||
listBox, "ID_Input_InvertMouseLookYAxis", defaultValue: false, page, resolveString,
|
||||
read: () => bindings.LoadCameraTurning().InvertMouseLookYAxis,
|
||||
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { InvertMouseLookYAxis = value }));
|
||||
|
||||
// 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 }));
|
||||
|
||||
cameraTurning = bindings.LoadCameraTurning();
|
||||
}
|
||||
|
||||
// ── Section 6: UI Options ───────────────────────────────────────────
|
||||
|
||||
private static readonly string[] ChatFontFaceChoices =
|
||||
{
|
||||
"ID_UI_Value_Arial",
|
||||
};
|
||||
|
||||
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)
|
||||
{
|
||||
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 is authored with only ONE literal decoded so
|
||||
// far ("Arial") — the remaining installed system fonts are a
|
||||
// per-machine list retail enumerates at runtime
|
||||
// (UIPreferences::AttachPreference's own SmartArray grow loop has
|
||||
// no fixed upper bound in the decompile); the menu still shows
|
||||
// every choice this table names rather than guessing a longer list.
|
||||
BuildMenuRow(
|
||||
listBox, "ID_UI_ChatFontFace", ChatFontFaceChoices, page, resolveString,
|
||||
read: () => bindings.LoadChat().ChatFontFace,
|
||||
apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontFace = value }),
|
||||
defaultValue: 2);
|
||||
|
||||
BuildMenuRow(
|
||||
listBox, "ID_UI_ChatFontSize", ChatFontSizeChoices, page, resolveString,
|
||||
read: () => bindings.LoadChat().ChatFontSizeIndex,
|
||||
apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontSizeIndex = value }),
|
||||
defaultValue: 1);
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
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; idx6's extra range-caption
|
||||
/// children are deliberately left untouched — 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)
|
||||
{
|
||||
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);
|
||||
|
||||
if (UiElement.FindDescendant(row, SliderElementId) is not UiScrollbar slider)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ConfigOptionsPageController: no slider leaf found in the row for "
|
||||
+ $"'{labelKey}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
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>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.</summary>
|
||||
private static void BuildTrioRow(
|
||||
UiTemplateListBox listBox,
|
||||
string toggleLabelKey,
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ConfigOptionsPageController: no UiMenu leaf found in the row for "
|
||||
+ $"'{labelKey}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ConfigOptionsPageController: no UiMenu leaf found in the row for "
|
||||
+ $"'{labelKey}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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.");
|
||||
|
||||
string? tooltip = resolveString(
|
||||
StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
|
||||
if (tooltip is not null)
|
||||
checkbox.TooltipText = tooltip;
|
||||
}
|
||||
|
||||
private static void SetLabelText(UiText label, string labelKey, Func<uint, uint, string?> resolveString)
|
||||
{
|
||||
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;
|
||||
}
|
||||
label.LinesProvider = () => new[] { new UiText.Line(text, label.DefaultColor) };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue