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; /// /// Campaign OP slice OP6 (2026-08-11): binds the Config tab (LayoutDesc /// 0x21000029, root 0x100001FF, ListBox 0x10000200) — /// retail's gmConfigUI::InitOptions @0x0049E400 — through OP2's /// template-list mechanism and OP3's per-page /// model, exactly like / /// . /// /// /// The row table below is transcribed directly from the decompiled /// registration, not from the research docs' own row table. Two /// grep-named-first sources supersede /// docs/research/2026-08-10-options-panel-structure.md §4/§9's /// summary (which itself flagged the slider-caption pairing as UNVERIFIED — /// its own "U4"): /// /// gmConfigUI::InitOptions @0x0049E400 — the /// authored row ORDER, widget shape per row (AddMenuOption/ /// AddToggleOption/AddSliderOption/ /// AddToggleWithSliderOption), and every SetDefaultValue /// literal. /// gmClient::InitUIPreferences @0x004035b0 — the /// COMPLETE UIPreferences::AttachPreference registration table: /// every row's exact label string key (ID_<Key>, hashed /// against string table 0x23000003, table-enum 0x10000003 /// matching AddHeader's own convention), its tooltip key /// (<label>_Help — 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 /// UIPreferences::SetPreferenceRange (NOT the widget's [0,1] scalar /// space — / convert /// at the build site), and every menu's UIPreferences::SetEnumChoices /// string array. /// /// /// /// /// U4 CORRECTED at the OP6 rework round (2026-08-11, review /// docs/research/2026-08-11-op6-review.md 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. BN renders every /// PlayerOptionPage::SetSliderLabel(this, slot, 0, 0) call with /// literal zero operands, but the raw bytes at each call site /// (0x0049E4C6/0x0049E51D/.../0x0049E68E, PE-byte- /// verified against C:\Users\erikn\Downloads\acclient.exe) are /// mov ecx, [ID_Graphics_Value_<High>] / /// mov edx, [ID_Graphics_Value_<Low>] — 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 /// (ChatOptionsPageController.BuildOpacitySliders's /// Transparent/Opaque pair, 0x0049FD37). Mouse Look Sensitivity (the /// SEVENTH slider, template idx3) genuinely gets NO SetSliderLabel /// call — that ONE omission is real and stays un-captioned; every idx6 row /// gets its low/high range-caption children (0x1000021E/ /// 0x1000021F) populated via 's optional /// rangeLowKey/rangeHighKey parameters. /// /// /// /// The row-template array (8 entries, Config's OWN ListBox property /// 0x64, cross-checked against the committed /// options_config_21000029.json/options_2100002B.json /// fixtures): idx0 header (Type 12 text, 0x10000216); idx1 /// separator (Type 3, 0x10000217); idx2 toggle row (Type 3, /// 0x10000218, one UIOption_Checkbox child 0x10000219); /// idx3 simple slider row (Type 3, 0x1000021A, name text /// 0x1000021B + slider 0x1000021C — used ONLY for Mouse Look /// Sensitivity, retail's own arg3=0 row); idx4 menu row (Type 3, /// 0x10000222, name text 0x10000223 + menu /// 0x10000224); idx5 toggle+slider trio (Type /// 0x10000036 = , /// 0x10000220, checkbox 0x10000219 + slider /// 0x1000021C, no separate name text — the checkbox's OWN label /// carries the row); idx6 range-captioned slider row (Type 3, /// 0x1000021D, name text 0x1000021B + slider /// 0x1000021C + POPULATED range-caption texts 0x1000021E/ /// 0x1000021F (OP6 rework, review M1 — see class doc's U4 note) — /// the six arg3=1 sliders); idx7 a SECOND /// toggle+slider trio template with range-caption children /// (0x10000221) that retail's own InitOptions never /// invokes — authored but dead, matching Chat's own unused-index pattern. /// /// /// /// Consumer disposition (OP6 contract §4): LIVE — the Sound/Ambient /// volume-trio sliders (AudioSettings.Sfx/Ambient, already /// live via ApplyAudio) and their toggle halves /// (/ /// — OP6 rework, review M2: retail's own ENABLED-sense fields, not /// "Disabled" — gating the SAME engine write); Resolution/Full Screen /// (DisplaySettings.Resolution/Fullscreen, 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 ApplyStartup): 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 DisplaySettings.ScreenBrightness /// field, range [-1,1] default 0 — NOT the pre-existing Gamma /// 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 QualityPreset, 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 ChatSettings.FontSize /// — 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 gmClient::InitUIPreferences @0x00403885- /// 0x004039ed, NOT a per-machine runtime enumeration as the /// rejected slice's comment claimed). /// /// /// /// Caption dimming (AD-78, user-directed, 2026-08-11, gate 2). 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 /// 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 storeOnly parameter /// at each of the 27 call sites, so wiring a future consumer means flipping /// that literal to -- the matching conformance test /// then fails until the change is made consciously. /// /// public static class ConfigOptionsPageController { /// Config page root — gmConfigUI — the STANDALONE /// 0x21000029 layout's own root id. Only present as its own /// distinct node when 0x21000029 is imported directly (e.g. /// FixtureLoader.LoadOptionsConfig()); when mounted through the /// tab host (0x2100006E/0x2100002B, what /// actually operates on), ElementReader.Merge's "derived id wins" /// rule means the PAGE SLOT keeps its own id /// () instead — use that one for any /// host-tree lookup (same split 's /// own RootElementId doc explains). public const uint RootElementId = 0x100001FFu; /// The Config page's SLOT element within the tab host /// (OptionsPanelController's own private ConfigPageId) — /// the id that actually survives base-merge in the host-mounted tree /// operates on. Used to SCOPE the scrollbar lookup /// below (the shared-id hazard with the Chat tab). private const uint PageSlotElementId = 0x10000213u; /// The row ListBox (dat Type 5) — m_pOptionBox. public const uint ListBoxElementId = 0x10000200u; /// The ListBox's linked scrollbar — SHARED with the Chat tab /// (research doc §10.1: both tabs author the same scrollbar element /// id). Scoped from 's page slot, exactly /// like 's own scrollbar lookup — /// the shared-id hazard this campaign's binding pattern exists for. 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; /// Slider row templates' (idx3/idx6) name-label text child. private const uint SliderLabelElementId = 0x1000021Bu; /// Slider leaf shared by every slider-bearing template /// (idx3/idx5/idx6) — retail's own single 0x1000021C convention, /// same id CharacterOptions/ChatOptions controllers already cite. private const uint SliderElementId = 0x1000021Cu; /// idx6 range-captioned slider template's low/high range-caption /// children (OP6 rework, review M1) — the SAME ids /// already cites for the exact /// same BN artifact on the Chat tab's own labelled slider. private const uint SliderRangeMinElementId = 0x1000021Eu; private const uint SliderRangeMaxElementId = 0x1000021Fu; /// Menu row template's (idx4) name-label text child. private const uint MenuLabelElementId = 0x10000223u; /// Menu row template's (idx4) leaf. private const uint MenuElementId = 0x10000224u; /// Toggle checkbox leaf shared by the plain toggle row (idx2) /// and the trio row's toggle half (idx5) — retail's own single /// 0x10000219 convention. private const uint ToggleCheckboxElementId = 0x10000219u; /// /// #378 fix (2026-08-11, gate 4): every Config-tab dropdown /// (, retail class UIOption_Menu, /// Type 0x10000038) rendered bare — no button well, no arrow, no /// popup — because NOTHING wired its sprites, the same class of bug /// 's own precedent already fixed for /// the vendor category dropdown. A live-DAT probe (menuprobe2, /// OptionsPanelLiveMountProbeTests) walked the retail inheritance /// chain the built widget tree collapses away /// (0x10000224 → base 0x10000353 in LayoutDesc /// 0x21000043 — retail's SHARED popup/dropdown catalog, the exact /// same layout 's own 0x100000BF /// also derives from, base 0x1000034B) and found the two /// prototypes BYTE-IDENTICAL in every sprite id: arrow cap /// 0x060012B1/B2, face/row sprite 0x060012B3/B4, /// and the full 6-sprite scrollbar chrome /// 0x06004C5F/60/63/66/69/6C — Config's own popup root/ListBox /// (0x10000357/0x10000358, attrs 6/2 on 0x10000353) /// carry the SAME 0x59=true/0x5D=1-col/0x5E=6-row/ /// 0x61=true properties as vendor's own 0x1000034F/ /// 0x10000350, and Config's row template (attr 9 → /// 0x1000035A) resolves the IDENTICAL Normal/Highlight media as /// vendor's own 0x10000352. Attribute 7 (the popup catalog /// LayoutDesc) is 0x21000043 for BOTH — not an approximation /// choice, a measured fact; no register row needed. No property "5" /// (open-upward) is authored on 0x10000353 either, matching /// vendor's own absent-defaults-false case — the popup opens DOWNWARD. /// These constants are therefore transcribed from the SAME live values /// already hardcodes, not duplicated by /// coincidence — a genuine shared retail asset, verified independently /// rather than assumed from the precedent. /// 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; } /// Applies + geometry to a /// freshly-built leaf — the SAME wiring shape /// 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). private static void ApplyMenuChrome( UiMenu menu, Func? 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; } /// 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). public sealed record Bindings( Func LoadDisplay, Action SaveDisplay, Func LoadAudio, Action SaveAudio, Func LoadCameraTurning, Action SaveCameraTurning, Func LoadChat, Action SaveChat); /// /// Builds the six authored sections (Sound/Camera/Graphics/Rendering /// Quality/Input/UI Options) into 's Config /// ListBox, links its scrollbar, seeds every row's current/default /// state, and registers each row into . /// /// #378: RenderSurface id → (GL tex handle, /// pixel width, pixel height), threaded onto every dropdown's /// — see 's /// own doc. Null (fixture/conformance callers that don't exercise /// dropdown chrome) leaves every menu's sprite ids populated but drawing /// nothing, matching 's own null-safety. /// #378: the retail dat-driven font every dropdown /// draws its button/row labels with. /// #378: fallback bitmap font when /// is unavailable — same convention /// uses. public static bool Bind( ImportedLayout layout, OptionPage page, Func templateResolver, Func resolveString, Bindings bindings, Func? 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 resolveString, Bindings bindings, ref AudioSettings audio, Func? 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 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 resolveString, Bindings bindings, ref DisplaySettings display, Func? 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 resolveString, Bindings bindings, ref DisplaySettings display, Func? 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 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 ─────────────────────────────────────────── /// /// 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: /// gmClient::InitUIPreferences builds a FIXED five-entry array /// (0x00403885-0x004039ed) — Arial, CourierNew, /// PalatinoLinotype, Tahoma, TimesNewRoman, in that order, then ONE /// SetEnumChoices call — the exact same /// SmartArray<unsigned long,1>::grow push idiom /// already uses for its five entries; /// there is no unbounded/per-machine loop. All five strings are present /// verbatim in the binary's .rdata (PE-byte-verified against /// C:\Users\erikn\Downloads\acclient.exe). Default index 2 /// (PalatinoLinotype, SetDefaultValue(2) @0x0049e7de) now indexes /// a real entry instead of falling past a 1-item array. /// 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 resolveString, Bindings bindings, ref ChatSettings chat, Func? 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 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 resolveString, Func read, Action 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); } /// Builds a slider row from EITHER /// (idx3, Mouse Look Sensitivity only) or /// (idx6, the other six) — structurally identical leaves for this /// controller's purposes (name text + slider). / /// (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 / /// traffic in — the settings-store unit) and the widget's normalized /// [0,1] scalar space. private static void BuildSliderRow( UiTemplateListBox listBox, int templateIndex, string labelKey, float min, float max, float defaultValue, OptionPage page, Func resolveString, Func read, Action 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)); } /// Populates one range-caption text child (idx6 template only — /// /) /// — the same shape /// already ports for the identical BN artifact on the Chat tab's own /// labelled slider (OP6 rework, review M1). private static void SetRangeLabel( UiElement row, uint elementId, string labelKey, Func 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) }; } /// 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. /// (OP6 rework, review S3) is the retail preference key for the SLIDER /// half's OWN AttachPreference registration (e.g. /// Sound_SoundVolume's ID_Sound_EffectVolume) — used ONLY /// to resolve its "_Help" tooltip, never as visible label text (the row /// has none for the slider half). private static void BuildTrioRow( UiTemplateListBox listBox, string toggleLabelKey, string sliderTooltipKey, bool toggleDefault, float sliderMin, float sliderMax, float sliderDefault, OptionPage page, Func resolveString, Func toggleRead, Action toggleApply, Func sliderRead, Action 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)); } /// Builds a menu row (idx4) over an payload — /// every Config-tab menu except Resolution (see /// ). private static void BuildMenuRow( UiTemplateListBox listBox, string labelKey, string[] choiceKeys, OptionPage page, Func resolveString, Func read, Action apply, int defaultValue, bool storeOnly, Func? 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); }; } /// Resolution's own menu — the one Config-tab row built via /// retail's arg3=0/SetUserPreference path rather than the /// generic UIPreferences::InqPreference label lookup every other /// menu here uses; its payload is the resolution string itself. private static void BuildStringMenuRow( UiTemplateListBox listBox, string labelKey, IReadOnlyList choices, OptionPage page, Func resolveString, Func read, Action apply, string defaultValue, bool storeOnly, Func? 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 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; } /// /// Resolves one <key>_Help tooltip string — the SAME suffix /// convention already used for toggle /// rows, byte-verified as universal across all 27+ Config-tab /// AttachPreference sites (OP6 rework, review S3): every row's /// tooltip key is its own label key with _Help appended, no /// exceptions found. Missing silently (no invented English), matching /// every other string lookup on this page. /// private static string? ResolveTooltip(string labelKey, Func resolveString) => resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help")); private static void SetLabelText( UiText label, string labelKey, Func 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; } /// Real-unit value → the widget's normalized [0,1] scalar /// space ('s own clamp). private static float ToNormalized(float real, float min, float max) => max > min ? (real - min) / (max - min) : 0f; /// Inverse of . private static float FromNormalized(float normalized, float min, float max) => min + normalized * (max - min); }