UI: settings menu cvar expansion, Audio category, slot-locked scrolling

This commit is contained in:
mchughalex 2026-07-17 04:01:22 +01:00
parent 6c8e885bce
commit 198f138300
3 changed files with 537 additions and 21 deletions

View file

@ -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.

View file

@ -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.");

View file

@ -48,6 +48,54 @@ constexpr std::array<std::string_view, 7> 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<std::string_view, 13> 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<const char*, 4> kMsaaLabels = {"Off", "2x", "4x", "8x"};
constexpr std::array<int32_t, 4> 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<const char*, 4> kShadowQualityLabels = {"Off", "Console (512)",
"High (1024)", "Ultra (2048)"};
constexpr std::array<int32_t, 4> kShadowQualityTiles = {512, 512, 1024, 2048};
// Audio device buffer sizes in sample frames (0 = backend default).
constexpr std::array<const char*, 4> kAudioBufferLabels = {
"Auto", "512 (low latency)", "1024", "2048 (most stable)"};
constexpr std::array<int32_t, 4> kAudioBufferFrames = {0, 512, 1024, 2048};
// Xbox 360 XLanguage ids 1..12 (user_language cvar).
constexpr std::array<const char*, 12> 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<const char*, 4> kMenuChordSpecs = {"rb+start", "lb+rb+start", "back+start",
"l3+r3"};
constexpr std::array<const char*, 4> kMenuChordLabels = {"RB + Start", "LB + RB + Start",
"Back + Start", "L3 + R3"};
constexpr std::array<const char*, 5> 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<CategoryInfo, 4> kCategories = {{
{"Video", "Display, resolution and framerate settings."},
{"Controls", "Mouse and keyboard input settings."},
constexpr std::array<CategoryInfo, 5> 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<std::string_view> GetSimpleSettingsCvars() {
std::vector<std::string_view> 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<double>("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<int>(kMsaaSamples.size()); ++i) {
if (samples >= kMsaaSamples[i]) {
best = i;
}
}
return best;
}
int MsaaIndexFromCvar() {
if (!HasMsaaCvar()) {
return 2;
}
return MsaaIndexFromSamples(rex::cvar::Query<int32_t>("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<bool>("skate3_native_render_scene_shadows"),
rex::cvar::Query<int32_t>("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<bool>("skate3_native_render");
}
int MonitorIndexFromCvar() {
if (!HasCvar("monitor")) {
return 0;
}
return std::clamp(rex::cvar::Query<int32_t>("monitor"), 0,
static_cast<int>(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<int32_t>("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<int>(rex::cvar::Query<uint32_t>("user_language")), 1,
static_cast<int>(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<std::string>("menu_chord");
for (int i = 0; i < static_cast<int>(kMenuChordSpecs.size()); ++i) {
if (current == kMenuChordSpecs[i]) {
return i;
}
}
if (custom_spec) {
*custom_spec = std::move(current);
}
return static_cast<int>(kMenuChordSpecs.size());
}
// ---- CVar default parsing (Y / R "reset to default") ----------------------
std::optional<std::string> 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<bool>("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<bool>("fullscreen");
vsync_ = rex::cvar::Query<bool>("vsync");
tearing_ = TearingFromCvar();
mnk_mode_ = rex::cvar::Query<bool>("mnk_mode");
mnk_capture_mouse_ = rex::cvar::Query<bool>("mnk_capture_mouse");
renderer_native_ =
HasCvar("skate3_native_render_scene") && rex::cvar::Query<bool>("skate3_native_render_scene");
mode_indicator_ = HasCvar("skate3_native_render_mode_indicator") &&
rex::cvar::Query<bool>("skate3_native_render_mode_indicator");
fps_counter_ = HasCvar("show_fps_counter") && rex::cvar::Query<bool>("show_fps_counter");
audio_mute_ = HasCvar("audio_mute") && rex::cvar::Query<bool>("audio_mute");
rumble_ = HasCvar("hid_rumble_enabled") && rex::cvar::Query<bool>("hid_rumble_enabled");
mnk_sensitivity_ = HasCvar("mnk_sensitivity")
? float(std::clamp(rex::cvar::Query<double>("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<bool>("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<bool>("fullscreen") ||
vsync_ != rex::cvar::Query<bool>("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<int>(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<int>(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<int>(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<int>(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<int>(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<RowSpec>& 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<RowSpec>& 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<RowSpec>& 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<RowSpec>& 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<int>(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<RowSpec>& 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<int>(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<int>((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<int>(std::trunc(wheel_accum_));
if (notches != 0) {
content_scroll_ -= notches * row_pitch;
wheel_accum_ -= static_cast<float>(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),