fix(ui): OP6 rework — six range captions, un-invert Sound enabled flags, five font faces
Fixes all three MUST-FIX findings from the OP6 REJECT review (docs/research/2026-08-11-op6-review.md) plus its SHOULD-FIXes and NOTEs. M1 — the "retail ships zero range captions" claim was a Binary Ninja constant-folding artifact (the same class the header-string globals a few lines above already worked around). The six SetSliderLabel call sites byte-decode to reads of runtime-filled ID_Graphics_Value_* globals, not immediate zeros (PE-byte-verified against the PDB-paired acclient.exe, independently re-derived in this session, not just re-asserted from the review). ConfigOptionsPageController.BuildSliderRow gained optional rangeLowKey/rangeHighKey parameters wired for all six idx6 sliders (Camera Stiffness Soft/Hard, Adjustment Speed Slow/Fast, FOV Narrow/Wide, Screen Brightness Dark/Bright, Graphics Performance Speed/Detail, Degrade Distance Close/Far) via the same SetRangeLabel mechanism OP5's Chat opacity sliders already established. Mouse Look Sensitivity (idx3) correctly stays uncaptioned — the one genuine SetSliderLabel omission. Class doc corrected; gate-script lines 535/653-equivalent corrected in place. M2 — the three Sound "Disabled" toggles were semantically inverted: SoundManager::effect_sounds_enabled/ambient_sounds_enabled/ interface_sounds_enabled are all compiled = 1 in .data, and UserPreferences::RegisterPreference binds the checkbox's boolean value DIRECTLY onto those enabled-sense statics — checked-by-default means enabled-by-default, not disabled. AudioSettings.SfxDisabled/AmbientDisabled/ InterfaceDisabled renamed to SfxEnabled/AmbientEnabled/InterfaceEnabled (fresh JSON keys — the rejected slice's keys never shipped in an accepted build); RuntimeSettingsStartupTargets.ApplyAudio now computes effective volume through the extracted, independently-unit-tested pure function ComputeEffectiveCategoryVolumes (enabled ? slider : 0f). This closes the blast radius the review flagged: a missing key in an EXISTING settings.json now falls back to AudioSettings.Default, which is enabled=true, so a fresh launch is audible, not muted. AP-199's wording and gate-script step 6 corrected; the enshrined-inversion test rewritten to assert the correct default and a new SettingsStore test pins the legacy-file fallback path. M3 — UI_ChatFontFace now ships all five of retail's authored choices (Arial, CourierNew, PalatinoLinotype, Tahoma, TimesNewRoman — a fixed compile-time array at gmClient::InitUIPreferences, PE-byte-verified present verbatim in .rdata, not a per-machine runtime enumeration as the rejected slice's comment claimed). Default index 2 (PalatinoLinotype) now indexes a real entry. S1 — Bind() now emits the sixth trailing AddSeperator retail's own InitOptions ends with (0x0049E80D), matching retail's 39-item ListBox (6 headers + 6 separators + 27 option-widget-rows) instead of 38. S2 — Screen Brightness gets its own DisplaySettings.ScreenBrightness field ([-1,1], default 0) instead of overloading Gamma, which has a different unit system (default 1.0, legacy [0.5,2.0] slider) and its own live Settings-panel consumer. S3 — UiScrollbar and UiMenu gained a settable TooltipText surfaced through GetTooltipText (UiButton's existing pattern). Every slider and menu row's own interactive widget (not just toggle/trio rows) now carries retail's "<label>_Help" tooltip, verified as a universal suffix convention across every AttachPreference site touched by this tab. S4 — "800x600" added to DisplaySettings.AvailableResolutions: a genuine retail display mode (Device::ForceDisplayResolution(1,0x320,0x258) at startup) and the Config tab's own byte-verified Resolution row default, not an invented preset. Defaults now lands on a highlighted, re-selectable dropdown entry instead of an orphaned value. S5 — four new/extended tests: ComputeEffectiveCategoryVolumes gets a dedicated pure-function value assertion (Theory + a default-profile-is- audible Fact) in RuntimeSettingsControllerTests, closing the "only event order was asserted" gap that let M2 ship; a label/choice-key conformance table in ConfigOptionsPageControllerTests enumerates every key this tab queries (traced directly from the fixed code paths, not guessed) and fails on an invented OR a dropped key; a per-row DefaultValue pin asserts every row's default against the retail literal directly, independent of the underlying settings-record defaults; and the S1 separator fix gets its own 39-item stacked-ListBox count pin. NOTEs — AP-198's row count was always ten (its own enumeration never said nine); the commit-message inconsistency N1 flagged is reconciled in both the row and the section-summary line, and its Screen Brightness sub-clause now matches S2. N2: Bind() now reads the scrollbar id from UiTemplateListBox.ScrollbarElementId (dat property 0x72) instead of a hardcoded constant. N3 (batch Defaults writes) and N4 (AfterApply on Config-tab entry, needs no action) are left as recorded — out of this rework's scope per the review's own disposition. Full Release suite: 13,125 passed / 4 skipped / 0 failed (baseline 13,117/4/0 — net +8 tests added, 0 regressions, 0 removed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4996974cfd
commit
472525b99e
11 changed files with 774 additions and 168 deletions
File diff suppressed because one or more lines are too long
|
|
@ -531,9 +531,17 @@ rows total, byte-verified directly from `gmConfigUI::InitOptions
|
||||||
@0x0049E400` and `gmClient::InitUIPreferences @0x004035b0` (the retail
|
@0x0049E400` and `gmClient::InitUIPreferences @0x004035b0` (the retail
|
||||||
registration table that supplies every row's label/tooltip/range/enum-
|
registration table that supplies every row's label/tooltip/range/enum-
|
||||||
choice — a stronger source than this campaign's own research docs, which
|
choice — a stronger source than this campaign's own research docs, which
|
||||||
had left the slider-caption pairing explicitly UNVERIFIED; OP6 resolved it:
|
had left the slider-caption pairing explicitly UNVERIFIED). **CORRECTED at
|
||||||
**retail ships zero range captions on this tab** — do not expect
|
the OP6 rework round (2026-08-11, review M1):** the "zero range captions"
|
||||||
"Dark/Bright"-style low/high labels on any slider).
|
reading was a Binary Ninja constant-folding artifact — the raw bytes at
|
||||||
|
each `SetSliderLabel` call site are reads of runtime-filled string-id
|
||||||
|
globals, not immediate zeros. **Retail DOES ship six low/high range
|
||||||
|
captions**, one per labelled slider: 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). Mouse Look Sensitivity is the one genuine exception —
|
||||||
|
retail's own `InitOptions` never calls `SetSliderLabel` for it, so it alone
|
||||||
|
should show no caption.
|
||||||
|
|
||||||
### Opening the tab and reading the rows
|
### Opening the tab and reading the rows
|
||||||
|
|
||||||
|
|
@ -556,20 +564,27 @@ had left the slider-caption pairing explicitly UNVERIFIED; OP6 resolved it:
|
||||||
ambient sound source, or trigger a combat/UI sound repeatedly). The
|
ambient sound source, or trigger a combat/UI sound repeatedly). The
|
||||||
volume should change LIVE, continuously, as you drag — not just on
|
volume should change LIVE, continuously, as you drag — not just on
|
||||||
release.
|
release.
|
||||||
4. **Check the "Disable Sound Effects" checkbox** (the SAME row's toggle
|
4. **CORRECTED at the OP6 rework round (2026-08-11, review M2) — the
|
||||||
half). Sound effects should go SILENT immediately, regardless of where
|
checkbox's stored value is ENABLED-sense, not "Disabled" as the
|
||||||
the slider is set. Uncheck it — sound effects resume at the slider's
|
rejected slice's own gate script previously described.** With the
|
||||||
current level.
|
"Disable Sound Effects" checkbox CHECKED (its default state — see step
|
||||||
5. **Repeat steps 3-4 for the "Ambient Sound" trio** (the second row) using
|
6), sound effects are AUDIBLE. **Uncheck it** — sound effects go SILENT
|
||||||
|
immediately, regardless of where the slider is set. Check it again —
|
||||||
|
sound effects resume at the slider's current level. (The checkbox's own
|
||||||
|
retail LABEL reads as if checked should mean muted; byte evidence says
|
||||||
|
the opposite — see `AudioSettings`' class doc for the full citation.
|
||||||
|
This gate is about the OBSERVED behaviour, not the label's English.)
|
||||||
|
5. **Repeat step 4 for the "Ambient Sound" trio** (the second row) using
|
||||||
an ambient loop (wind, water, torches) as your audible test — same live
|
an ambient loop (wind, water, torches) as your audible test — same live
|
||||||
drag + mute-on-check behaviour.
|
drag + mute-on-uncheck behaviour.
|
||||||
6. **Note the retail-faithful surprise:** on a FRESH character (never
|
6. **Note the retail-faithful default:** on a FRESH character (never
|
||||||
touched these settings), BOTH "Disable Sound Effects" and "Disable
|
touched these settings), BOTH "Disable Sound Effects" and "Disable
|
||||||
Ambient Sound" checkboxes are CHECKED by default — i.e., sound starts
|
Ambient Sound" checkboxes are CHECKED by default
|
||||||
MUTED out of the box. This is retail's own byte-verified 2013 EoR
|
(`gmConfigUI::InitOptions`'s `SetDefaultValue(1, ...)` on both trios) —
|
||||||
default (`gmConfigUI::InitOptions`'s `SetDefaultValue(1, ...)` on both
|
and CHECKED means AUDIBLE, matching retail's own byte-verified
|
||||||
trios), reproduced faithfully — **not a bug**, however counterintuitive.
|
`SoundManager::effect_sounds_enabled`/`ambient_sounds_enabled` statics
|
||||||
Uncheck both to hear anything.
|
(both compiled `= 1`). **Sound starts ON out of the box, not muted.**
|
||||||
|
If you hear silence on a fresh character, that IS a defect — report it.
|
||||||
7. **Drag the third slider / check its toggle ("Disable Interface Sound",
|
7. **Drag the third slider / check its toggle ("Disable Interface Sound",
|
||||||
third trio).** Expect NO audible change either way — this is retail's
|
third trio).** Expect NO audible change either way — this is retail's
|
||||||
OWN dead knob (register row AP-174/AP-199): retail registers this
|
OWN dead knob (register row AP-174/AP-199): retail registers this
|
||||||
|
|
@ -626,10 +641,13 @@ had left the slider-caption pairing explicitly UNVERIFIED; OP6 resolved it:
|
||||||
ACTUAL effect too (sound un-mutes or re-mutes, resolution changes
|
ACTUAL effect too (sound un-mutes or re-mutes, resolution changes
|
||||||
back), not just the checkbox art.
|
back), not just the checkbox art.
|
||||||
15. **Click Defaults.** Every row jumps LIVE to retail's byte-verified
|
15. **Click Defaults.** Every row jumps LIVE to retail's byte-verified
|
||||||
default (Sound/Ambient trios back to muted+100% volume, Resolution to
|
default (Sound/Ambient trios back to AUDIBLE+100% volume — CORRECTED
|
||||||
800×600, Full Screen on, Camera Stiffness to 0.45, etc.) — Apply/Reset
|
at the OP6 rework round, review M2; the checked-by-default checkbox
|
||||||
light up afterward if anything actually changed (Defaults never
|
means enabled, not muted — Resolution to 800×600, now a highlighted
|
||||||
commits by itself).
|
dropdown entry since it is one of `DisplaySettings.AvailableResolutions`
|
||||||
|
(review S4), Full Screen on, Camera Stiffness to 0.45, etc.) —
|
||||||
|
Apply/Reset light up afterward if anything actually changed (Defaults
|
||||||
|
never commits by itself).
|
||||||
16. **Switch to another tab (e.g. Gameplay) without clicking Apply after
|
16. **Switch to another tab (e.g. Gameplay) without clicking Apply after
|
||||||
an edit.** Uncommitted edits silently revert — switching tabs is a
|
an edit.** Uncommitted edits silently revert — switching tabs is a
|
||||||
Reset, not a save.
|
Reset, not a save.
|
||||||
|
|
@ -649,11 +667,18 @@ had left the slider-caption pairing explicitly UNVERIFIED; OP6 resolved it:
|
||||||
|
|
||||||
### What to report
|
### What to report
|
||||||
|
|
||||||
- Any section/row/label missing, or a slider showing a low/high range
|
- Any section/row/label missing, OR (CORRECTED at the OP6 rework round,
|
||||||
caption (retail ships none on this tab — see this section's own intro).
|
review M1) any of the SIX labelled sliders (Camera Stiffness/Adjustment
|
||||||
|
Speed, Field Of View, Screen Brightness, Graphics Performance, Degrade
|
||||||
|
Distance) MISSING its low/high range caption — retail ships one on each
|
||||||
|
of those six (see this section's own intro). Mouse Look Sensitivity is
|
||||||
|
the one slider that should show none.
|
||||||
- A live row (Sound/Ambient volume+mute, Resolution, Full Screen) that
|
- A live row (Sound/Ambient volume+mute, Resolution, Full Screen) that
|
||||||
does not change live, or a store-only row that unexpectedly DOES change
|
does not change live, or a store-only row that unexpectedly DOES change
|
||||||
something (a sign its "no consumer" premise is stale).
|
something (a sign its "no consumer" premise is stale).
|
||||||
|
- Sound effects or ambient audio SILENT on a freshly-created character
|
||||||
|
before you touch any Config-tab control (see step 6) — that is the M2
|
||||||
|
regression, not expected behaviour.
|
||||||
- Reset/Defaults not behaving as described in 14-15.
|
- Reset/Defaults not behaving as described in 14-15.
|
||||||
- Any row that fails to persist across a full relaunch (step 18),
|
- Any row that fails to persist across a full relaunch (step 18),
|
||||||
including the two next-launch-only rows (Sync To Refresh, Field of
|
including the two next-launch-only rows (Sync To Refresh, Field of
|
||||||
|
|
|
||||||
|
|
@ -136,16 +136,34 @@ internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTar
|
||||||
if (engine is not { IsAvailable: true })
|
if (engine is not { IsAvailable: true })
|
||||||
return;
|
return;
|
||||||
engine.MasterVolume = audio.Master;
|
engine.MasterVolume = audio.Master;
|
||||||
// Campaign OP slice OP6: the Config tab's toggle halves of the
|
(float sfx, float ambient) = ComputeEffectiveCategoryVolumes(audio);
|
||||||
// Sound/Ambient volume trios (Sound_SoundDisabled/
|
engine.SfxVolume = sfx;
|
||||||
// Sound_AmbientSoundDisabled) gate the SAME slider value — retail's
|
engine.AmbientVolume = ambient;
|
||||||
// UIOption_CheckboxSlider is one row over two preferences, and a
|
}
|
||||||
// checked "Disable Sound Effects" LED mutes the category regardless
|
|
||||||
// of what the slider itself is set to. No engine-level "disabled"
|
/// <summary>
|
||||||
// concept is needed: the effective volume sent IS zero when
|
/// Campaign OP slice OP6 (2026-08-11), CORRECTED at the OP6 rework round
|
||||||
// disabled, exactly as if the user dragged the slider to zero.
|
/// (review <c>docs/research/2026-08-11-op6-review.md</c> finding M2): the
|
||||||
engine.SfxVolume = audio.SfxDisabled ? 0f : audio.Sfx;
|
/// Config tab's toggle halves of the Sound/Ambient volume trios
|
||||||
engine.AmbientVolume = audio.AmbientDisabled ? 0f : audio.Ambient;
|
/// (<see cref="AudioSettings.SfxEnabled"/>/<see cref="AudioSettings.AmbientEnabled"/>
|
||||||
|
/// — retail's own ENABLED-sense <c>SoundManager::effect_sounds_enabled</c>/
|
||||||
|
/// <c>ambient_sounds_enabled</c> statics, see <see cref="AudioSettings"/>'s
|
||||||
|
/// class doc for the byte evidence) gate the SAME slider value: the
|
||||||
|
/// effective volume sent to the engine is the slider value when enabled,
|
||||||
|
/// zero when not — exactly as if the user dragged the slider to zero.
|
||||||
|
/// Extracted as a pure function (no <see cref="OpenAlAudioEngine"/>
|
||||||
|
/// dependency) so the mapping itself — not just that some target was
|
||||||
|
/// called — is unit-testable without OpenAL hardware/mocking (S5: the
|
||||||
|
/// rejected slice's only audio test asserted event ORDER, never the
|
||||||
|
/// VALUE that reached the engine, which is exactly how M2's muted-by-
|
||||||
|
/// default inversion shipped unnoticed).
|
||||||
|
/// </summary>
|
||||||
|
internal static (float Sfx, float Ambient) ComputeEffectiveCategoryVolumes(AudioSettings audio)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(audio);
|
||||||
|
float sfx = audio.SfxEnabled ? audio.Sfx : 0f;
|
||||||
|
float ambient = audio.AmbientEnabled ? audio.Ambient : 0f;
|
||||||
|
return (sfx, ambient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,10 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// against string table <c>0x23000003</c>, table-enum <c>0x10000003</c>
|
/// against string table <c>0x23000003</c>, table-enum <c>0x10000003</c>
|
||||||
/// matching <c>AddHeader</c>'s own convention), its tooltip key
|
/// matching <c>AddHeader</c>'s own convention), its tooltip key
|
||||||
/// (<c><label>_Help</c> — verified at every non-truncated call site,
|
/// (<c><label>_Help</c> — verified at every non-truncated call site,
|
||||||
/// applied uniformly), every slider's real-unit
|
/// applied uniformly to every toggle/trio/slider/menu row's own
|
||||||
|
/// interactive widget as of the OP6 rework round, review S3 — the
|
||||||
|
/// rejected slice had wired it onto toggle/trio rows only), every slider's
|
||||||
|
/// real-unit
|
||||||
/// <c>UIPreferences::SetPreferenceRange</c> (NOT the widget's [0,1] scalar
|
/// <c>UIPreferences::SetPreferenceRange</c> (NOT the widget's [0,1] scalar
|
||||||
/// space — <see cref="ToNormalized"/>/<see cref="FromNormalized"/> convert
|
/// space — <see cref="ToNormalized"/>/<see cref="FromNormalized"/> convert
|
||||||
/// at the build site), and every menu's <c>UIPreferences::SetEnumChoices</c>
|
/// at the build site), and every menu's <c>UIPreferences::SetEnumChoices</c>
|
||||||
|
|
@ -41,21 +44,32 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>U4 resolved: retail ships ZERO slider range captions on this tab.</b>
|
/// <b>U4 CORRECTED at the OP6 rework round (2026-08-11, review
|
||||||
/// Every one of the seven <c>PlayerOptionPage::SetSliderLabel</c> calls this
|
/// <c>docs/research/2026-08-11-op6-review.md</c> finding M1): retail DOES
|
||||||
/// tab makes passes literal <c>(0, 0)</c> for the low/high string ids
|
/// ship six slider range captions on this tab — the rejected slice's "zero
|
||||||
/// (<c>0x0049E4E0</c>/<c>0x0049E51D</c>/<c>0x0049E556</c>/<c>0x0049E614</c>/
|
/// captions" claim was a Binary Ninja constant-folding artifact, the SAME
|
||||||
/// <c>0x0049E65D</c>/<c>0x0049E68E</c> — six calls; the seventh slider,
|
/// class of bug the header-string globals a few lines above already had to
|
||||||
/// Mouse Look Sensitivity, gets NO <c>SetSliderLabel</c> call at all).
|
/// route around.</b> BN renders every
|
||||||
/// <c>SetSliderLabel</c>'s own body (<c>0x004F2B80</c>) has no <c>IsValid</c>
|
/// <c>PlayerOptionPage::SetSliderLabel(this, slot, 0, 0)</c> call with
|
||||||
/// guard before writing — unlike <c>AddHeader</c> — so <c>StringId=0</c>
|
/// literal zero operands, but the raw bytes at each call site
|
||||||
/// resolves to blank text either way. The <c>ID_Graphics_Value_*</c> globals
|
/// (<c>0x0049E4C6</c>/<c>0x0049E51D</c>/.../<c>0x0049E68E</c>, PE-byte-
|
||||||
/// (Dark/Bright, Speed/Detail, ...) the structure doc's §4 found ARE real
|
/// verified against <c>C:\Users\erikn\Downloads\acclient.exe</c>) are
|
||||||
/// strings elsewhere in the binary, but THIS tab's <c>InitOptions</c> never
|
/// <c>mov ecx, [ID_Graphics_Value_<High>]</c> /
|
||||||
/// wires them to any row — they are dead literals for this build. This
|
/// <c>mov edx, [ID_Graphics_Value_<Low>]</c> — reads of the SAME
|
||||||
/// controller therefore never touches the range-caption text children
|
/// runtime-filled string-id globals the structure doc's §4 already found,
|
||||||
/// (<c>0x1000021E</c>/<c>0x1000021F</c>) at all — omitting them is the
|
/// not immediates. All six labelled sliders pair as: Camera Stiffness
|
||||||
/// faithful port, not a shortcut.
|
/// (Soft/Hard), Camera Adjustment Speed (Slow/Fast), Field Of View
|
||||||
|
/// (Narrow/Wide), Screen Brightness (Dark/Bright), Graphics Performance
|
||||||
|
/// (Speed/Detail), Degrade Distance (Close/Far) — declaration order,
|
||||||
|
/// semantically obvious pairing, cross-checked against OP5's own PORTED
|
||||||
|
/// mechanism for the exact same BN artifact
|
||||||
|
/// (<c>ChatOptionsPageController.BuildOpacitySliders</c>'s
|
||||||
|
/// Transparent/Opaque pair, <c>0x0049FD37</c>). Mouse Look Sensitivity (the
|
||||||
|
/// SEVENTH slider, template idx3) genuinely gets NO <c>SetSliderLabel</c>
|
||||||
|
/// call — that ONE omission is real and stays un-captioned; every idx6 row
|
||||||
|
/// gets its low/high range-caption children (<c>0x1000021E</c>/
|
||||||
|
/// <c>0x1000021F</c>) populated via <see cref="BuildSliderRow"/>'s optional
|
||||||
|
/// <c>rangeLowKey</c>/<c>rangeHighKey</c> parameters.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -75,8 +89,9 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// <c>0x1000021C</c>, no separate name text — the checkbox's OWN label
|
/// <c>0x1000021C</c>, no separate name text — the checkbox's OWN label
|
||||||
/// carries the row); idx6 range-captioned slider row (Type 3,
|
/// carries the row); idx6 range-captioned slider row (Type 3,
|
||||||
/// <c>0x1000021D</c>, name text <c>0x1000021B</c> + slider
|
/// <c>0x1000021D</c>, name text <c>0x1000021B</c> + slider
|
||||||
/// <c>0x1000021C</c> + UNUSED range-caption texts <c>0x1000021E</c>/
|
/// <c>0x1000021C</c> + POPULATED range-caption texts <c>0x1000021E</c>/
|
||||||
/// <c>0x1000021F</c> — the six <c>arg3=1</c> sliders); idx7 a SECOND
|
/// <c>0x1000021F</c> (OP6 rework, review M1 — see class doc's U4 note) —
|
||||||
|
/// the six <c>arg3=1</c> sliders); idx7 a SECOND
|
||||||
/// toggle+slider trio template with range-caption children
|
/// toggle+slider trio template with range-caption children
|
||||||
/// (<c>0x10000221</c>) that retail's own <c>InitOptions</c> never
|
/// (<c>0x10000221</c>) that retail's own <c>InitOptions</c> never
|
||||||
/// invokes — authored but dead, matching Chat's own unused-index pattern.
|
/// invokes — authored but dead, matching Chat's own unused-index pattern.
|
||||||
|
|
@ -86,8 +101,9 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// <b>Consumer disposition (OP6 contract §4):</b> LIVE — the Sound/Ambient
|
/// <b>Consumer disposition (OP6 contract §4):</b> LIVE — the Sound/Ambient
|
||||||
/// volume-trio sliders (<c>AudioSettings.Sfx</c>/<c>Ambient</c>, already
|
/// volume-trio sliders (<c>AudioSettings.Sfx</c>/<c>Ambient</c>, already
|
||||||
/// live via <c>ApplyAudio</c>) and their toggle halves
|
/// live via <c>ApplyAudio</c>) and their toggle halves
|
||||||
/// (<see cref="AudioSettings.SfxDisabled"/>/<see cref="AudioSettings.AmbientDisabled"/>,
|
/// (<see cref="AudioSettings.SfxEnabled"/>/<see cref="AudioSettings.AmbientEnabled"/>
|
||||||
/// gating the SAME engine write); Resolution/Full Screen
|
/// — OP6 rework, review M2: retail's own ENABLED-sense fields, not
|
||||||
|
/// "Disabled" — gating the SAME engine write); Resolution/Full Screen
|
||||||
/// (<c>DisplaySettings.Resolution</c>/<c>Fullscreen</c>, immediately live —
|
/// (<c>DisplaySettings.Resolution</c>/<c>Fullscreen</c>, immediately live —
|
||||||
/// resizes the window on Save). NEXT-LAUNCH (existing precedent, not a new
|
/// resizes the window on Save). NEXT-LAUNCH (existing precedent, not a new
|
||||||
/// gap — neither the pre-existing dev-tools Settings panel nor this
|
/// gap — neither the pre-existing dev-tools Settings panel nor this
|
||||||
|
|
@ -95,17 +111,24 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// Refresh, Field of View. STORE-ONLY (register rows, cited per group below):
|
/// Refresh, Field of View. STORE-ONLY (register rows, cited per group below):
|
||||||
/// Sound Features menu, Interface Sound trio (AP-174 — retail's own
|
/// Sound Features menu, Interface Sound trio (AP-174 — retail's own
|
||||||
/// registered-and-never-read knob), Play Sound Only When Active; Screen
|
/// registered-and-never-read knob), Play Sound Only When Active; Screen
|
||||||
/// Brightness (reuses the existing inert <c>DisplaySettings.Gamma</c> — no
|
/// Brightness (OP6 rework, review S2: its OWN <c>DisplaySettings.ScreenBrightness</c>
|
||||||
/// gamma-correction pass exists); Automatic Degrades/Graphics
|
/// field, range [-1,1] default 0 — NOT the pre-existing <c>Gamma</c>
|
||||||
/// Performance/Degrade Distance/the four texture-detail-family
|
/// multiplier, a different unit system with its own live legacy-panel
|
||||||
/// menus/Building Detail Textures/Multi-Pass Alpha (the renderer is Vulkan +
|
/// consumer; no gamma-correction render pass exists for either); Automatic
|
||||||
/// one aggregate <c>QualityPreset</c>, no per-feature knobs); Camera
|
/// Degrades/Graphics Performance/Degrade Distance/the four texture-detail-
|
||||||
/// Stiffness/Adjustment Speed/Align To Slope/Mouse Look
|
/// 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
|
/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no
|
||||||
/// persistent mouse-turning camera mode exists for ANY of these six to
|
/// persistent mouse-turning camera mode exists for ANY of these six to
|
||||||
/// drive, already registered before this slice); Chat Font Face/Size
|
/// drive, already registered before this slice); Chat Font Face/Size
|
||||||
/// (distinct NEW fields from the existing live <c>ChatSettings.FontSize</c>
|
/// (distinct NEW fields from the existing live <c>ChatSettings.FontSize</c>
|
||||||
/// — no verified index-to-point/face mapping).
|
/// — no verified index-to-point/face mapping; OP6 rework, review M3: Chat
|
||||||
|
/// Font Face ships all FIVE of retail's authored choices — Arial,
|
||||||
|
/// CourierNew, PalatinoLinotype, Tahoma, TimesNewRoman, a fixed
|
||||||
|
/// compile-time array at <c>gmClient::InitUIPreferences @0x00403885</c>-
|
||||||
|
/// <c>0x004039ed</c>, NOT a per-machine runtime enumeration as the
|
||||||
|
/// rejected slice's comment claimed).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ConfigOptionsPageController
|
public static class ConfigOptionsPageController
|
||||||
|
|
@ -157,6 +180,13 @@ public static class ConfigOptionsPageController
|
||||||
/// same id CharacterOptions/ChatOptions controllers already cite.</summary>
|
/// same id CharacterOptions/ChatOptions controllers already cite.</summary>
|
||||||
private const uint SliderElementId = 0x1000021Cu;
|
private const uint SliderElementId = 0x1000021Cu;
|
||||||
|
|
||||||
|
/// <summary>idx6 range-captioned slider template's low/high range-caption
|
||||||
|
/// children (OP6 rework, review M1) — the SAME ids
|
||||||
|
/// <see cref="ChatOptionsPageController"/> already cites for the exact
|
||||||
|
/// same BN artifact on the Chat tab's own labelled slider.</summary>
|
||||||
|
private const uint SliderRangeMinElementId = 0x1000021Eu;
|
||||||
|
private const uint SliderRangeMaxElementId = 0x1000021Fu;
|
||||||
|
|
||||||
/// <summary>Menu row template's (idx4) name-label text child.</summary>
|
/// <summary>Menu row template's (idx4) name-label text child.</summary>
|
||||||
private const uint MenuLabelElementId = 0x10000223u;
|
private const uint MenuLabelElementId = 0x10000223u;
|
||||||
|
|
||||||
|
|
@ -212,24 +242,30 @@ public static class ConfigOptionsPageController
|
||||||
|
|
||||||
listBox.TemplateResolver = templateResolver;
|
listBox.TemplateResolver = templateResolver;
|
||||||
|
|
||||||
// ScrollbarElementId (0x10000201) is SHARED with the Chat tab
|
// OP6 rework (2026-08-11, review N2): read the scrollbar id the DAT
|
||||||
// (0x1000050D's own scrollbar lookup cites the same hazard) —
|
// itself authors (UiTemplateListBox.ScrollbarElementId, dat property
|
||||||
// ImportedLayout.FindElement is a flat id->widget dictionary
|
// 0x72) rather than the hardcoded ScrollbarElementId constant —
|
||||||
// (last-build-wins on a collision), so a plain lookup here could
|
// feedback_prefer_dat_field_over_geometry. The constant above still
|
||||||
// silently wire THIS scroll model onto the Chat tab's own
|
// documents retail's known value (0x10000201, SHARED with the Chat
|
||||||
// scrollbar instance. Scope the search to the Config page's own
|
// tab — 0x1000050D's own scrollbar lookup cites the same hazard) for
|
||||||
// subtree instead. Scoped from PageSlotElementId, NOT RootElementId
|
// callers that need it without a built listBox in hand, but Bind
|
||||||
// — see that field's own doc for why the standalone layout's root
|
// itself now trusts the authored field. ImportedLayout.FindElement is
|
||||||
// id does not survive base-merge here.
|
// 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? configPageSlot = layout.FindElement(PageSlotElementId);
|
||||||
UiElement? scrollbarElement = configPageSlot is null
|
UiElement? scrollbarElement = configPageSlot is null || scrollbarElementId == 0
|
||||||
? null
|
? null
|
||||||
: UiElement.FindDescendant(configPageSlot, ScrollbarElementId);
|
: UiElement.FindDescendant(configPageSlot, scrollbarElementId);
|
||||||
if (scrollbarElement is UiScrollbar scrollbar)
|
if (scrollbarElement is UiScrollbar scrollbar)
|
||||||
scrollbar.Model = listBox.Scroll;
|
scrollbar.Model = listBox.Scroll;
|
||||||
else
|
else
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"[D.2b] ConfigOptionsPageController: scrollbar 0x{ScrollbarElementId:X8} "
|
$"[D.2b] ConfigOptionsPageController: scrollbar 0x{scrollbarElementId:X8} "
|
||||||
+ $"not found under Config page slot 0x{PageSlotElementId:X8} — the Config "
|
+ $"not found under Config page slot 0x{PageSlotElementId:X8} — the Config "
|
||||||
+ "tab's row list will not scroll.");
|
+ "tab's row list will not scroll.");
|
||||||
|
|
||||||
|
|
@ -249,6 +285,13 @@ public static class ConfigOptionsPageController
|
||||||
BindInputSection(listBox, page, resolveString, bindings, ref cameraTurning);
|
BindInputSection(listBox, page, resolveString, bindings, ref cameraTurning);
|
||||||
BuildSeparatorRow(listBox);
|
BuildSeparatorRow(listBox);
|
||||||
BindUiSection(listBox, page, resolveString, bindings, ref chat);
|
BindUiSection(listBox, page, resolveString, bindings, ref chat);
|
||||||
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -276,21 +319,35 @@ public static class ConfigOptionsPageController
|
||||||
},
|
},
|
||||||
defaultValue: 0);
|
defaultValue: 0);
|
||||||
|
|
||||||
|
// 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(
|
BuildTrioRow(
|
||||||
listBox, "ID_Sound_DisableSound", toggleDefault: true,
|
listBox, "ID_Sound_DisableSound", sliderTooltipKey: "ID_Sound_EffectVolume",
|
||||||
|
toggleDefault: true,
|
||||||
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
||||||
page, resolveString,
|
page, resolveString,
|
||||||
toggleRead: () => bindings.LoadAudio().SfxDisabled,
|
toggleRead: () => bindings.LoadAudio().SfxEnabled,
|
||||||
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { SfxDisabled = value }),
|
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { SfxEnabled = value }),
|
||||||
sliderRead: () => bindings.LoadAudio().Sfx,
|
sliderRead: () => bindings.LoadAudio().Sfx,
|
||||||
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Sfx = value }));
|
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Sfx = value }));
|
||||||
|
|
||||||
BuildTrioRow(
|
BuildTrioRow(
|
||||||
listBox, "ID_Sound_DisableAmbientSound", toggleDefault: true,
|
listBox, "ID_Sound_DisableAmbientSound", sliderTooltipKey: "ID_Sound_AmbientVolume",
|
||||||
|
toggleDefault: true,
|
||||||
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
||||||
page, resolveString,
|
page, resolveString,
|
||||||
toggleRead: () => bindings.LoadAudio().AmbientDisabled,
|
toggleRead: () => bindings.LoadAudio().AmbientEnabled,
|
||||||
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { AmbientDisabled = value }),
|
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { AmbientEnabled = value }),
|
||||||
sliderRead: () => bindings.LoadAudio().Ambient,
|
sliderRead: () => bindings.LoadAudio().Ambient,
|
||||||
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Ambient = value }));
|
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Ambient = value }));
|
||||||
|
|
||||||
|
|
@ -299,11 +356,12 @@ public static class ConfigOptionsPageController
|
||||||
// read). Store-only, same shape as every other row here — this
|
// read). Store-only, same shape as every other row here — this
|
||||||
// trio simply has no live consumer to gate.
|
// trio simply has no live consumer to gate.
|
||||||
BuildTrioRow(
|
BuildTrioRow(
|
||||||
listBox, "ID_Sound_DisableInterfaceSound", toggleDefault: true,
|
listBox, "ID_Sound_DisableInterfaceSound", sliderTooltipKey: "ID_Sound_InterfaceVolume",
|
||||||
|
toggleDefault: true,
|
||||||
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f,
|
||||||
page, resolveString,
|
page, resolveString,
|
||||||
toggleRead: () => bindings.LoadAudio().InterfaceDisabled,
|
toggleRead: () => bindings.LoadAudio().InterfaceEnabled,
|
||||||
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceDisabled = value }),
|
toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceEnabled = value }),
|
||||||
sliderRead: () => bindings.LoadAudio().InterfaceVolume,
|
sliderRead: () => bindings.LoadAudio().InterfaceVolume,
|
||||||
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceVolume = value }));
|
sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceVolume = value }));
|
||||||
|
|
||||||
|
|
@ -328,17 +386,21 @@ public static class ConfigOptionsPageController
|
||||||
|
|
||||||
// Camera Stiffness / Adjustment Speed / Align To Slope: TS-74 —
|
// Camera Stiffness / Adjustment Speed / Align To Slope: TS-74 —
|
||||||
// store-only, no persistent mouse-turning camera mode exists.
|
// 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(
|
BuildSliderRow(
|
||||||
listBox, RangedSliderTemplateIndex, "ID_Camera_Stiffness",
|
listBox, RangedSliderTemplateIndex, "ID_Camera_Stiffness",
|
||||||
min: 0.285714298f, max: 1f, defaultValue: 0.45f, page, resolveString,
|
min: 0.285714298f, max: 1f, defaultValue: 0.45f, page, resolveString,
|
||||||
read: () => bindings.LoadCameraTurning().Stiffness,
|
read: () => bindings.LoadCameraTurning().Stiffness,
|
||||||
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { Stiffness = value }));
|
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { Stiffness = value }),
|
||||||
|
rangeLowKey: "ID_Graphics_Value_Soft", rangeHighKey: "ID_Graphics_Value_Hard");
|
||||||
|
|
||||||
BuildSliderRow(
|
BuildSliderRow(
|
||||||
listBox, RangedSliderTemplateIndex, "ID_Camera_AdjustmentSpeed",
|
listBox, RangedSliderTemplateIndex, "ID_Camera_AdjustmentSpeed",
|
||||||
min: 5f, max: 80f, defaultValue: 40.0f, page, resolveString,
|
min: 5f, max: 80f, defaultValue: 40.0f, page, resolveString,
|
||||||
read: () => bindings.LoadCameraTurning().AdjustmentSpeed,
|
read: () => bindings.LoadCameraTurning().AdjustmentSpeed,
|
||||||
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AdjustmentSpeed = value }));
|
apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AdjustmentSpeed = value }),
|
||||||
|
rangeLowKey: "ID_Graphics_Value_Slow", rangeHighKey: "ID_Graphics_Value_Fast");
|
||||||
|
|
||||||
// Field of View: NEXT-LAUNCH via DisplaySettings.FieldOfView + the
|
// Field of View: NEXT-LAUNCH via DisplaySettings.FieldOfView + the
|
||||||
// existing RuntimeSettingsController.ApplyStartup path — matches
|
// existing RuntimeSettingsController.ApplyStartup path — matches
|
||||||
|
|
@ -348,7 +410,8 @@ public static class ConfigOptionsPageController
|
||||||
listBox, RangedSliderTemplateIndex, "ID_Graphics_FieldOfView",
|
listBox, RangedSliderTemplateIndex, "ID_Graphics_FieldOfView",
|
||||||
min: 10f, max: 160f, defaultValue: 90.0f, page, resolveString,
|
min: 10f, max: 160f, defaultValue: 90.0f, page, resolveString,
|
||||||
read: () => bindings.LoadDisplay().FieldOfView,
|
read: () => bindings.LoadDisplay().FieldOfView,
|
||||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { FieldOfView = value }));
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { FieldOfView = value }),
|
||||||
|
rangeLowKey: "ID_Graphics_Value_Narrow", rangeHighKey: "ID_Graphics_Value_Wide");
|
||||||
|
|
||||||
BuildToggleRow(
|
BuildToggleRow(
|
||||||
listBox, "ID_Camera_AlignToSlope", defaultValue: true, page, resolveString,
|
listBox, "ID_Camera_AlignToSlope", defaultValue: true, page, resolveString,
|
||||||
|
|
@ -379,15 +442,14 @@ public static class ConfigOptionsPageController
|
||||||
// generic path, matching that divergence honestly). SetConfirmChange
|
// generic path, matching that divergence honestly). SetConfirmChange
|
||||||
// ships when a resolution-change confirmation flow exists — not
|
// ships when a resolution-change confirmation flow exists — not
|
||||||
// this slice (plan §4 OP6). Retail's literal default ("800x600",
|
// this slice (plan §4 OP6). Retail's literal default ("800x600",
|
||||||
// preserved below as the value Defaults restores) is NOT one of
|
// preserved below as the value Defaults restores, byte-verified
|
||||||
// DisplaySettings.AvailableResolutions' modern presets — the same
|
// SetDefaultValue(0x03200258) @0x0049e5ac) IS now a selectable
|
||||||
// "opaque default the menu may not highlight" shape as
|
// DisplaySettings.AvailableResolutions entry (OP6 rework, review
|
||||||
// LandscapeDrawDistance (register row AP-198's own sub-note), NOT
|
// S4 — it is a genuine retail display mode,
|
||||||
// a functional gap: TryParseResolution accepts any "WxH" string,
|
// Device::ForceDisplayResolution(1, 0x320, 0x258)
|
||||||
// so clicking Defaults still resizes the window correctly to
|
// @gmClient::Init 0x004047af, not an invented preset), so clicking
|
||||||
// 800x600, it just may not show a highlighted dropdown row. A
|
// Defaults both resizes the window AND leaves the dropdown showing
|
||||||
// FRESH profile's own current value (DisplaySettings.Default =
|
// a highlighted, re-selectable row.
|
||||||
// "1280x720") IS in the preset list and highlights normally.
|
|
||||||
BuildStringMenuRow(
|
BuildStringMenuRow(
|
||||||
listBox, "ID_Rendering_DisplayResolution",
|
listBox, "ID_Rendering_DisplayResolution",
|
||||||
DisplaySettings.AvailableResolutions, page, resolveString,
|
DisplaySettings.AvailableResolutions, page, resolveString,
|
||||||
|
|
@ -407,13 +469,17 @@ public static class ConfigOptionsPageController
|
||||||
read: () => bindings.LoadDisplay().VSync,
|
read: () => bindings.LoadDisplay().VSync,
|
||||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { VSync = value }));
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { VSync = value }));
|
||||||
|
|
||||||
// Screen Brightness: reuses the existing DisplaySettings.Gamma
|
// Screen Brightness: OP6 rework (2026-08-11, review S2) — its OWN
|
||||||
// field (no gamma-correction render pass exists — inert, store-only).
|
// 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(
|
BuildSliderRow(
|
||||||
listBox, RangedSliderTemplateIndex, "ID_Graphics_ScreenBrightness",
|
listBox, RangedSliderTemplateIndex, "ID_Graphics_ScreenBrightness",
|
||||||
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
|
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
|
||||||
read: () => bindings.LoadDisplay().Gamma,
|
read: () => bindings.LoadDisplay().ScreenBrightness,
|
||||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Gamma = value }));
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { ScreenBrightness = value }),
|
||||||
|
rangeLowKey: "ID_Graphics_Value_Dark", rangeHighKey: "ID_Graphics_Value_Bright");
|
||||||
|
|
||||||
BuildToggleRow(
|
BuildToggleRow(
|
||||||
listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString,
|
listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString,
|
||||||
|
|
@ -424,13 +490,15 @@ public static class ConfigOptionsPageController
|
||||||
listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias",
|
listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias",
|
||||||
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
|
min: -1f, max: 1f, defaultValue: 0f, page, resolveString,
|
||||||
read: () => bindings.LoadDisplay().GraphicsPerformance,
|
read: () => bindings.LoadDisplay().GraphicsPerformance,
|
||||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }));
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }),
|
||||||
|
rangeLowKey: "ID_Graphics_Value_Speed", rangeHighKey: "ID_Graphics_Value_Detail");
|
||||||
|
|
||||||
BuildSliderRow(
|
BuildSliderRow(
|
||||||
listBox, RangedSliderTemplateIndex, "ID_Graphics_DegradeDistance",
|
listBox, RangedSliderTemplateIndex, "ID_Graphics_DegradeDistance",
|
||||||
min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString,
|
min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString,
|
||||||
read: () => bindings.LoadDisplay().DegradeDistance,
|
read: () => bindings.LoadDisplay().DegradeDistance,
|
||||||
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }));
|
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }),
|
||||||
|
rangeLowKey: "ID_Graphics_Value_Close", rangeHighKey: "ID_Graphics_Value_Far");
|
||||||
|
|
||||||
display = bindings.LoadDisplay();
|
display = bindings.LoadDisplay();
|
||||||
}
|
}
|
||||||
|
|
@ -548,9 +616,27 @@ public static class ConfigOptionsPageController
|
||||||
|
|
||||||
// ── Section 6: UI Options ───────────────────────────────────────────
|
// ── Section 6: UI Options ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OP6 rework (2026-08-11, review M3): all FIVE of retail's authored
|
||||||
|
/// choices, not the one the rejected slice shipped. The rejected
|
||||||
|
/// slice's "per-machine runtime enumeration" justification was
|
||||||
|
/// contradicted by the decompile it cited:
|
||||||
|
/// <c>gmClient::InitUIPreferences</c> builds a FIXED five-entry array
|
||||||
|
/// (<c>0x00403885</c>-<c>0x004039ed</c>) — Arial, CourierNew,
|
||||||
|
/// PalatinoLinotype, Tahoma, TimesNewRoman, in that order, then ONE
|
||||||
|
/// <c>SetEnumChoices</c> call — the exact same
|
||||||
|
/// <c>SmartArray<unsigned long,1>::grow</c> push idiom
|
||||||
|
/// <see cref="ChatFontSizeChoices"/> already uses for its five entries;
|
||||||
|
/// there is no unbounded/per-machine loop. All five strings are present
|
||||||
|
/// verbatim in the binary's <c>.rdata</c> (PE-byte-verified against
|
||||||
|
/// <c>C:\Users\erikn\Downloads\acclient.exe</c>). Default index 2
|
||||||
|
/// (PalatinoLinotype, <c>SetDefaultValue(2) @0x0049e7de</c>) now indexes
|
||||||
|
/// a real entry instead of falling past a 1-item array.
|
||||||
|
/// </summary>
|
||||||
private static readonly string[] ChatFontFaceChoices =
|
private static readonly string[] ChatFontFaceChoices =
|
||||||
{
|
{
|
||||||
"ID_UI_Value_Arial",
|
"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 =
|
private static readonly string[] ChatFontSizeChoices =
|
||||||
|
|
@ -570,12 +656,8 @@ public static class ConfigOptionsPageController
|
||||||
|
|
||||||
// Chat Font Face/Size: store-only, distinct from the existing live
|
// Chat Font Face/Size: store-only, distinct from the existing live
|
||||||
// ChatSettings.FontSize (see that field's own doc for why). The
|
// ChatSettings.FontSize (see that field's own doc for why). The
|
||||||
// face-choice array is authored with only ONE literal decoded so
|
// face-choice array ships all five retail-authored choices — see
|
||||||
// far ("Arial") — the remaining installed system fonts are a
|
// ChatFontFaceChoices' own doc (OP6 rework, review M3).
|
||||||
// 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(
|
BuildMenuRow(
|
||||||
listBox, "ID_UI_ChatFontFace", ChatFontFaceChoices, page, resolveString,
|
listBox, "ID_UI_ChatFontFace", ChatFontFaceChoices, page, resolveString,
|
||||||
read: () => bindings.LoadChat().ChatFontFace,
|
read: () => bindings.LoadChat().ChatFontFace,
|
||||||
|
|
@ -673,11 +755,14 @@ public static class ConfigOptionsPageController
|
||||||
/// <summary>Builds a slider row from EITHER <see cref="SimpleSliderTemplateIndex"/>
|
/// <summary>Builds a slider row from EITHER <see cref="SimpleSliderTemplateIndex"/>
|
||||||
/// (idx3, Mouse Look Sensitivity only) or <see cref="RangedSliderTemplateIndex"/>
|
/// (idx3, Mouse Look Sensitivity only) or <see cref="RangedSliderTemplateIndex"/>
|
||||||
/// (idx6, the other six) — structurally identical leaves for this
|
/// (idx6, the other six) — structurally identical leaves for this
|
||||||
/// controller's purposes (name text + slider; idx6's extra range-caption
|
/// controller's purposes (name text + slider). <paramref name="rangeLowKey"/>/
|
||||||
/// children are deliberately left untouched — see class doc's U4 note).
|
/// <paramref name="rangeHighKey"/> (OP6 rework, review M1) populate idx6's
|
||||||
/// Converts between the row's REAL-unit current/default (what
|
/// extra range-caption children when supplied — left null (the default)
|
||||||
/// <paramref name="read"/>/<paramref name="apply"/> traffic in — the
|
/// for Mouse Look Sensitivity, retail's own genuine no-caption exception
|
||||||
/// settings-store unit) and the widget's normalized [0,1] scalar space.</summary>
|
/// (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(
|
private static void BuildSliderRow(
|
||||||
UiTemplateListBox listBox,
|
UiTemplateListBox listBox,
|
||||||
int templateIndex,
|
int templateIndex,
|
||||||
|
|
@ -688,7 +773,9 @@ public static class ConfigOptionsPageController
|
||||||
OptionPage page,
|
OptionPage page,
|
||||||
Func<uint, uint, string?> resolveString,
|
Func<uint, uint, string?> resolveString,
|
||||||
Func<float> read,
|
Func<float> read,
|
||||||
Action<float> apply)
|
Action<float> apply,
|
||||||
|
string? rangeLowKey = null,
|
||||||
|
string? rangeHighKey = null)
|
||||||
{
|
{
|
||||||
UiElement? row = listBox.AddItemFromTemplateList(templateIndex);
|
UiElement? row = listBox.AddItemFromTemplateList(templateIndex);
|
||||||
if (row is null)
|
if (row is null)
|
||||||
|
|
@ -702,6 +789,11 @@ public static class ConfigOptionsPageController
|
||||||
if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label)
|
if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label)
|
||||||
SetLabelText(label, labelKey, resolveString);
|
SetLabelText(label, labelKey, resolveString);
|
||||||
|
|
||||||
|
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)
|
if (UiElement.FindDescendant(row, SliderElementId) is not UiScrollbar slider)
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
|
|
@ -710,6 +802,12 @@ public static class ConfigOptionsPageController
|
||||||
return;
|
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();
|
float initial = read();
|
||||||
slider.SetScalarPosition(ToNormalized(initial, min, max));
|
slider.SetScalarPosition(ToNormalized(initial, min, max));
|
||||||
|
|
||||||
|
|
@ -728,14 +826,41 @@ public static class ConfigOptionsPageController
|
||||||
slider.ScalarChanged = normalized => row_.SetCurrentValue(FromNormalized(normalized, min, max));
|
slider.ScalarChanged = normalized => row_.SetCurrentValue(FromNormalized(normalized, min, max));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Populates one range-caption text child (idx6 template only —
|
||||||
|
/// <see cref="SliderRangeMinElementId"/>/<see cref="SliderRangeMaxElementId"/>)
|
||||||
|
/// — the same shape <see cref="ChatOptionsPageController.SetRangeLabel"/>
|
||||||
|
/// already ports for the identical BN artifact on the Chat tab's own
|
||||||
|
/// labelled slider (OP6 rework, review M1).</summary>
|
||||||
|
private static void SetRangeLabel(
|
||||||
|
UiElement row, uint elementId, string labelKey, Func<uint, uint, string?> resolveString)
|
||||||
|
{
|
||||||
|
if (UiElement.FindDescendant(row, elementId) is not UiText text)
|
||||||
|
return;
|
||||||
|
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
|
||||||
|
if (label is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[D.2b] ConfigOptionsPageController: range label '{labelKey}' did not "
|
||||||
|
+ "resolve — rendered with no text rather than invented English.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
text.LinesProvider = () => new[] { new UiText.Line(label, text.DefaultColor) };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Builds the toggle+slider trio (idx5) — the checkbox half
|
/// <summary>Builds the toggle+slider trio (idx5) — the checkbox half
|
||||||
/// carries the ONLY row label (retail's own row shape has no separate
|
/// 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
|
/// 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
|
/// campaign (every trio here is a volume knob), so no unit conversion
|
||||||
/// is needed for the slider half.</summary>
|
/// is needed for the slider half. <paramref name="sliderTooltipKey"/>
|
||||||
|
/// (OP6 rework, review S3) is the retail preference key for the SLIDER
|
||||||
|
/// half's OWN <c>AttachPreference</c> registration (e.g.
|
||||||
|
/// <c>Sound_SoundVolume</c>'s <c>ID_Sound_EffectVolume</c>) — used ONLY
|
||||||
|
/// to resolve its "_Help" tooltip, never as visible label text (the row
|
||||||
|
/// has none for the slider half).</summary>
|
||||||
private static void BuildTrioRow(
|
private static void BuildTrioRow(
|
||||||
UiTemplateListBox listBox,
|
UiTemplateListBox listBox,
|
||||||
string toggleLabelKey,
|
string toggleLabelKey,
|
||||||
|
string sliderTooltipKey,
|
||||||
bool toggleDefault,
|
bool toggleDefault,
|
||||||
float sliderMin,
|
float sliderMin,
|
||||||
float sliderMax,
|
float sliderMax,
|
||||||
|
|
@ -783,6 +908,10 @@ public static class ConfigOptionsPageController
|
||||||
page.Register(toggleRow);
|
page.Register(toggleRow);
|
||||||
checkbox.OnClick = () => toggleRow.SetCurrentValue(checkbox.Selected);
|
checkbox.OnClick = () => toggleRow.SetCurrentValue(checkbox.Selected);
|
||||||
|
|
||||||
|
string? sliderTooltip = ResolveTooltip(sliderTooltipKey, resolveString);
|
||||||
|
if (sliderTooltip is not null)
|
||||||
|
slider.TooltipText = sliderTooltip;
|
||||||
|
|
||||||
float sliderInitial = sliderRead();
|
float sliderInitial = sliderRead();
|
||||||
slider.SetScalarPosition(ToNormalized(sliderInitial, sliderMin, sliderMax));
|
slider.SetScalarPosition(ToNormalized(sliderInitial, sliderMin, sliderMax));
|
||||||
var sliderRow = new FloatOptionRow(
|
var sliderRow = new FloatOptionRow(
|
||||||
|
|
@ -833,6 +962,12 @@ public static class ConfigOptionsPageController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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];
|
string[] choiceLabels = new string[choiceKeys.Length];
|
||||||
var items = new UiMenu.MenuItem[choiceKeys.Length];
|
var items = new UiMenu.MenuItem[choiceKeys.Length];
|
||||||
for (int i = 0; i < choiceKeys.Length; i++)
|
for (int i = 0; i < choiceKeys.Length; i++)
|
||||||
|
|
@ -909,6 +1044,12 @@ public static class ConfigOptionsPageController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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];
|
var items = new UiMenu.MenuItem[choices.Count];
|
||||||
for (int i = 0; i < choices.Count; i++)
|
for (int i = 0; i < choices.Count; i++)
|
||||||
items[i] = new UiMenu.MenuItem(choices[i], choices[i]);
|
items[i] = new UiMenu.MenuItem(choices[i], choices[i]);
|
||||||
|
|
@ -948,12 +1089,23 @@ public static class ConfigOptionsPageController
|
||||||
$"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — "
|
$"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — "
|
||||||
+ "row renders with no caption rather than invented English.");
|
+ "row renders with no caption rather than invented English.");
|
||||||
|
|
||||||
string? tooltip = resolveString(
|
string? tooltip = ResolveTooltip(labelKey, resolveString);
|
||||||
StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
|
|
||||||
if (tooltip is not null)
|
if (tooltip is not null)
|
||||||
checkbox.TooltipText = tooltip;
|
checkbox.TooltipText = tooltip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves one <c><key>_Help</c> tooltip string — the SAME suffix
|
||||||
|
/// convention <see cref="ApplyLabelAndTooltip"/> already used for toggle
|
||||||
|
/// rows, byte-verified as universal across all 27+ Config-tab
|
||||||
|
/// <c>AttachPreference</c> sites (OP6 rework, review S3): every row's
|
||||||
|
/// tooltip key is its own label key with <c>_Help</c> appended, no
|
||||||
|
/// exceptions found. Missing silently (no invented English), matching
|
||||||
|
/// every other string lookup on this page.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ResolveTooltip(string labelKey, Func<uint, uint, string?> resolveString)
|
||||||
|
=> resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
|
||||||
|
|
||||||
private static void SetLabelText(UiText label, string labelKey, Func<uint, uint, string?> resolveString)
|
private static void SetLabelText(UiText label, string labelKey, Func<uint, uint, string?> resolveString)
|
||||||
{
|
{
|
||||||
string? text = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
|
string? text = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,20 @@ public sealed class UiMenu : UiElement
|
||||||
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
|
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
|
||||||
public Func<string>? ButtonLabelProvider { get; set; }
|
public Func<string>? ButtonLabelProvider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Settable tooltip, surfaced through the shared
|
||||||
|
/// <see cref="UiElement.GetTooltipText"/> hover pipeline — the SAME
|
||||||
|
/// pattern <see cref="UiButton.TooltipText"/> already established
|
||||||
|
/// (OP6 rework, review S3). Lets a menu-row controller (e.g. the
|
||||||
|
/// Config tab's Sound Features / texture-detail menus) attach retail's
|
||||||
|
/// own <c>_Help</c> string to the closed dropdown button itself, since
|
||||||
|
/// it — not the sibling label text — is the interactive/hoverable
|
||||||
|
/// surface for the row.</summary>
|
||||||
|
public string? TooltipText { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string? GetTooltipText() =>
|
||||||
|
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
|
||||||
|
|
||||||
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);
|
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);
|
||||||
// ALSO the visible-row window height when Scrollable
|
// ALSO the visible-row window height when Scrollable
|
||||||
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
|
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,19 @@ public sealed class UiScrollbar : UiElement
|
||||||
public void SetScalarPosition(float position)
|
public void SetScalarPosition(float position)
|
||||||
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
|
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
|
||||||
|
|
||||||
|
/// <summary>Settable tooltip, surfaced through the shared
|
||||||
|
/// <see cref="UiElement.GetTooltipText"/> hover pipeline — the SAME
|
||||||
|
/// pattern <see cref="UiButton.TooltipText"/> already established
|
||||||
|
/// (OP6 rework, review S3). Lets a slider-row controller (e.g. the
|
||||||
|
/// Config tab's real-unit sliders) attach retail's own <c>_Help</c>
|
||||||
|
/// string to the scalar widget itself, since it — not the sibling
|
||||||
|
/// label text — is the interactive/hoverable surface for the row.</summary>
|
||||||
|
public string? TooltipText { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string? GetTooltipText() =>
|
||||||
|
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
|
||||||
|
|
||||||
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
|
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
|
||||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,22 +20,56 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Campaign OP slice OP6 (2026-08-11): the six trailing fields are the
|
/// Campaign OP slice OP6 (2026-08-11), CORRECTED at the OP6 rework round
|
||||||
/// remaining Config-tab "Sound Options" rows, byte-verified from
|
/// (2026-08-11, review <c>docs/research/2026-08-11-op6-review.md</c> finding
|
||||||
/// <c>gmClient::InitUIPreferences @0x004035b0</c>
|
/// M2): the six trailing fields are the remaining Config-tab "Sound Options"
|
||||||
/// (<c>UIPreferences::AttachPreference</c> calls). <see cref="SfxDisabled"/>/
|
/// rows, byte-verified from <c>gmClient::InitUIPreferences @0x004035b0</c>
|
||||||
/// <see cref="AmbientDisabled"/> gate the EXISTING live <see cref="Sfx"/>/
|
/// (<c>UIPreferences::AttachPreference</c> calls) AND
|
||||||
|
/// <c>SoundManager::InitPrefs @0x005503F0</c>. <see cref="SfxEnabled"/>/
|
||||||
|
/// <see cref="AmbientEnabled"/> gate the EXISTING live <see cref="Sfx"/>/
|
||||||
/// <see cref="Ambient"/> knobs (<c>RuntimeSettingsStartupTargets.ApplyAudio</c>
|
/// <see cref="Ambient"/> knobs (<c>RuntimeSettingsStartupTargets.ApplyAudio</c>
|
||||||
/// sends 0 to the engine when disabled, else the slider value — retail's
|
/// sends 0 to the engine when NOT enabled, else the slider value).
|
||||||
/// own <c>Sound_SoundDisabled</c>/<c>Sound_AmbientSoundDisabled</c> toggle
|
/// </para>
|
||||||
/// halves of the SAME <c>UIOption_CheckboxSlider</c> row). The other four
|
///
|
||||||
/// fields are honest store-only round-trips (register row, OP6): retail
|
/// <para>
|
||||||
/// registers <c>Sound_InterfaceSoundVolume</c>/<c>Sound_InterfaceSoundDisabled</c>
|
/// <b>Why "Enabled", not "Disabled" (the rejected OP6 slice's original
|
||||||
/// and then never reads them either — AP-174 already documents "interface
|
/// naming).</b> The Config tab's checkbox LABEL reads "Disable Sound
|
||||||
/// sounds are scaled by the EFFECT knob" — so acdream matches retail's own
|
/// Effects"/"Disable Ambient Sound"/"Disable Interface Sound"
|
||||||
/// dead-knob behaviour rather than building a working one; <see cref="SoundFeatures"/>
|
/// (<c>ID_Sound_DisableSound</c> etc.), which reads as if checking it
|
||||||
/// (Stereo/Mono) and <see cref="PlaySoundOnlyWhenActive"/> have no acdream
|
/// disables sound. Byte evidence says otherwise:
|
||||||
/// mixer-channel-count or window-focus-mute consumer.
|
/// <c>UserPreferences::RegisterPreference(&SoundManager::effect_sounds_enabled,
|
||||||
|
/// &Sound_SoundDisabled, ...) @0x0055053d</c> (and the Ambient/Interface
|
||||||
|
/// siblings at <c>:346734</c>/<c>:346741</c>) bind the checkbox's boolean
|
||||||
|
/// value DIRECTLY — un-inverted — onto retail's own ENABLED-sense backing
|
||||||
|
/// statics (<c>SoundManager::effect_sounds_enabled</c>/
|
||||||
|
/// <c>ambient_sounds_enabled</c>/<c>interface_sounds_enabled</c>, each
|
||||||
|
/// compiled <c>= 0x1</c> at <c>:1102289</c>/<c>:1102294</c>/<c>:1102299</c>,
|
||||||
|
/// PE-byte-verified against <c>C:\Users\erikn\Downloads\acclient.exe</c>),
|
||||||
|
/// and every consumer read is enabled-sense too (e.g.
|
||||||
|
/// <c>if (SoundManager::effect_sounds_enabled != 0 && ...) @:346813</c>).
|
||||||
|
/// <c>gmConfigUI::InitOptions</c>'s <c>SetDefaultValue(1, 0x3f800000)</c>
|
||||||
|
/// therefore means CHECKED-BY-DEFAULT maps to ENABLED-BY-DEFAULT — a fresh
|
||||||
|
/// character has sound ON, not muted. The checkbox's own retail label is a
|
||||||
|
/// legacy misnomer (the string key's name never changed even though the
|
||||||
|
/// binding is not inverted); the STORED semantic is unambiguous. The
|
||||||
|
/// rejected OP6 slice read the "1" as "Disabled=true" without checking what
|
||||||
|
/// the registered backing variable actually meant, producing three inverted
|
||||||
|
/// toggles that muted SFX and ambient audio for every profile — fresh AND
|
||||||
|
/// existing (a missing key in an existing settings.json falls back to the
|
||||||
|
/// record default, so the fix must make the RECORD DEFAULT mean "on").
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The other four fields are honest store-only round-trips (register row
|
||||||
|
/// AP-199): retail registers <c>Sound_InterfaceSoundVolume</c>/
|
||||||
|
/// <c>Sound_InterfaceSoundDisabled</c> and then never reads them either —
|
||||||
|
/// AP-174 already documents "interface sounds are scaled by the EFFECT
|
||||||
|
/// knob" — so acdream matches retail's own dead-knob behaviour rather than
|
||||||
|
/// building a working one (<see cref="InterfaceEnabled"/> keeps the same
|
||||||
|
/// enabled-sense naming as its two live siblings for consistency, even
|
||||||
|
/// though nothing reads it); <see cref="SoundFeatures"/> (Stereo/Mono) and
|
||||||
|
/// <see cref="PlaySoundOnlyWhenActive"/> have no acdream mixer-channel-count
|
||||||
|
/// or window-focus-mute consumer.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record AudioSettings(
|
public sealed record AudioSettings(
|
||||||
|
|
@ -45,14 +79,20 @@ public sealed record AudioSettings(
|
||||||
// OP6: Sound Options menu row — Sound_SoundFeatures (Stereo=0/Mono=1).
|
// OP6: Sound Options menu row — Sound_SoundFeatures (Stereo=0/Mono=1).
|
||||||
// Store-only: acdream's OpenAL backend has no channel-count toggle.
|
// Store-only: acdream's OpenAL backend has no channel-count toggle.
|
||||||
int SoundFeatures = 0,
|
int SoundFeatures = 0,
|
||||||
// OP6: the toggle halves of the Sound/Ambient volume trios
|
// OP6 rework (2026-08-11, review M2): the toggle halves of the
|
||||||
// (Sound_SoundDisabled / Sound_AmbientSoundDisabled). LIVE — gate the
|
// Sound/Ambient volume trios — retail's own ENABLED-sense
|
||||||
// existing Sfx/Ambient knobs (RuntimeSettingsStartupTargets.ApplyAudio).
|
// SoundManager::effect_sounds_enabled/ambient_sounds_enabled statics
|
||||||
bool SfxDisabled = true,
|
// (see class doc for the byte evidence), NOT "Disabled" fields. LIVE —
|
||||||
bool AmbientDisabled = true,
|
// gate the existing Sfx/Ambient knobs
|
||||||
// OP6: the Interface Sound trio — retail's own dead knob (AP-174).
|
// (RuntimeSettingsStartupTargets.ApplyAudio). Fresh key names — the
|
||||||
// Store-only.
|
// rejected slice's "sfxDisabled"/"ambientDisabled" JSON keys never
|
||||||
bool InterfaceDisabled = true,
|
// shipped in an accepted build.
|
||||||
|
bool SfxEnabled = true,
|
||||||
|
bool AmbientEnabled = true,
|
||||||
|
// OP6 rework: the Interface Sound trio — retail's own dead knob
|
||||||
|
// (AP-174). Store-only; enabled-sense naming kept for consistency with
|
||||||
|
// its two live siblings above.
|
||||||
|
bool InterfaceEnabled = true,
|
||||||
float InterfaceVolume = 1.0f,
|
float InterfaceVolume = 1.0f,
|
||||||
// OP6: Sound_PlaySoundOnlyWhenActive — store-only, no window-focus
|
// OP6: Sound_PlaySoundOnlyWhenActive — store-only, no window-focus
|
||||||
// mute subsystem exists.
|
// mute subsystem exists.
|
||||||
|
|
@ -64,10 +104,10 @@ public sealed record AudioSettings(
|
||||||
/// so ambient starts at unity rather than the invented 0.8. The six OP6
|
/// so ambient starts at unity rather than the invented 0.8. The six OP6
|
||||||
/// trailing fields default to retail's OWN byte-verified
|
/// trailing fields default to retail's OWN byte-verified
|
||||||
/// <c>gmClient::InitUIPreferences</c> literals: <c>SetDefaultValue(1,
|
/// <c>gmClient::InitUIPreferences</c> literals: <c>SetDefaultValue(1,
|
||||||
/// 0x3f800000)</c> on every trio (toggle=checked/"Disabled"=true,
|
/// 0x3f800000)</c> on every trio (toggle=checked=enabled per the class
|
||||||
/// slider=1.0) — retail's 2013 EoR build genuinely ships every Sound
|
/// doc's byte evidence, slider=1.0) — retail's 2013 EoR build genuinely
|
||||||
/// category checkbox CHECKED (disabled) by default; ported faithfully,
|
/// ships every Sound category ENABLED and audible by default; ported
|
||||||
/// not "fixed".
|
/// faithfully.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static AudioSettings Default { get; } = new(
|
public static AudioSettings Default { get; } = new(
|
||||||
Master: 1.0f,
|
Master: 1.0f,
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ public enum ParticleRange
|
||||||
/// <c>gmClient::InitUIPreferences @0x004035b0</c> DOES register
|
/// <c>gmClient::InitUIPreferences @0x004035b0</c> DOES register
|
||||||
/// <c>Render_FieldOfView</c> (<c>ID_Graphics_FieldOfView</c>, range
|
/// <c>Render_FieldOfView</c> (<c>ID_Graphics_FieldOfView</c>, range
|
||||||
/// [10,160]), <c>Display_SyncToRefresh</c>, <c>Display_Resolution</c>, and
|
/// [10,160]), <c>Display_SyncToRefresh</c>, <c>Display_Resolution</c>, and
|
||||||
/// <c>Render_ScreenBrightness</c> (range [-1,1], mapped onto
|
/// <c>Render_ScreenBrightness</c> (<see cref="ScreenBrightness"/> below,
|
||||||
/// <see cref="Gamma"/> below) as genuine <c>UserPreferences.ini</c> rows —
|
/// range [-1,1]) as genuine <c>UserPreferences.ini</c> rows — they simply
|
||||||
/// they simply had no acdream UI surface until OP6's Config tab. Resolution/
|
/// had no acdream UI surface until OP6's Config tab. Resolution/
|
||||||
/// Fullscreen are LIVE on save (<c>RuntimeSettingsTargets.
|
/// Fullscreen are LIVE on save (<c>RuntimeSettingsTargets.
|
||||||
/// ApplyDisplayWindowState</c> resizes the window immediately); VSync/FOV/
|
/// ApplyDisplayWindowState</c> resizes the window immediately); VSync/FOV/
|
||||||
/// Gamma apply at the next launch only (<c>RuntimeSettingsController.
|
/// Gamma apply at the next launch only (<c>RuntimeSettingsController.
|
||||||
|
|
@ -51,6 +51,18 @@ public sealed record DisplaySettings(
|
||||||
// per-feature knobs (register row, OP6). Persisted faithfully; every
|
// per-feature knobs (register row, OP6). Persisted faithfully; every
|
||||||
// default below is retail's own byte-verified
|
// default below is retail's own byte-verified
|
||||||
// gmClient::InitUIPreferences / gmConfigUI::InitOptions literal.
|
// gmClient::InitUIPreferences / gmConfigUI::InitOptions literal.
|
||||||
|
//
|
||||||
|
// OP6 rework (2026-08-11, review S2): Screen Brightness gets its OWN
|
||||||
|
// field — the rejected slice reused Gamma (a pre-existing multiplier,
|
||||||
|
// default 1.0, legacy Settings-panel range [0.5, 2.0]), a genuinely
|
||||||
|
// different unit system from retail's own Render_ScreenBrightness
|
||||||
|
// range [-1, 1] / default 0 (AttachPreference @0x004043df,
|
||||||
|
// SetPreferenceRange @0x004043f3). Overloading Gamma pinned the Config
|
||||||
|
// row at the WRONG default (a fresh Gamma=1.0 normalizes to the
|
||||||
|
// slider's maximum, not center) and made Defaults write a value (0)
|
||||||
|
// outside the legacy slider's own range. Gamma itself is untouched —
|
||||||
|
// still the pre-existing multiplier the legacy Settings panel drives.
|
||||||
|
float ScreenBrightness = 0f,
|
||||||
bool AutomaticDegrades = false,
|
bool AutomaticDegrades = false,
|
||||||
float GraphicsPerformance = 0f,
|
float GraphicsPerformance = 0f,
|
||||||
float DegradeDistance = 50f,
|
float DegradeDistance = 50f,
|
||||||
|
|
@ -81,9 +93,24 @@ public sealed record DisplaySettings(
|
||||||
Quality: QualityPreset.High,
|
Quality: QualityPreset.High,
|
||||||
ParticleRange: ParticleRange.Extended);
|
ParticleRange: ParticleRange.Extended);
|
||||||
|
|
||||||
/// <summary>16:9 resolution presets offered in the dropdown.</summary>
|
/// <summary>
|
||||||
|
/// Resolution presets offered in the dropdown. <c>800x600</c> is retail's
|
||||||
|
/// OWN Config-tab default (OP6 rework, review S4) — a genuine legacy
|
||||||
|
/// display mode, not an invented entry: <c>gmClient::Init @0x004047af</c>
|
||||||
|
/// calls <c>Device::ForceDisplayResolution(1, 0x320, 0x258)</c> (0x320 =
|
||||||
|
/// 800, 0x258 = 600) at startup, and <c>gmConfigUI::InitOptions</c>'s own
|
||||||
|
/// <c>SetDefaultValue(0x03200258)</c> (byte-verified) names it as the
|
||||||
|
/// Resolution row's default. Without it in this list, clicking Defaults
|
||||||
|
/// resized the window correctly but left the dropdown showing an entry
|
||||||
|
/// that could never be re-selected — the same "opaque default" shape
|
||||||
|
/// LandscapeDrawDistance has for a genuinely different reason (AP-198's
|
||||||
|
/// sub-note); this one has a one-line fix instead of an opaque default,
|
||||||
|
/// so it gets the fix. The rest of the list is acdream's own modern
|
||||||
|
/// 16:9 preset ladder, not retail-authored.
|
||||||
|
/// </summary>
|
||||||
public static IReadOnlyList<string> AvailableResolutions { get; } = new[]
|
public static IReadOnlyList<string> AvailableResolutions { get; } = new[]
|
||||||
{
|
{
|
||||||
|
"800x600",
|
||||||
"1280x720",
|
"1280x720",
|
||||||
"1366x768",
|
"1366x768",
|
||||||
"1600x900",
|
"1600x900",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ public sealed class SettingsStore
|
||||||
Quality: ReadQuality (disp, "quality", d.Quality),
|
Quality: ReadQuality (disp, "quality", d.Quality),
|
||||||
ParticleRange: ReadParticleRange(
|
ParticleRange: ReadParticleRange(
|
||||||
disp, "particleRange", d.ParticleRange),
|
disp, "particleRange", d.ParticleRange),
|
||||||
|
ScreenBrightness: ReadFloat(disp, "screenBrightness", d.ScreenBrightness),
|
||||||
AutomaticDegrades: ReadBool (disp, "automaticDegrades", d.AutomaticDegrades),
|
AutomaticDegrades: ReadBool (disp, "automaticDegrades", d.AutomaticDegrades),
|
||||||
GraphicsPerformance: ReadFloat(disp, "graphicsPerformance", d.GraphicsPerformance),
|
GraphicsPerformance: ReadFloat(disp, "graphicsPerformance", d.GraphicsPerformance),
|
||||||
DegradeDistance: ReadFloat(disp, "degradeDistance", d.DegradeDistance),
|
DegradeDistance: ReadFloat(disp, "degradeDistance", d.DegradeDistance),
|
||||||
|
|
@ -120,9 +121,16 @@ public sealed class SettingsStore
|
||||||
Sfx: ReadFloat(audio, "sfx", d.Sfx),
|
Sfx: ReadFloat(audio, "sfx", d.Sfx),
|
||||||
Ambient: ReadFloat(audio, "ambient", d.Ambient),
|
Ambient: ReadFloat(audio, "ambient", d.Ambient),
|
||||||
SoundFeatures: ReadInt (audio, "soundFeatures", d.SoundFeatures),
|
SoundFeatures: ReadInt (audio, "soundFeatures", d.SoundFeatures),
|
||||||
SfxDisabled: ReadBool (audio, "sfxDisabled", d.SfxDisabled),
|
// OP6 rework (review M2): fresh enabled-sense key names — the
|
||||||
AmbientDisabled: ReadBool (audio, "ambientDisabled", d.AmbientDisabled),
|
// rejected slice's "sfxDisabled"/"ambientDisabled"/
|
||||||
InterfaceDisabled: ReadBool (audio, "interfaceDisabled", d.InterfaceDisabled),
|
// "interfaceDisabled" keys never shipped in an accepted
|
||||||
|
// build, so there is no legacy key to migrate. A missing key
|
||||||
|
// (including every pre-OP6 settings.json) falls back to
|
||||||
|
// AudioSettings.Default, which is now enabled=true — sound
|
||||||
|
// ON, matching retail.
|
||||||
|
SfxEnabled: ReadBool (audio, "sfxEnabled", d.SfxEnabled),
|
||||||
|
AmbientEnabled: ReadBool (audio, "ambientEnabled", d.AmbientEnabled),
|
||||||
|
InterfaceEnabled: ReadBool (audio, "interfaceEnabled", d.InterfaceEnabled),
|
||||||
InterfaceVolume: ReadFloat(audio, "interfaceVolume", d.InterfaceVolume),
|
InterfaceVolume: ReadFloat(audio, "interfaceVolume", d.InterfaceVolume),
|
||||||
PlaySoundOnlyWhenActive: ReadBool (audio, "playSoundOnlyWhenActive", d.PlaySoundOnlyWhenActive));
|
PlaySoundOnlyWhenActive: ReadBool (audio, "playSoundOnlyWhenActive", d.PlaySoundOnlyWhenActive));
|
||||||
}
|
}
|
||||||
|
|
@ -651,6 +659,7 @@ public sealed class SettingsStore
|
||||||
["particleRange"] = d.ParticleRange.ToString(),
|
["particleRange"] = d.ParticleRange.ToString(),
|
||||||
["quality"] = d.Quality.ToString(),
|
["quality"] = d.Quality.ToString(),
|
||||||
["resolution"] = d.Resolution,
|
["resolution"] = d.Resolution,
|
||||||
|
["screenBrightness"] = d.ScreenBrightness,
|
||||||
["showFps"] = d.ShowFps,
|
["showFps"] = d.ShowFps,
|
||||||
["textureFiltering"] = d.TextureFiltering,
|
["textureFiltering"] = d.TextureFiltering,
|
||||||
["vsync"] = d.VSync,
|
["vsync"] = d.VSync,
|
||||||
|
|
@ -671,13 +680,13 @@ public sealed class SettingsStore
|
||||||
=> new(StringComparer.Ordinal)
|
=> new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
["ambient"] = a.Ambient,
|
["ambient"] = a.Ambient,
|
||||||
["ambientDisabled"] = a.AmbientDisabled,
|
["ambientEnabled"] = a.AmbientEnabled,
|
||||||
["interfaceDisabled"] = a.InterfaceDisabled,
|
["interfaceEnabled"] = a.InterfaceEnabled,
|
||||||
["interfaceVolume"] = a.InterfaceVolume,
|
["interfaceVolume"] = a.InterfaceVolume,
|
||||||
["master"] = a.Master,
|
["master"] = a.Master,
|
||||||
["playSoundOnlyWhenActive"] = a.PlaySoundOnlyWhenActive,
|
["playSoundOnlyWhenActive"] = a.PlaySoundOnlyWhenActive,
|
||||||
["sfx"] = a.Sfx,
|
["sfx"] = a.Sfx,
|
||||||
["sfxDisabled"] = a.SfxDisabled,
|
["sfxEnabled"] = a.SfxEnabled,
|
||||||
["soundFeatures"] = a.SoundFeatures,
|
["soundFeatures"] = a.SoundFeatures,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,50 @@ public sealed class RuntimeSettingsControllerTests
|
||||||
Assert.Equal(resolved, controller.ResolvedQuality);
|
Assert.Equal(resolved, controller.ResolvedQuality);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OP6 rework (2026-08-11, review S5 / M2): pins the EFFECTIVE volume
|
||||||
|
// ApplyAudio actually computes, not just that some target was called.
|
||||||
|
// The FakeRuntimeTargets-based "target-audio" assertion above (and its
|
||||||
|
// predecessor before this rework) only ever checked that ApplyAudio
|
||||||
|
// fired, never what it fired WITH — the exact gap that let M2's
|
||||||
|
// Enabled/Disabled inversion mute every default profile unnoticed.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true, true, 0.6f, 0.9f, 0.6f, 0.9f)] // enabled: slider value passes through
|
||||||
|
[InlineData(false, false, 0.6f, 0.9f, 0f, 0f)] // disabled: forced to zero regardless of slider
|
||||||
|
[InlineData(true, false, 1.0f, 1.0f, 1.0f, 0f)] // independent per-category gating
|
||||||
|
public void ComputeEffectiveCategoryVolumes_GatesSliderValueOnEnabledFlag(
|
||||||
|
bool sfxEnabled, bool ambientEnabled,
|
||||||
|
float sfxSlider, float ambientSlider,
|
||||||
|
float expectedSfx, float expectedAmbient)
|
||||||
|
{
|
||||||
|
AudioSettings audio = AudioSettings.Default with
|
||||||
|
{
|
||||||
|
SfxEnabled = sfxEnabled,
|
||||||
|
AmbientEnabled = ambientEnabled,
|
||||||
|
Sfx = sfxSlider,
|
||||||
|
Ambient = ambientSlider,
|
||||||
|
};
|
||||||
|
|
||||||
|
(float sfx, float ambient) = RuntimeSettingsStartupTargets.ComputeEffectiveCategoryVolumes(audio);
|
||||||
|
|
||||||
|
Assert.Equal(expectedSfx, sfx);
|
||||||
|
Assert.Equal(expectedAmbient, ambient);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeEffectiveCategoryVolumes_DefaultProfile_IsAudible_NotMuted()
|
||||||
|
{
|
||||||
|
// The exact regression M2 shipped: a fresh/default AudioSettings
|
||||||
|
// must leave sound effects and ambient audio AUDIBLE on the next
|
||||||
|
// launch, matching retail's own byte-verified enabled-by-default
|
||||||
|
// statics (SoundManager::effect_sounds_enabled/
|
||||||
|
// ambient_sounds_enabled = 1) — see AudioSettings' class doc.
|
||||||
|
(float sfx, float ambient) =
|
||||||
|
RuntimeSettingsStartupTargets.ComputeEffectiveCategoryVolumes(AudioSettings.Default);
|
||||||
|
|
||||||
|
Assert.Equal(1.0f, sfx);
|
||||||
|
Assert.Equal(1.0f, ambient);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SaveChatPublishesSetSingleCharacterOptionOnlyForChangedBits()
|
public void SaveChatPublishesSetSingleCharacterOptionOnlyForChangedBits()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -41,16 +41,22 @@ public sealed class ConfigOptionsPageControllerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AudioSettings_Default_MatchesRetailByteVerifiedTrioDefaults()
|
public void AudioSettings_Default_MatchesRetailByteVerifiedTrioDefaults()
|
||||||
{
|
{
|
||||||
// gmClient::InitUIPreferences: every Sound trio's checkbox defaults
|
// OP6 rework (2026-08-11, review M2): every Sound trio's checkbox
|
||||||
// to CHECKED ("Disabled"=true) — SetDefaultValue(1, 0x3f800000) in
|
// defaults to CHECKED — SetDefaultValue(1, 0x3f800000) in
|
||||||
// gmConfigUI::InitOptions, ported faithfully (see
|
// gmConfigUI::InitOptions — AND checked means ENABLED, not
|
||||||
// AudioSettings.Default's own doc for why this looks backwards but
|
// "Disabled" (SoundManager::effect_sounds_enabled/
|
||||||
// isn't a "fix").
|
// ambient_sounds_enabled/interface_sounds_enabled are all compiled
|
||||||
|
// = 1 in .data, and UserPreferences::RegisterPreference binds the
|
||||||
|
// checkbox's boolean value directly onto those statics — see
|
||||||
|
// AudioSettings' own class doc for the full byte evidence). A
|
||||||
|
// fresh character therefore hears sound by default; this is NOT
|
||||||
|
// the "looks backwards but isn't a fix" case the rejected slice
|
||||||
|
// claimed — the rejected slice's inversion was the actual bug.
|
||||||
AudioSettings d = AudioSettings.Default;
|
AudioSettings d = AudioSettings.Default;
|
||||||
Assert.Equal(0, d.SoundFeatures);
|
Assert.Equal(0, d.SoundFeatures);
|
||||||
Assert.True(d.SfxDisabled);
|
Assert.True(d.SfxEnabled);
|
||||||
Assert.True(d.AmbientDisabled);
|
Assert.True(d.AmbientEnabled);
|
||||||
Assert.True(d.InterfaceDisabled);
|
Assert.True(d.InterfaceEnabled);
|
||||||
Assert.Equal(1.0f, d.InterfaceVolume);
|
Assert.Equal(1.0f, d.InterfaceVolume);
|
||||||
Assert.True(d.PlaySoundOnlyWhenActive);
|
Assert.True(d.PlaySoundOnlyWhenActive);
|
||||||
Assert.Equal(1.0f, d.Sfx);
|
Assert.Equal(1.0f, d.Sfx);
|
||||||
|
|
@ -73,6 +79,8 @@ public sealed class ConfigOptionsPageControllerTests
|
||||||
public void DisplaySettings_Default_MatchesRetailByteVerifiedConfigDefaults()
|
public void DisplaySettings_Default_MatchesRetailByteVerifiedConfigDefaults()
|
||||||
{
|
{
|
||||||
DisplaySettings d = DisplaySettings.Default;
|
DisplaySettings d = DisplaySettings.Default;
|
||||||
|
// OP6 rework (review S2): ScreenBrightness is its own field now.
|
||||||
|
Assert.Equal(0f, d.ScreenBrightness);
|
||||||
Assert.False(d.AutomaticDegrades);
|
Assert.False(d.AutomaticDegrades);
|
||||||
Assert.Equal(0f, d.GraphicsPerformance);
|
Assert.Equal(0f, d.GraphicsPerformance);
|
||||||
Assert.Equal(50f, d.DegradeDistance);
|
Assert.Equal(50f, d.DegradeDistance);
|
||||||
|
|
@ -102,12 +110,14 @@ public sealed class ConfigOptionsPageControllerTests
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var store = new SettingsStore(path);
|
var store = new SettingsStore(path);
|
||||||
|
// OP6 rework (review M2): renamed Enabled-sense fields, fresh
|
||||||
|
// JSON keys ("sfxEnabled" etc.) — see AudioSettings' own doc.
|
||||||
var written = AudioSettings.Default with
|
var written = AudioSettings.Default with
|
||||||
{
|
{
|
||||||
SoundFeatures = 1,
|
SoundFeatures = 1,
|
||||||
SfxDisabled = false,
|
SfxEnabled = false,
|
||||||
AmbientDisabled = false,
|
AmbientEnabled = false,
|
||||||
InterfaceDisabled = false,
|
InterfaceEnabled = false,
|
||||||
InterfaceVolume = 0.4f,
|
InterfaceVolume = 0.4f,
|
||||||
PlaySoundOnlyWhenActive = false,
|
PlaySoundOnlyWhenActive = false,
|
||||||
};
|
};
|
||||||
|
|
@ -122,7 +132,47 @@ public sealed class ConfigOptionsPageControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SettingsStore_DisplayRoundTrip_PreservesTheNineOP6Fields()
|
public void SettingsStore_ExistingProfileMissingTheEnabledKeys_LoadsWithSoundOn()
|
||||||
|
{
|
||||||
|
// OP6 rework (review M2 blast radius): an existing settings.json
|
||||||
|
// written by a build that never had the sfxEnabled/ambientEnabled/
|
||||||
|
// interfaceEnabled keys (e.g. only the "display" section present)
|
||||||
|
// must fall back to AudioSettings.Default — enabled=true — NOT to
|
||||||
|
// an inverted/missing-means-muted reading. This is the exact
|
||||||
|
// scenario the review's blast-radius finding named: "every existing
|
||||||
|
// settings.json... loads as disabled" under the rejected slice.
|
||||||
|
string path = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(), $"acdream-op6-audio-legacy-{Guid.NewGuid():N}.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.IO.File.WriteAllText(path, """
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"display": { "resolution": "1920x1080" }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
var store = new SettingsStore(path);
|
||||||
|
AudioSettings read = store.LoadAudio();
|
||||||
|
|
||||||
|
Assert.True(read.SfxEnabled);
|
||||||
|
Assert.True(read.AmbientEnabled);
|
||||||
|
Assert.True(read.InterfaceEnabled);
|
||||||
|
Assert.Equal(1.0f, read.Sfx);
|
||||||
|
Assert.Equal(1.0f, read.Ambient);
|
||||||
|
|
||||||
|
(float sfx, float ambient) =
|
||||||
|
AcDream.App.Settings.RuntimeSettingsStartupTargets.ComputeEffectiveCategoryVolumes(read);
|
||||||
|
Assert.Equal(1.0f, sfx);
|
||||||
|
Assert.Equal(1.0f, ambient);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettingsStore_DisplayRoundTrip_PreservesTheTenOP6Fields()
|
||||||
{
|
{
|
||||||
string path = System.IO.Path.Combine(
|
string path = System.IO.Path.Combine(
|
||||||
System.IO.Path.GetTempPath(), $"acdream-op6-display-{Guid.NewGuid():N}.json");
|
System.IO.Path.GetTempPath(), $"acdream-op6-display-{Guid.NewGuid():N}.json");
|
||||||
|
|
@ -131,6 +181,8 @@ public sealed class ConfigOptionsPageControllerTests
|
||||||
var store = new SettingsStore(path);
|
var store = new SettingsStore(path);
|
||||||
var written = DisplaySettings.Default with
|
var written = DisplaySettings.Default with
|
||||||
{
|
{
|
||||||
|
// OP6 rework (review S2): ScreenBrightness's own field.
|
||||||
|
ScreenBrightness = 0.25f,
|
||||||
AutomaticDegrades = true,
|
AutomaticDegrades = true,
|
||||||
GraphicsPerformance = 0.5f,
|
GraphicsPerformance = 0.5f,
|
||||||
DegradeDistance = 75f,
|
DegradeDistance = 75f,
|
||||||
|
|
@ -327,15 +379,49 @@ public sealed class ConfigOptionsPageControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToggleRow_SfxDisabled_WritesThroughAudioBindings()
|
public void Bind_ListBoxStacks39Items_SixHeaders_SixSeparators_27OptionRows()
|
||||||
|
{
|
||||||
|
// OP6 rework (review S1): retail's own InitOptions ends with a
|
||||||
|
// SIXTH AddSeperator tailcall (0x0049e80d @Bind) — a trailing
|
||||||
|
// separator after the LAST section, not just the five interior
|
||||||
|
// ones. 6 headers + 6 separators + 27 option-widget-rows (the 3
|
||||||
|
// trios are ONE widget row each despite registering TWO
|
||||||
|
// IOptionRow instances — see Bind_Succeeds_AndRegistersExactly30Rows)
|
||||||
|
// = 39 stacked ListBox items total, matching retail exactly. The
|
||||||
|
// rejected slice built 38 (five interior separators only).
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
||||||
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
||||||
|
layout,
|
||||||
|
new OptionsPanelController.Callbacks(
|
||||||
|
Toggle: () => { },
|
||||||
|
RequestExitToCharacterSelection: () => { },
|
||||||
|
ExitGame: () => { },
|
||||||
|
UseMouseTurningSettings: () => { },
|
||||||
|
DisplaySystemMessage: _ => { }))!;
|
||||||
|
var fakeBindings = new FakeBindings();
|
||||||
|
bool bound = ConfigOptionsPageController.Bind(
|
||||||
|
layout, controller.ConfigPage, MakeTemplateResolver(), (_, _) => null,
|
||||||
|
fakeBindings.ToBindings());
|
||||||
|
Assert.True(bound);
|
||||||
|
|
||||||
|
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||||
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
||||||
|
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
|
||||||
|
|
||||||
|
UiElement viewport = Assert.Single(listBox.Children);
|
||||||
|
Assert.Equal(39, viewport.Children.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToggleRow_SfxEnabled_WritesThroughAudioBindings()
|
||||||
{
|
{
|
||||||
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
||||||
var row = (BoolOptionRow)controller.ConfigPage.Rows[1]; // Sound trio toggle
|
var row = (BoolOptionRow)controller.ConfigPage.Rows[1]; // Sound trio toggle
|
||||||
|
|
||||||
row.SetCurrentValue(!AudioSettings.Default.SfxDisabled);
|
row.SetCurrentValue(!AudioSettings.Default.SfxEnabled);
|
||||||
|
|
||||||
Assert.Single(bindings.AudioSaves);
|
Assert.Single(bindings.AudioSaves);
|
||||||
Assert.Equal(!AudioSettings.Default.SfxDisabled, bindings.AudioSaves[0].SfxDisabled);
|
Assert.Equal(!AudioSettings.Default.SfxEnabled, bindings.AudioSaves[0].SfxEnabled);
|
||||||
// The slider half's own field must be untouched by the toggle write.
|
// The slider half's own field must be untouched by the toggle write.
|
||||||
Assert.Equal(AudioSettings.Default.Sfx, bindings.AudioSaves[0].Sfx);
|
Assert.Equal(AudioSettings.Default.Sfx, bindings.AudioSaves[0].Sfx);
|
||||||
}
|
}
|
||||||
|
|
@ -468,6 +554,184 @@ public sealed class ConfigOptionsPageControllerTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryRow_DefaultValue_MatchesRetailLiteral_NotJustSelfConsistency()
|
||||||
|
{
|
||||||
|
// OP6 rework (review S5): Defaults_RestoresRetailDefaultValue...
|
||||||
|
// above only proves each row's Current becomes its OWN
|
||||||
|
// DefaultValue — it says nothing about whether that DefaultValue
|
||||||
|
// IS retail's byte-verified literal. This pins every row's
|
||||||
|
// DefaultValue directly against the decompiled
|
||||||
|
// gmConfigUI::InitOptions/gmClient::InitUIPreferences constant,
|
||||||
|
// independent of the underlying settings-record defaults (already
|
||||||
|
// pinned separately above) — Build*Row's own inline
|
||||||
|
// `defaultValue:` parameters could drift from those record
|
||||||
|
// defaults without either test alone catching it.
|
||||||
|
(OptionsPanelController controller, _, _) = BindReal();
|
||||||
|
IReadOnlyList<IOptionRow> rows = controller.ConfigPage.Rows;
|
||||||
|
Assert.Equal(30, rows.Count);
|
||||||
|
|
||||||
|
object[] expected =
|
||||||
|
[
|
||||||
|
0, // 0 Sound Features menu (Stereo=0)
|
||||||
|
true, 1.0f, // 1-2 Sound trio (Enabled, Sfx)
|
||||||
|
true, 1.0f, // 3-4 Ambient trio
|
||||||
|
true, 1.0f, // 5-6 Interface trio
|
||||||
|
true, // 7 Play Sound Only When Active
|
||||||
|
|
||||||
|
0.45f, // 8 Camera Stiffness
|
||||||
|
40.0f, // 9 Camera Adjustment Speed
|
||||||
|
90.0f, // 10 Field Of View
|
||||||
|
true, // 11 Align To Slope
|
||||||
|
|
||||||
|
"800x600", // 12 Resolution
|
||||||
|
true, // 13 Full Screen
|
||||||
|
false, // 14 Sync To Refresh
|
||||||
|
0f, // 15 Screen Brightness
|
||||||
|
false, // 16 Automatic Degrades
|
||||||
|
0f, // 17 Graphics Performance
|
||||||
|
50.0f, // 18 Degrade Distance
|
||||||
|
|
||||||
|
2, // 19 Landscape Texture Detail
|
||||||
|
1, // 20 Environment Texture Detail
|
||||||
|
1, // 21 Texture Filtering
|
||||||
|
8, // 22 Landscape Draw Distance (opaque — AP-198 sub-note)
|
||||||
|
true, // 23 Building Detail Textures
|
||||||
|
false, // 24 Multi-Pass Alpha
|
||||||
|
|
||||||
|
0.55f, // 25 Mouse Look Sensitivity
|
||||||
|
false, // 26 Invert Mouselook Y Axis
|
||||||
|
false, // 27 Use Mouse Turning
|
||||||
|
|
||||||
|
2, // 28 Chat Font Face (PalatinoLinotype)
|
||||||
|
1, // 29 Chat Font Size
|
||||||
|
];
|
||||||
|
|
||||||
|
for (int i = 0; i < rows.Count; i++)
|
||||||
|
{
|
||||||
|
object actual = rows[i] switch
|
||||||
|
{
|
||||||
|
BoolOptionRow b => b.DefaultValue,
|
||||||
|
FloatOptionRow f => f.DefaultValue,
|
||||||
|
IntOptionRow n => n.DefaultValue,
|
||||||
|
StringOptionRow s => s.DefaultValue,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"row {i} has unexpected type {rows[i].GetType()}"),
|
||||||
|
};
|
||||||
|
Assert.True(
|
||||||
|
Equals(expected[i], actual),
|
||||||
|
$"row {i}: expected retail default {expected[i]} but row.DefaultValue was {actual}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_ResolvesOnlyTheAuthoredStringKeys_NoInventedOrDroppedKey()
|
||||||
|
{
|
||||||
|
// OP6 rework (review S5): the Character tab's registry-conformance
|
||||||
|
// pattern ("an invented row or a dropped row fails the build")
|
||||||
|
// applied to Config's label/tooltip/choice STRING KEYS — the exact
|
||||||
|
// dimension M3's 1-vs-5-entry ChatFontFaceChoices bug lived in and
|
||||||
|
// that LabelResolutionFailure... (null resolver) cannot see, since
|
||||||
|
// a null resolver makes every key invisible. Every string this
|
||||||
|
// page queries is transcribed here directly from
|
||||||
|
// gmClient::InitUIPreferences/gmConfigUI::InitOptions (bare label
|
||||||
|
// keys, their "<key>_Help" tooltip siblings, and every menu choice
|
||||||
|
// key) — an extra query (a typo'd/invented key) fails immediately;
|
||||||
|
// a query this test expected but never saw (a dropped key) fails
|
||||||
|
// at the end.
|
||||||
|
string[] expectedKeys =
|
||||||
|
[
|
||||||
|
// Headers
|
||||||
|
"ID_Sound_SoundSection", "ID_Camera_CameraSection",
|
||||||
|
"ID_Graphics_GraphicsSection", "ID_Graphics_TextureSection",
|
||||||
|
"ID_Input_InputSection", "ID_UI_UISection",
|
||||||
|
|
||||||
|
// Sound section
|
||||||
|
"ID_Sound_SoundFeatures", "ID_Sound_SoundFeatures_Help",
|
||||||
|
"ID_Sound_Stereo", "ID_Sound_Mono",
|
||||||
|
"ID_Sound_DisableSound", "ID_Sound_DisableSound_Help",
|
||||||
|
"ID_Sound_EffectVolume_Help",
|
||||||
|
"ID_Sound_DisableAmbientSound", "ID_Sound_DisableAmbientSound_Help",
|
||||||
|
"ID_Sound_AmbientVolume_Help",
|
||||||
|
"ID_Sound_DisableInterfaceSound", "ID_Sound_DisableInterfaceSound_Help",
|
||||||
|
"ID_Sound_InterfaceVolume_Help",
|
||||||
|
"ID_Sound_NoFocusNoSound", "ID_Sound_NoFocusNoSound_Help",
|
||||||
|
|
||||||
|
// Camera section
|
||||||
|
"ID_Camera_Stiffness", "ID_Camera_Stiffness_Help",
|
||||||
|
"ID_Graphics_Value_Soft", "ID_Graphics_Value_Hard",
|
||||||
|
"ID_Camera_AdjustmentSpeed", "ID_Camera_AdjustmentSpeed_Help",
|
||||||
|
"ID_Graphics_Value_Slow", "ID_Graphics_Value_Fast",
|
||||||
|
"ID_Graphics_FieldOfView", "ID_Graphics_FieldOfView_Help",
|
||||||
|
"ID_Graphics_Value_Narrow", "ID_Graphics_Value_Wide",
|
||||||
|
"ID_Camera_AlignToSlope", "ID_Camera_AlignToSlope_Help",
|
||||||
|
|
||||||
|
// Graphics section
|
||||||
|
"ID_Rendering_DisplayResolution", "ID_Rendering_DisplayResolution_Help",
|
||||||
|
"ID_Rendering_FullScreen", "ID_Rendering_FullScreen_Help",
|
||||||
|
"ID_Rendering_SyncToDisplayRefresh", "ID_Rendering_SyncToDisplayRefresh_Help",
|
||||||
|
"ID_Graphics_ScreenBrightness", "ID_Graphics_ScreenBrightness_Help",
|
||||||
|
"ID_Graphics_Value_Dark", "ID_Graphics_Value_Bright",
|
||||||
|
"ID_Graphics_AdaptiveDegrade", "ID_Graphics_AdaptiveDegrade_Help",
|
||||||
|
"ID_Graphics_AdaptiveDegradeBias", "ID_Graphics_AdaptiveDegradeBias_Help",
|
||||||
|
"ID_Graphics_Value_Speed", "ID_Graphics_Value_Detail",
|
||||||
|
"ID_Graphics_DegradeDistance", "ID_Graphics_DegradeDistance_Help",
|
||||||
|
"ID_Graphics_Value_Close", "ID_Graphics_Value_Far",
|
||||||
|
|
||||||
|
// Rendering Quality section
|
||||||
|
"ID_Graphics_LandscapeTextureDetail", "ID_Graphics_LandscapeTextureDetail_Help",
|
||||||
|
"ID_Graphics_EnvironmentTextureDetail", "ID_Graphics_EnvironmentTextureDetail_Help",
|
||||||
|
"ID_Graphics_Value_VeryLow", "ID_Graphics_Value_Low", "ID_Graphics_Value_Medium",
|
||||||
|
"ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh",
|
||||||
|
"ID_Graphics_TextureFiltering", "ID_Graphics_TextureFiltering_Help",
|
||||||
|
"ID_Graphics_TextureFiltering_Bilinear", "ID_Graphics_TextureFiltering_Trilinear",
|
||||||
|
"ID_Graphics_TextureFiltering_Sharp", "ID_Graphics_TextureFiltering_Anisotropic",
|
||||||
|
"ID_Graphics_LandscapeDrawDistance", "ID_Graphics_LandscapeDrawDistance_Help",
|
||||||
|
"ID_Graphics_Value_Extreme",
|
||||||
|
"ID_Graphics_BuildingDetailTextures", "ID_Graphics_BuildingDetailTextures_Help",
|
||||||
|
"ID_Graphics_MultiPassAlpha", "ID_Graphics_MultiPassAlpha_Help",
|
||||||
|
|
||||||
|
// Input section
|
||||||
|
"ID_Input_MouseLookSensitivity", "ID_Input_MouseLookSensitivity_Help",
|
||||||
|
"ID_Input_InvertMouseLookYAxis", "ID_Input_InvertMouseLookYAxis_Help",
|
||||||
|
"ID_Input_UseMouseTurning", "ID_Input_UseMouseTurning_Help",
|
||||||
|
|
||||||
|
// UI section
|
||||||
|
"ID_UI_ChatFontFace", "ID_UI_ChatFontFace_Help",
|
||||||
|
"ID_UI_Value_Arial", "ID_UI_Value_CourierNew", "ID_UI_Value_PalatinoLinotype",
|
||||||
|
"ID_UI_Value_Tahoma", "ID_UI_Value_TimesNewRoman",
|
||||||
|
"ID_UI_ChatFontSize", "ID_UI_ChatFontSize_Help",
|
||||||
|
"ID_UI_Value_Tiny", "ID_UI_Value_Small", "ID_UI_Value_Medium",
|
||||||
|
"ID_UI_Value_Large", "ID_UI_Value_XLarge",
|
||||||
|
];
|
||||||
|
|
||||||
|
var expectedByHash = new Dictionary<uint, string>();
|
||||||
|
foreach (string key in expectedKeys)
|
||||||
|
expectedByHash[DatStringResolver.ComputeHash(key)] = key;
|
||||||
|
|
||||||
|
var seen = new HashSet<string>();
|
||||||
|
Func<uint, uint, string?> recordingResolver = (table, hash) =>
|
||||||
|
{
|
||||||
|
Assert.Equal(0x23000003u, table);
|
||||||
|
Assert.True(
|
||||||
|
expectedByHash.TryGetValue(hash, out string? key),
|
||||||
|
$"resolveString queried hash 0x{hash:X8} — no key in the expected table hashes "
|
||||||
|
+ "to this value. Either an invented/typo'd key was added, or this test's "
|
||||||
|
+ "expected-key table is stale.");
|
||||||
|
seen.Add(key!);
|
||||||
|
return "x"; // any non-null value keeps the row-build path fully populated.
|
||||||
|
};
|
||||||
|
|
||||||
|
(_, _, bool bound) = BindReal(recordingResolver);
|
||||||
|
Assert.True(bound);
|
||||||
|
|
||||||
|
IEnumerable<string> missing = expectedByHash.Values.Except(seen);
|
||||||
|
Assert.True(
|
||||||
|
!missing.Any(),
|
||||||
|
"Bind never queried these expected keys (a dropped row/key): "
|
||||||
|
+ string.Join(", ", missing));
|
||||||
|
}
|
||||||
|
|
||||||
// The tab host's own private per-page SLOT ids (OptionsPanelController's
|
// The tab host's own private per-page SLOT ids (OptionsPanelController's
|
||||||
// ConfigPageId/ChatPageId) — the ids that actually survive base-merge in
|
// ConfigPageId/ChatPageId) — the ids that actually survive base-merge in
|
||||||
// the host-mounted tree (each page's own standalone-layout root id does
|
// the host-mounted tree (each page's own standalone-layout root id does
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue