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
|
|
@ -20,22 +20,56 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Campaign OP slice OP6 (2026-08-11): the six trailing fields are the
|
||||
/// remaining Config-tab "Sound Options" rows, byte-verified from
|
||||
/// <c>gmClient::InitUIPreferences @0x004035b0</c>
|
||||
/// (<c>UIPreferences::AttachPreference</c> calls). <see cref="SfxDisabled"/>/
|
||||
/// <see cref="AmbientDisabled"/> gate the EXISTING live <see cref="Sfx"/>/
|
||||
/// Campaign OP slice OP6 (2026-08-11), CORRECTED at the OP6 rework round
|
||||
/// (2026-08-11, review <c>docs/research/2026-08-11-op6-review.md</c> finding
|
||||
/// M2): the six trailing fields are the remaining Config-tab "Sound Options"
|
||||
/// rows, byte-verified from <c>gmClient::InitUIPreferences @0x004035b0</c>
|
||||
/// (<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>
|
||||
/// sends 0 to the engine when disabled, else the slider value — retail's
|
||||
/// own <c>Sound_SoundDisabled</c>/<c>Sound_AmbientSoundDisabled</c> toggle
|
||||
/// halves of the SAME <c>UIOption_CheckboxSlider</c> row). The other four
|
||||
/// fields are honest store-only round-trips (register row, OP6): 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="SoundFeatures"/>
|
||||
/// (Stereo/Mono) and <see cref="PlaySoundOnlyWhenActive"/> have no acdream
|
||||
/// mixer-channel-count or window-focus-mute consumer.
|
||||
/// sends 0 to the engine when NOT enabled, else the slider value).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why "Enabled", not "Disabled" (the rejected OP6 slice's original
|
||||
/// naming).</b> The Config tab's checkbox LABEL reads "Disable Sound
|
||||
/// Effects"/"Disable Ambient Sound"/"Disable Interface Sound"
|
||||
/// (<c>ID_Sound_DisableSound</c> etc.), which reads as if checking it
|
||||
/// disables sound. Byte evidence says otherwise:
|
||||
/// <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>
|
||||
/// </summary>
|
||||
public sealed record AudioSettings(
|
||||
|
|
@ -45,14 +79,20 @@ public sealed record AudioSettings(
|
|||
// OP6: Sound Options menu row — Sound_SoundFeatures (Stereo=0/Mono=1).
|
||||
// Store-only: acdream's OpenAL backend has no channel-count toggle.
|
||||
int SoundFeatures = 0,
|
||||
// OP6: the toggle halves of the Sound/Ambient volume trios
|
||||
// (Sound_SoundDisabled / Sound_AmbientSoundDisabled). LIVE — gate the
|
||||
// existing Sfx/Ambient knobs (RuntimeSettingsStartupTargets.ApplyAudio).
|
||||
bool SfxDisabled = true,
|
||||
bool AmbientDisabled = true,
|
||||
// OP6: the Interface Sound trio — retail's own dead knob (AP-174).
|
||||
// Store-only.
|
||||
bool InterfaceDisabled = true,
|
||||
// OP6 rework (2026-08-11, review M2): the toggle halves of the
|
||||
// Sound/Ambient volume trios — retail's own ENABLED-sense
|
||||
// SoundManager::effect_sounds_enabled/ambient_sounds_enabled statics
|
||||
// (see class doc for the byte evidence), NOT "Disabled" fields. LIVE —
|
||||
// gate the existing Sfx/Ambient knobs
|
||||
// (RuntimeSettingsStartupTargets.ApplyAudio). Fresh key names — the
|
||||
// rejected slice's "sfxDisabled"/"ambientDisabled" JSON keys never
|
||||
// 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,
|
||||
// OP6: Sound_PlaySoundOnlyWhenActive — store-only, no window-focus
|
||||
// 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
|
||||
/// trailing fields default to retail's OWN byte-verified
|
||||
/// <c>gmClient::InitUIPreferences</c> literals: <c>SetDefaultValue(1,
|
||||
/// 0x3f800000)</c> on every trio (toggle=checked/"Disabled"=true,
|
||||
/// slider=1.0) — retail's 2013 EoR build genuinely ships every Sound
|
||||
/// category checkbox CHECKED (disabled) by default; ported faithfully,
|
||||
/// not "fixed".
|
||||
/// 0x3f800000)</c> on every trio (toggle=checked=enabled per the class
|
||||
/// doc's byte evidence, slider=1.0) — retail's 2013 EoR build genuinely
|
||||
/// ships every Sound category ENABLED and audible by default; ported
|
||||
/// faithfully.
|
||||
/// </summary>
|
||||
public static AudioSettings Default { get; } = new(
|
||||
Master: 1.0f,
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ public enum ParticleRange
|
|||
/// <c>gmClient::InitUIPreferences @0x004035b0</c> DOES register
|
||||
/// <c>Render_FieldOfView</c> (<c>ID_Graphics_FieldOfView</c>, range
|
||||
/// [10,160]), <c>Display_SyncToRefresh</c>, <c>Display_Resolution</c>, and
|
||||
/// <c>Render_ScreenBrightness</c> (range [-1,1], mapped onto
|
||||
/// <see cref="Gamma"/> below) as genuine <c>UserPreferences.ini</c> rows —
|
||||
/// they simply had no acdream UI surface until OP6's Config tab. Resolution/
|
||||
/// <c>Render_ScreenBrightness</c> (<see cref="ScreenBrightness"/> below,
|
||||
/// range [-1,1]) as genuine <c>UserPreferences.ini</c> rows — they simply
|
||||
/// had no acdream UI surface until OP6's Config tab. Resolution/
|
||||
/// Fullscreen are LIVE on save (<c>RuntimeSettingsTargets.
|
||||
/// ApplyDisplayWindowState</c> resizes the window immediately); VSync/FOV/
|
||||
/// 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
|
||||
// default below is retail's own byte-verified
|
||||
// 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,
|
||||
float GraphicsPerformance = 0f,
|
||||
float DegradeDistance = 50f,
|
||||
|
|
@ -81,9 +93,24 @@ public sealed record DisplaySettings(
|
|||
Quality: QualityPreset.High,
|
||||
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[]
|
||||
{
|
||||
"800x600",
|
||||
"1280x720",
|
||||
"1366x768",
|
||||
"1600x900",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ public sealed class SettingsStore
|
|||
Quality: ReadQuality (disp, "quality", d.Quality),
|
||||
ParticleRange: ReadParticleRange(
|
||||
disp, "particleRange", d.ParticleRange),
|
||||
ScreenBrightness: ReadFloat(disp, "screenBrightness", d.ScreenBrightness),
|
||||
AutomaticDegrades: ReadBool (disp, "automaticDegrades", d.AutomaticDegrades),
|
||||
GraphicsPerformance: ReadFloat(disp, "graphicsPerformance", d.GraphicsPerformance),
|
||||
DegradeDistance: ReadFloat(disp, "degradeDistance", d.DegradeDistance),
|
||||
|
|
@ -120,9 +121,16 @@ public sealed class SettingsStore
|
|||
Sfx: ReadFloat(audio, "sfx", d.Sfx),
|
||||
Ambient: ReadFloat(audio, "ambient", d.Ambient),
|
||||
SoundFeatures: ReadInt (audio, "soundFeatures", d.SoundFeatures),
|
||||
SfxDisabled: ReadBool (audio, "sfxDisabled", d.SfxDisabled),
|
||||
AmbientDisabled: ReadBool (audio, "ambientDisabled", d.AmbientDisabled),
|
||||
InterfaceDisabled: ReadBool (audio, "interfaceDisabled", d.InterfaceDisabled),
|
||||
// OP6 rework (review M2): fresh enabled-sense key names — the
|
||||
// rejected slice's "sfxDisabled"/"ambientDisabled"/
|
||||
// "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),
|
||||
PlaySoundOnlyWhenActive: ReadBool (audio, "playSoundOnlyWhenActive", d.PlaySoundOnlyWhenActive));
|
||||
}
|
||||
|
|
@ -651,6 +659,7 @@ public sealed class SettingsStore
|
|||
["particleRange"] = d.ParticleRange.ToString(),
|
||||
["quality"] = d.Quality.ToString(),
|
||||
["resolution"] = d.Resolution,
|
||||
["screenBrightness"] = d.ScreenBrightness,
|
||||
["showFps"] = d.ShowFps,
|
||||
["textureFiltering"] = d.TextureFiltering,
|
||||
["vsync"] = d.VSync,
|
||||
|
|
@ -671,13 +680,13 @@ public sealed class SettingsStore
|
|||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
["ambient"] = a.Ambient,
|
||||
["ambientDisabled"] = a.AmbientDisabled,
|
||||
["interfaceDisabled"] = a.InterfaceDisabled,
|
||||
["ambientEnabled"] = a.AmbientEnabled,
|
||||
["interfaceEnabled"] = a.InterfaceEnabled,
|
||||
["interfaceVolume"] = a.InterfaceVolume,
|
||||
["master"] = a.Master,
|
||||
["playSoundOnlyWhenActive"] = a.PlaySoundOnlyWhenActive,
|
||||
["sfx"] = a.Sfx,
|
||||
["sfxDisabled"] = a.SfxDisabled,
|
||||
["sfxEnabled"] = a.SfxEnabled,
|
||||
["soundFeatures"] = a.SoundFeatures,
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue