From 198f138300a06a82ec0438e3696a082841fd8b7b Mon Sep 17 00:00:00 2001 From: mchughalex <25671999+mchughalex@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:01:22 +0100 Subject: [PATCH] UI: settings menu cvar expansion, Audio category, slot-locked scrolling --- .../rex/ui/overlay/simple_settings_overlay.h | 18 +- src/input/input_system.cpp | 2 +- src/ui/overlay/simple_settings_overlay.cpp | 538 +++++++++++++++++- 3 files changed, 537 insertions(+), 21 deletions(-) diff --git a/include/rex/ui/overlay/simple_settings_overlay.h b/include/rex/ui/overlay/simple_settings_overlay.h index 6e3eca5..6e21938 100644 --- a/include/rex/ui/overlay/simple_settings_overlay.h +++ b/include/rex/ui/overlay/simple_settings_overlay.h @@ -100,6 +100,11 @@ class SimpleSettingsDialog final : public ImGuiDialog { int resolution_scale_index_ = 0; int frame_cap_index_ = 0; int aspect_ratio_index_ = 0; + int msaa_index_ = 2; + int shadow_quality_index_ = 2; + int monitor_index_ = 0; + int audio_buffer_index_ = 0; + int language_index_ = 0; float field_of_view_ = 60.0f; bool fullscreen_ = true; bool vsync_ = false; @@ -108,6 +113,15 @@ class SimpleSettingsDialog final : public ImGuiDialog { bool mnk_capture_mouse_ = false; bool profile_signed_in_ = true; char gamertag_buf_[32] = {}; + // Live setting values (hot cvars, applied and saved on change). + bool renderer_native_ = true; + bool mode_indicator_ = true; + bool fps_counter_ = false; + bool audio_mute_ = false; + bool rumble_ = true; + float mnk_sensitivity_ = 1.0f; + int chord_index_ = 0; + std::string chord_custom_; // Navigation state. FocusZone zone_ = FocusZone::kRail; @@ -124,7 +138,9 @@ class SimpleSettingsDialog final : public ImGuiDialog { float repeat_timer_y_ = 0.0f; float highlight_anim_y_ = -1.0f; // smoothed selection highlight position float rail_anim_y_ = -1.0f; - float content_scroll_ = 0.0f; + float content_scroll_ = 0.0f; // scroll TARGET, locked to row boundaries + float content_scroll_anim_ = -1.0f; // drawn scroll, chases the target + float wheel_accum_ = 0.0f; // fractional wheel deltas -> whole notches // Swallow the first frame's cursor delta after Show: the pre-open cursor // position (or a cursor-mode warp) otherwise reads as mouse motion and // steals focus from the rail to whatever row it lands on. diff --git a/src/input/input_system.cpp b/src/input/input_system.cpp index 56181f1..a1f41cc 100644 --- a/src/input/input_system.cpp +++ b/src/input/input_system.cpp @@ -48,7 +48,7 @@ REXCVAR_DEFINE_BOOL(guide_button, false, "Input", "Enable guide button pass-thro // Back+Start is deliberately not the default: Steam Input uses View+Menu // (Back+Start) as its Guide-button chord on pads without a Guide button, so // opening our settings would also pop the Steam overlay. -REXCVAR_DEFINE_STRING(menu_chord, "lb+rb+start", "Input", +REXCVAR_DEFINE_STRING(menu_chord, "rb+start", "Input", "Controller chord that toggles the settings menu. Buttons joined by '+': " "a, b, x, y, lb, rb, l3, r3, back, start, dpad_up, dpad_down, dpad_left, " "dpad_right. Empty disables the chord."); diff --git a/src/ui/overlay/simple_settings_overlay.cpp b/src/ui/overlay/simple_settings_overlay.cpp index c2eb0a4..8181617 100644 --- a/src/ui/overlay/simple_settings_overlay.cpp +++ b/src/ui/overlay/simple_settings_overlay.cpp @@ -48,6 +48,54 @@ constexpr std::array kCoreSimpleSettingsCvars = { "vsync", "mnk_mode", "mnk_capture_mouse"}; +// Optional cvars persisted when the host defines them (HasCvar-gated: app +// cvars like the native-renderer knobs don't exist in every embedder, and +// backend/platform cvars don't exist in every build). +constexpr std::array kOptionalSimpleSettingsCvars = { + "skate3_native_render_scene", + "skate3_native_render_scene_msaa", + "skate3_native_render_scene_shadows", + "skate3_native_render_scene_shadow_tile", + "skate3_native_render_mode_indicator", + "show_fps_counter", + "monitor", + "mnk_sensitivity", + "hid_rumble_enabled", + "menu_chord", + "audio_mute", + "audio_device_sample_frames", + "user_language"}; + +// MSAA sample counts for the native scene renderer. +constexpr std::array kMsaaLabels = {"Off", "2x", "4x", "8x"}; +constexpr std::array kMsaaSamples = {1, 2, 4, 8}; + +// Shadow quality: dynamic-shadow toggle + cascade tile resolution. The +// game's own atlas is three 512 tiles ("Console"). +constexpr std::array kShadowQualityLabels = {"Off", "Console (512)", + "High (1024)", "Ultra (2048)"}; +constexpr std::array kShadowQualityTiles = {512, 512, 1024, 2048}; + +// Audio device buffer sizes in sample frames (0 = backend default). +constexpr std::array kAudioBufferLabels = { + "Auto", "512 (low latency)", "1024", "2048 (most stable)"}; +constexpr std::array kAudioBufferFrames = {0, 512, 1024, 2048}; + +// Xbox 360 XLanguage ids 1..12 (user_language cvar). +constexpr std::array kLanguageLabels = { + "English", "Japanese", "German", "French", + "Spanish", "Italian", "Korean", "Traditional Chinese", + "Portuguese", "Simplified Chinese", "Polish", "Russian"}; + +// Settings-menu controller chord presets (menu_chord cvar spec strings). +// First entry matches the cvar default (the chord reset target). +constexpr std::array kMenuChordSpecs = {"rb+start", "lb+rb+start", "back+start", + "l3+r3"}; +constexpr std::array kMenuChordLabels = {"RB + Start", "LB + RB + Start", + "Back + Start", "L3 + R3"}; + +constexpr std::array kMonitorLabels = {"Auto", "Monitor 1", "Monitor 2", + "Monitor 3", "Monitor 4"}; // X_INPUT_GAMEPAD_* button bits, mirrored locally to keep the UI overlay // decoupled from the kernel input headers. @@ -67,11 +115,12 @@ struct CategoryInfo { const char* name; const char* desc; }; -constexpr std::array kCategories = {{ - {"Video", "Display, resolution and framerate settings."}, - {"Controls", "Mouse and keyboard input settings."}, +constexpr std::array kCategories = {{ + {"Video", "Display, resolution, framerate and renderer quality settings."}, + {"Controls", "Controller, mouse and keyboard input settings."}, + {"Audio", "Sound output settings."}, {"Profile", "Local player profile and sign-in."}, - {"System", "Apply or revert pending changes and close the settings."}, + {"System", "Game language, pending changes and closing the settings."}, }}; // Navigation repeat pacing (seconds). @@ -121,8 +170,13 @@ bool HasCvar(std::string_view name) { std::vector GetSimpleSettingsCvars() { std::vector cvars; - cvars.reserve(15); + cvars.reserve(32); cvars.insert(cvars.end(), kCoreSimpleSettingsCvars.begin(), kCoreSimpleSettingsCvars.end()); + for (std::string_view name : kOptionalSimpleSettingsCvars) { + if (HasCvar(name)) { + cvars.push_back(name); + } + } // Literal names rather than the registry's string to keep the string_views // pointing at static storage. const std::string device_cvar = GetGraphicsDeviceList().cvar_name; @@ -247,6 +301,107 @@ float FieldOfViewFromCvar() { return float(std::clamp(rex::cvar::Query("skate3_field_of_view"), 40.0, 120.0)); } +bool HasMsaaCvar() { return HasCvar("skate3_native_render_scene_msaa"); } + +int MsaaIndexFromSamples(int32_t samples) { + int best = 0; + for (int i = 0; i < static_cast(kMsaaSamples.size()); ++i) { + if (samples >= kMsaaSamples[i]) { + best = i; + } + } + return best; +} + +int MsaaIndexFromCvar() { + if (!HasMsaaCvar()) { + return 2; + } + return MsaaIndexFromSamples(rex::cvar::Query("skate3_native_render_scene_msaa")); +} + +bool HasShadowQualityCvars() { + return HasCvar("skate3_native_render_scene_shadows") && + HasCvar("skate3_native_render_scene_shadow_tile"); +} + +int ShadowQualityIndexFrom(bool enabled, int32_t tile) { + if (!enabled) { + return 0; + } + if (tile <= 512) { + return 1; + } + return tile <= 1024 ? 2 : 3; +} + +int ShadowQualityIndexFromCvar() { + if (!HasShadowQualityCvars()) { + return 2; + } + return ShadowQualityIndexFrom( + rex::cvar::Query("skate3_native_render_scene_shadows"), + rex::cvar::Query("skate3_native_render_scene_shadow_tile")); +} + +// The native/emulated renderer choice only means anything while the native +// hook layer (skate3_native_render, restart-scoped) is active. +bool HasRendererChoice() { + return HasCvar("skate3_native_render_scene") && HasCvar("skate3_native_render") && + rex::cvar::Query("skate3_native_render"); +} + +int MonitorIndexFromCvar() { + if (!HasCvar("monitor")) { + return 0; + } + return std::clamp(rex::cvar::Query("monitor"), 0, + static_cast(kMonitorLabels.size()) - 1); +} + +int AudioBufferIndexFromFrames(int32_t frames) { + if (frames <= 0) { + return 0; + } + if (frames <= 512) { + return 1; + } + return frames <= 1024 ? 2 : 3; +} + +int AudioBufferIndexFromCvar() { + if (!HasCvar("audio_device_sample_frames")) { + return 0; + } + return AudioBufferIndexFromFrames(rex::cvar::Query("audio_device_sample_frames")); +} + +int LanguageIndexFromCvar() { + if (!HasCvar("user_language")) { + return 0; + } + // XLanguage ids are 1-based (1 = English .. 12 = Russian). + return std::clamp(static_cast(rex::cvar::Query("user_language")), 1, + static_cast(kLanguageLabels.size())) - + 1; +} + +// Preset index for the current menu_chord spec; kMenuChordSpecs.size() means +// "custom" (a hand-edited spec the presets don't cover - kept as an extra +// option so opening the menu never silently rewrites it). +int MenuChordIndexFromCvar(std::string* custom_spec) { + std::string current = rex::cvar::Query("menu_chord"); + for (int i = 0; i < static_cast(kMenuChordSpecs.size()); ++i) { + if (current == kMenuChordSpecs[i]) { + return i; + } + } + if (custom_spec) { + *custom_spec = std::move(current); + } + return static_cast(kMenuChordSpecs.size()); +} + // ---- CVar default parsing (Y / R "reset to default") ---------------------- std::optional CvarDefault(std::string_view name) { @@ -604,6 +759,7 @@ void SimpleSettingsDialog::Show() { rail_sel_ = category_; row_index_ = 0; content_scroll_ = 0.0f; + content_scroll_anim_ = 0.0f; editing_text_ = false; highlight_anim_y_ = -1.0f; rail_anim_y_ = -1.0f; @@ -618,12 +774,29 @@ void SimpleSettingsDialog::LoadSettingsFromCvars() { frame_cap_index_ = FrameCapIndexFromCvar(); aspect_ratio_index_ = HasCvar("skate3_ultrawide") && rex::cvar::Query("skate3_ultrawide") ? 1 : 0; + msaa_index_ = MsaaIndexFromCvar(); + shadow_quality_index_ = ShadowQualityIndexFromCvar(); + monitor_index_ = MonitorIndexFromCvar(); + audio_buffer_index_ = AudioBufferIndexFromCvar(); + language_index_ = LanguageIndexFromCvar(); field_of_view_ = FieldOfViewFromCvar(); fullscreen_ = rex::cvar::Query("fullscreen"); vsync_ = rex::cvar::Query("vsync"); tearing_ = TearingFromCvar(); mnk_mode_ = rex::cvar::Query("mnk_mode"); mnk_capture_mouse_ = rex::cvar::Query("mnk_capture_mouse"); + renderer_native_ = + HasCvar("skate3_native_render_scene") && rex::cvar::Query("skate3_native_render_scene"); + mode_indicator_ = HasCvar("skate3_native_render_mode_indicator") && + rex::cvar::Query("skate3_native_render_mode_indicator"); + fps_counter_ = HasCvar("show_fps_counter") && rex::cvar::Query("show_fps_counter"); + audio_mute_ = HasCvar("audio_mute") && rex::cvar::Query("audio_mute"); + rumble_ = HasCvar("hid_rumble_enabled") && rex::cvar::Query("hid_rumble_enabled"); + mnk_sensitivity_ = HasCvar("mnk_sensitivity") + ? float(std::clamp(rex::cvar::Query("mnk_sensitivity"), 0.1, 5.0)) + : 1.0f; + chord_custom_.clear(); + chord_index_ = HasCvar("menu_chord") ? MenuChordIndexFromCvar(&chord_custom_) : 0; } bool SimpleSettingsDialog::HasSettingsChanges() const { @@ -632,6 +805,12 @@ bool SimpleSettingsDialog::HasSettingsChanges() const { frame_cap_index_ != FrameCapIndexFromCvar() || (HasCvar("skate3_ultrawide") && (aspect_ratio_index_ != 0) != rex::cvar::Query("skate3_ultrawide")) || + (HasMsaaCvar() && msaa_index_ != MsaaIndexFromCvar()) || + (HasShadowQualityCvars() && shadow_quality_index_ != ShadowQualityIndexFromCvar()) || + (HasCvar("monitor") && monitor_index_ != MonitorIndexFromCvar()) || + (HasCvar("audio_device_sample_frames") && + audio_buffer_index_ != AudioBufferIndexFromCvar()) || + (HasCvar("user_language") && language_index_ != LanguageIndexFromCvar()) || fullscreen_ != rex::cvar::Query("fullscreen") || vsync_ != rex::cvar::Query("vsync") || tearing_ != TearingFromCvar() || @@ -728,6 +907,35 @@ void SimpleSettingsDialog::SaveVideo() { if (HasFieldOfViewCvar()) { rex::cvar::SetFlagByName("skate3_field_of_view", std::to_string(field_of_view_)); } + if (HasMsaaCvar()) { + msaa_index_ = std::clamp(msaa_index_, 0, static_cast(kMsaaSamples.size()) - 1); + rex::cvar::SetFlagByName("skate3_native_render_scene_msaa", + std::to_string(kMsaaSamples[msaa_index_])); + } + if (HasShadowQualityCvars()) { + shadow_quality_index_ = + std::clamp(shadow_quality_index_, 0, static_cast(kShadowQualityTiles.size()) - 1); + SetBoolCvar("skate3_native_render_scene_shadows", shadow_quality_index_ != 0); + if (shadow_quality_index_ != 0) { + rex::cvar::SetFlagByName("skate3_native_render_scene_shadow_tile", + std::to_string(kShadowQualityTiles[shadow_quality_index_])); + } + } + if (HasCvar("monitor")) { + monitor_index_ = std::clamp(monitor_index_, 0, static_cast(kMonitorLabels.size()) - 1); + rex::cvar::SetFlagByName("monitor", std::to_string(monitor_index_)); + } + if (HasCvar("audio_device_sample_frames")) { + audio_buffer_index_ = + std::clamp(audio_buffer_index_, 0, static_cast(kAudioBufferFrames.size()) - 1); + rex::cvar::SetFlagByName("audio_device_sample_frames", + std::to_string(kAudioBufferFrames[audio_buffer_index_])); + } + if (HasCvar("user_language")) { + language_index_ = + std::clamp(language_index_, 0, static_cast(kLanguageLabels.size()) - 1); + rex::cvar::SetFlagByName("user_language", std::to_string(language_index_ + 1)); + } SetBoolCvar("vsync", vsync_); SetTearingCvars(tearing_); SetBoolCvar("mnk_mode", mnk_mode_); @@ -852,6 +1060,20 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { row.reset = [this] { fullscreen_ = CvarDefaultBool("fullscreen", true); }; rows.push_back(std::move(row)); } + if (HasCvar("monitor")) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Monitor"; + row.desc = "Which display the game window opens on. Auto uses the primary display."; + for (const char* label : kMonitorLabels) { + row.options.push_back(label); + } + row.index = &monitor_index_; + row.reset = [this] { + monitor_index_ = int(CvarDefaultDouble("monitor", 0.0)); + }; + rows.push_back(std::move(row)); + } { RowSpec row; row.kind = RowSpec::kEnum; @@ -877,6 +1099,64 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { row.reset = [this] { tearing_ = TearingDefault(); }; rows.push_back(std::move(row)); } + if (HasRendererChoice() || HasMsaaCvar() || HasShadowQualityCvars()) { + header("Quality"); + } + if (HasRendererChoice()) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Renderer"; + row.desc = + "Native renders the game directly on your GPU (much faster); Emulated " + "replays the console GPU exactly. Switches live, no restart needed."; + row.options = {"Emulated", "Native"}; + row.flag = &renderer_native_; + row.on_enum_change = [this](int value) { + SetBoolCvar("skate3_native_render_scene", value != 0); + SaveSimpleSettingsConfig(config_path_); + }; + row.reset = [this] { + renderer_native_ = CvarDefaultBool("skate3_native_render_scene", false); + SetBoolCvar("skate3_native_render_scene", renderer_native_); + SaveSimpleSettingsConfig(config_path_); + }; + rows.push_back(std::move(row)); + } + if (HasMsaaCvar()) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Anti-Aliasing (MSAA)"; + row.desc = + "Multisampling for the native renderer. Smooths distant thin geometry " + "(railings, wires) that shimmers otherwise."; + for (const char* label : kMsaaLabels) { + row.options.push_back(label); + } + row.index = &msaa_index_; + row.reset = [this] { + msaa_index_ = + MsaaIndexFromSamples(int32_t(CvarDefaultDouble("skate3_native_render_scene_msaa", 4.0))); + }; + rows.push_back(std::move(row)); + } + if (HasShadowQualityCvars()) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Shadow Quality"; + row.desc = + "Dynamic character/prop shadow resolution in the native renderer. " + "Console matches the original game's shadow maps."; + for (const char* label : kShadowQualityLabels) { + row.options.push_back(label); + } + row.index = &shadow_quality_index_; + row.reset = [this] { + shadow_quality_index_ = ShadowQualityIndexFrom( + CvarDefaultBool("skate3_native_render_scene_shadows", true), + int32_t(CvarDefaultDouble("skate3_native_render_scene_shadow_tile", 1024.0))); + }; + rows.push_back(std::move(row)); + } if (HasFieldOfViewCvar()) { header("Gameplay"); RowSpec row; @@ -899,6 +1179,47 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { }; rows.push_back(std::move(row)); } + if (HasCvar("show_fps_counter") || HasCvar("skate3_native_render_mode_indicator")) { + header("Interface"); + } + if (HasCvar("show_fps_counter")) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "FPS Counter"; + row.desc = "Show the framerate overlay in the corner. Applies immediately."; + row.options = {"Off", "On"}; + row.flag = &fps_counter_; + row.on_enum_change = [this](int value) { + SetBoolCvar("show_fps_counter", value != 0); + SaveSimpleSettingsConfig(config_path_); + }; + row.reset = [this] { + fps_counter_ = CvarDefaultBool("show_fps_counter", false); + SetBoolCvar("show_fps_counter", fps_counter_); + SaveSimpleSettingsConfig(config_path_); + }; + rows.push_back(std::move(row)); + } + if (HasCvar("skate3_native_render_mode_indicator")) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Renderer Indicator"; + row.desc = + "Small corner readout of which renderer produced the last frame " + "(NATIVE or EMULATED). Applies immediately."; + row.options = {"Off", "On"}; + row.flag = &mode_indicator_; + row.on_enum_change = [this](int value) { + SetBoolCvar("skate3_native_render_mode_indicator", value != 0); + SaveSimpleSettingsConfig(config_path_); + }; + row.reset = [this] { + mode_indicator_ = CvarDefaultBool("skate3_native_render_mode_indicator", true); + SetBoolCvar("skate3_native_render_mode_indicator", mode_indicator_); + SaveSimpleSettingsConfig(config_path_); + }; + rows.push_back(std::move(row)); + } break; } case 1: { // Controls @@ -927,9 +1248,121 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { }; rows.push_back(std::move(row)); } + if (HasCvar("mnk_sensitivity")) { + RowSpec row; + row.kind = RowSpec::kSlider; + row.label = "Mouse Sensitivity"; + row.desc = + "Mouse-look speed while Mouse & Keyboard Mode is active. Applies " + "immediately."; + row.value = &mnk_sensitivity_; + row.min = 0.1f; + row.max = 5.0f; + row.step = 0.1f; + row.fmt = "%.1f"; + row.on_value_change = [this] { + mnk_sensitivity_ = std::clamp(mnk_sensitivity_, 0.1f, 5.0f); + rex::cvar::SetFlagByName("mnk_sensitivity", std::to_string(mnk_sensitivity_)); + }; + row.reset = [this] { + mnk_sensitivity_ = float(CvarDefaultDouble("mnk_sensitivity", 1.0)); + rex::cvar::SetFlagByName("mnk_sensitivity", std::to_string(mnk_sensitivity_)); + SaveSimpleSettingsConfig(config_path_); + }; + rows.push_back(std::move(row)); + } + if (HasCvar("hid_rumble_enabled") || HasCvar("menu_chord")) { + header("Controller"); + } + if (HasCvar("hid_rumble_enabled")) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Vibration"; + row.desc = "Controller rumble. Applies immediately."; + row.options = {"Off", "On"}; + row.flag = &rumble_; + row.on_enum_change = [this](int value) { + SetBoolCvar("hid_rumble_enabled", value != 0); + SaveSimpleSettingsConfig(config_path_); + }; + row.reset = [this] { + rumble_ = CvarDefaultBool("hid_rumble_enabled", true); + SetBoolCvar("hid_rumble_enabled", rumble_); + SaveSimpleSettingsConfig(config_path_); + }; + rows.push_back(std::move(row)); + } + if (HasCvar("menu_chord")) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Settings Menu Shortcut"; + row.desc = + "Controller button chord that opens this settings menu. Applies " + "immediately."; + for (const char* label : kMenuChordLabels) { + row.options.push_back(label); + } + if (!chord_custom_.empty()) { + row.options.push_back("Custom: " + chord_custom_); + } + row.index = &chord_index_; + row.on_enum_change = [this](int value) { + if (value < static_cast(kMenuChordSpecs.size())) { + rex::cvar::SetFlagByName("menu_chord", kMenuChordSpecs[value]); + } else if (!chord_custom_.empty()) { + rex::cvar::SetFlagByName("menu_chord", chord_custom_); + } + SaveSimpleSettingsConfig(config_path_); + }; + row.reset = [this] { + chord_index_ = 0; + rex::cvar::SetFlagByName("menu_chord", kMenuChordSpecs[0]); + SaveSimpleSettingsConfig(config_path_); + }; + rows.push_back(std::move(row)); + } break; } - case 2: { // Profile + case 2: { // Audio + header("Output"); + if (HasCvar("audio_mute")) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Mute"; + row.desc = "Silence all game audio. Applies immediately."; + row.options = {"Off", "On"}; + row.flag = &audio_mute_; + row.on_enum_change = [this](int value) { + SetBoolCvar("audio_mute", value != 0); + SaveSimpleSettingsConfig(config_path_); + }; + row.reset = [this] { + audio_mute_ = CvarDefaultBool("audio_mute", false); + SetBoolCvar("audio_mute", audio_mute_); + SaveSimpleSettingsConfig(config_path_); + }; + rows.push_back(std::move(row)); + } + if (HasCvar("audio_device_sample_frames")) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Audio Buffer Size"; + row.desc = + "Audio device buffer length. Larger buffers add a little latency but " + "fix crackling on machines with scheduling hiccups."; + for (const char* label : kAudioBufferLabels) { + row.options.push_back(label); + } + row.index = &audio_buffer_index_; + row.reset = [this] { + audio_buffer_index_ = AudioBufferIndexFromFrames( + int32_t(CvarDefaultDouble("audio_device_sample_frames", 0.0))); + }; + rows.push_back(std::move(row)); + } + break; + } + case 3: { // Profile header("Local Profile"); { RowSpec row; @@ -976,7 +1409,26 @@ void SimpleSettingsDialog::BuildRows(std::vector& rows, int category) { } break; } - case 3: { // System + case 4: { // System + if (HasCvar("user_language")) { + RowSpec row; + row.kind = RowSpec::kEnum; + row.label = "Game Language"; + row.desc = + "Language the game boots in. Skate 3 reads this once at startup, so " + "changing it needs Apply & Restart."; + for (const char* label : kLanguageLabels) { + row.options.push_back(label); + } + row.index = &language_index_; + row.reset = [this] { + language_index_ = + std::clamp(int(CvarDefaultDouble("user_language", 1.0)), 1, + static_cast(kLanguageLabels.size())) - + 1; + }; + rows.push_back(std::move(row)); + } header("Session"); { RowSpec row; @@ -1204,6 +1656,7 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { rail_sel_ = category_; row_index_ = 0; content_scroll_ = 0.0f; + content_scroll_anim_ = 0.0f; highlight_anim_y_ = -1.0f; } if (zone_ == FocusZone::kRail && in.move_y != 0) { @@ -1212,6 +1665,7 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { category_ = rail_sel_; row_index_ = 0; content_scroll_ = 0.0f; + content_scroll_anim_ = 0.0f; highlight_anim_y_ = -1.0f; } } @@ -1400,6 +1854,16 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { // Description panel height - also anchors Close Game's bottom edge. const float desc_panel_h = Snap(std::min(content_bottom - columns_y, 320.0f * s)); + // The content view holds an exact whole number of row slots: the + // bottom edge pulls UP to the last full slot, and the scroll target locks + // to the same pitch, so a resting list always shows complete rows evenly. + // One slot fewer than fits: playtest verdict - the list otherwise runs too + // close to the footer. + const float row_pitch = row_h + row_gap; + const int view_rows = std::max( + 1, static_cast((content_bottom - columns_y + row_gap) / row_pitch) - 1); + const float content_view_h = view_rows * row_pitch - row_gap; + const float content_view_bottom = columns_y + content_view_h; const ImVec2 mouse = io.MousePos; const bool mouse_moved = @@ -1462,6 +1926,7 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { zone_ = FocusZone::kRail; row_index_ = 0; content_scroll_ = 0.0f; + content_scroll_anim_ = 0.0f; is_current = true; } if (is_current && zone_ == FocusZone::kRail && rail_sel_ == i) { @@ -1507,8 +1972,12 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { // Close Game entry: below the categories, set apart by an extra gap. { - // Bottom edge aligned with the description panel's bottom. - float y0 = Snap(columns_y + desc_panel_h - rail_item_h); + // Bottom edge aligned with the description panel's bottom, but + // never closer to the last category than the 45*s gap the 4-category + // layout had - with more categories the rail wins and pushes it down. + const float rail_bottom = columns_y + category_count * (rail_item_h + row_gap) - row_gap; + float y0 = Snap(std::max(columns_y + desc_panel_h - rail_item_h, + rail_bottom + 45.0f * s)); float y1 = y0 + rail_item_h; bool focused = zone_ == FocusZone::kRail && rail_sel_ == quit_rail_index; bool hovered = mouse_in(rail_x, y0, rail_x + rail_w, y1); @@ -1536,14 +2005,23 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { y += row_hgt[i] + row_gap; } } - const float content_view_h = content_bottom - columns_y; const float rows_total_h = rows.empty() ? 0.0f : row_y.back() + row_hgt.back(); const float max_scroll = std::max(0.0f, rows_total_h - content_view_h); - // Wheel scroll while over the content column. - if (io.MouseWheel != 0.0f && - mouse_in(content_x, columns_y, content_x + content_w, content_bottom)) { - content_scroll_ -= io.MouseWheel * 60.0f * s; + // Wheel scroll steps whole rows while over the content column. High- + // resolution wheels and touchpads report fractional deltas per event, so + // accumulate them into whole notches first - scaling each tiny delta by + // the row pitch and then locking to row boundaries would round every one + // of them straight back to the current position. + if (mouse_in(content_x, columns_y, content_x + content_w, content_view_bottom)) { + wheel_accum_ += io.MouseWheel; + const int notches = static_cast(std::trunc(wheel_accum_)); + if (notches != 0) { + content_scroll_ -= notches * row_pitch; + wheel_accum_ -= static_cast(notches); + } + } else { + wheel_accum_ = 0.0f; } // Keep the focused row in view when navigating. if (zone_ == FocusZone::kContent && row_index_ >= 0 && (in.move_y != 0 || in.select)) { @@ -1555,14 +2033,29 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { content_scroll_ = y1 - content_view_h; } } + // Lock the resting position onto whole-row boundaries (rows and the view + // share the same pitch, so max_scroll is itself a pitch multiple). content_scroll_ = std::clamp(content_scroll_, 0.0f, max_scroll); - const float scroll = Snap(content_scroll_); + content_scroll_ = + std::clamp(std::round(content_scroll_ / row_pitch) * row_pitch, 0.0f, max_scroll); + // The drawn offset chases the locked target so scrolling slides smoothly + // instead of rows popping between aligned positions. + if (content_scroll_anim_ < 0.0f) { + content_scroll_anim_ = content_scroll_; + } + content_scroll_anim_ += + (content_scroll_ - content_scroll_anim_) * std::min(1.0f, io.DeltaTime * 16.0f); + if (std::abs(content_scroll_anim_ - content_scroll_) < 0.5f) { + content_scroll_anim_ = content_scroll_; // settle exactly on the boundary + } + const float scroll = Snap(content_scroll_anim_); // Clip window padded by the focus-ring extent (6*s) so the ring can sit // fully OUTSIDE the focused row without being cut off at the edges. const float ring_pad = 6.0f * s; dl->PushClipRect(ImVec2(content_x - ring_pad, columns_y - ring_pad), - ImVec2(content_x + content_w + ring_pad + 1.0f, content_bottom + ring_pad), + ImVec2(content_x + content_w + ring_pad + 1.0f, + content_view_bottom + ring_pad), true); // Sliding selection highlight (white fill under the focused row). @@ -1580,6 +2073,13 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { const float value_w = std::min(330.0f * s, content_w * 0.46f); const float chevron_r = 13.0f * s; + // Rows draw whenever they touch the view: at rest the pitch-locked scroll + // means nothing is ever cut, and mid-animation the edge rows slide in and + // out under the clip rect instead of popping. + auto row_in_view = [&](float y0, float y1) { + return y1 >= columns_y && y0 <= content_view_bottom; + }; + // Backgrounds first, then per-row content in TWO phases around the sliding // highlight: non-focused content below it, the focused row's content above // it - the ring overlaps neighbours (gap is 1 design px tighter than the @@ -1591,7 +2091,7 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { } float y0 = columns_y + Snap(row_y[i]) - scroll; float y1 = y0 + row_hgt[i]; - if (y1 < columns_y || y0 > content_bottom) { + if (!row_in_view(y0, y1)) { continue; } bool hovered = mouse_in(content_x, y0, content_x + content_w, y1); @@ -1614,7 +2114,7 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { RowSpec& row = rows[i]; float y0 = columns_y + Snap(row_y[i]) - scroll; float y1 = y0 + row_hgt[i]; - if (y1 < columns_y || y0 > content_bottom) { + if (!row_in_view(y0, y1)) { return; } float cy = (y0 + y1) * 0.5f; @@ -1808,7 +2308,7 @@ void SimpleSettingsDialog::OnDraw(ImGuiIO& io) { float bar_x = content_x + content_w + 4.0f * s; float track_h = content_view_h; float bar_h = std::max(24.0f * s, track_h * (content_view_h / rows_total_h)); - float bar_y = columns_y + (track_h - bar_h) * (content_scroll_ / max_scroll); + float bar_y = columns_y + (track_h - bar_h) * (content_scroll_anim_ / max_scroll); dl->AddRectFilled(ImVec2(bar_x, columns_y), ImVec2(bar_x + 3.0f * s, columns_y + track_h), IM_COL32(255, 255, 255, 18), 0.0f); dl->AddRectFilled(ImVec2(bar_x, bar_y), ImVec2(bar_x + 3.0f * s, bar_y + bar_h),