From dc6850e4796a4dab5b8fdec70b7995d12c31c355 Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:08:20 -0800 Subject: [PATCH] overlay: helper modal dialog for picking option values (#561) ## Link to GitHub Issue or related Pull Request, if one exists ## Description of change Introduce a new modal dialog for picking out some of the options to improve UX. Implemented a few for now: 1. UI for picking ASIO drivers from a list (for -iidxasio, -sdvxasio, -asioconvert, etc) 2. Button to generate card numbers for -card0 and card1 3. Processor Affinity selector (very similar to what's in Windows Task Manager, list of checkboxes) 4. file selector (file open dialog for various file path overrides like log file) 5. folder selector (folder override) I wrote a selector for COM ports as well but how we use the value was inconsistent (\\\\.\\COMx vs. COMx) and didn't see a huge point in it so I didn't check it in. ## Testing --- src/spice2x/cfg/option.h | 13 + src/spice2x/launcher/options.cpp | 26 ++ src/spice2x/overlay/imgui/extensions.cpp | 2 - src/spice2x/overlay/imgui/impl_spice.cpp | 2 +- src/spice2x/overlay/windows/config.cpp | 321 ++++++++++++++++++++++- src/spice2x/overlay/windows/config.h | 6 + src/spice2x/rawinput/rawinput.cpp | 4 + src/spice2x/rawinput/rawinput.h | 10 + 8 files changed, 374 insertions(+), 10 deletions(-) diff --git a/src/spice2x/cfg/option.h b/src/spice2x/cfg/option.h index 6aa39c9..2c7d2bd 100644 --- a/src/spice2x/cfg/option.h +++ b/src/spice2x/cfg/option.h @@ -13,6 +13,15 @@ enum class OptionType { Hex, }; +enum class OptionPickerType { + None, + AsioDriver, + EACard, + CpuAffinity, + FilePath, + DirectoryPath, +}; + struct OptionDefinition { std::string title; // unique identifier used for flag matching but also stored in config files @@ -32,6 +41,10 @@ struct OptionDefinition { bool sensitive = false; std::vector> elements = {}; bool disabled = false; + OptionPickerType picker = OptionPickerType::None; + + // for OptionPickerType::FilePath + std::string file_extension = ""; }; class Option { diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index 47fa204..2da08c0 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -71,6 +71,7 @@ static const std::vector OPTION_DEFINITIONS = { .type = OptionType::Text, .setting_name = "*.dll", .category = "Path Overrides", + // intentionally not setting a file picker here to discourage people setting this without a good reason }, { .title = "Open Configurator", @@ -121,6 +122,7 @@ static const std::vector OPTION_DEFINITIONS = { .setting_name = "E004010000000000", .category = "Network", .sensitive = true, + .picker = OptionPickerType::EACard, }, { .title = "Player 2 Card", @@ -130,6 +132,7 @@ static const std::vector OPTION_DEFINITIONS = { .setting_name = "E004010000000000", .category = "Network", .sensitive = true, + .picker = OptionPickerType::EACard, }, { // Player1PinMacro @@ -618,6 +621,7 @@ static const std::vector OPTION_DEFINITIONS = { .type = OptionType::Text, .game_name = "Beatmania IIDX", .category = "Game Options", + .picker = OptionPickerType::AsioDriver, }, { .title = "IIDX BIO2 Firmware Update", @@ -778,6 +782,7 @@ static const std::vector OPTION_DEFINITIONS = { .type = OptionType::Text, .game_name = "Sound Voltex", .category = "Game Options (Advanced)", + .picker = OptionPickerType::DirectoryPath, }, { .title = "SDVX Printer Output Clear", @@ -889,6 +894,7 @@ static const std::vector OPTION_DEFINITIONS = { .type = OptionType::Text, .game_name = "Sound Voltex", .category = "Game Options", + .picker = OptionPickerType::AsioDriver, }, { // spice2x_SDVXSubPos @@ -1284,6 +1290,7 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "Sets a custom path to the modules folder.", .type = OptionType::Text, .category = "Path Overrides", + // intentionally not setting a folder picker here to discourage people setting this without a good reason }, { .title = "Screenshot Folder Override", @@ -1291,6 +1298,7 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "Sets a custom path to the screenshots folder.", .type = OptionType::Text, .category = "Path Overrides", + .picker = OptionPickerType::DirectoryPath, }, { .title = "Configuration Path Override", @@ -1310,6 +1318,8 @@ static const std::vector OPTION_DEFINITIONS = { "If left empty, %appdata%\\spice2x\\spicetools_screen_resize.json will be used.", .type = OptionType::Text, .category = "Path Overrides", + .picker = OptionPickerType::FilePath, + .file_extension = "JSON", }, { // PatchManagerConfigPath @@ -1319,6 +1329,8 @@ static const std::vector OPTION_DEFINITIONS = { "If left empty, %appdata%\\spice2x\\spicetools_patch_manager.json will be used.", .type = OptionType::Text, .category = "Path Overrides", + .picker = OptionPickerType::FilePath, + .file_extension = "JSON", }, { .title = "Intel SDE", @@ -1326,6 +1338,7 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "Path to Intel SDE kit path for automatic attaching.", .type = OptionType::Text, .category = "Development", + .picker = OptionPickerType::DirectoryPath }, { .title = "ea3-config.xml Override", @@ -1333,6 +1346,8 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "Sets a custom path to ea3-config.xml.", .type = OptionType::Text, .category = "Path Overrides", + .picker = OptionPickerType::FilePath, + .file_extension = "XML", }, { .title = "app-config.xml Override", @@ -1340,6 +1355,8 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "Sets a custom path to app-config.xml.", .type = OptionType::Text, .category = "Path Overrides", + .picker = OptionPickerType::FilePath, + .file_extension = "XML", }, { .title = "avs-config.xml Override", @@ -1347,6 +1364,8 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "Sets a custom path to avs-config.xml.", .type = OptionType::Text, .category = "Path Overrides", + .picker = OptionPickerType::FilePath, + .file_extension = "XML", }, { .title = "bootstrap.xml Override", @@ -1354,6 +1373,8 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "Sets a custom path to bootstrap.xml.", .type = OptionType::Text, .category = "Path Overrides", + .picker = OptionPickerType::FilePath, + .file_extension = "XML", }, { .title = "log.txt Override", @@ -1361,6 +1382,8 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "Sets a custom path to log.txt.", .type = OptionType::Text, .category = "Path Overrides", + .picker = OptionPickerType::FilePath, + .file_extension = "TXT", }, { .title = "API TCP Port", @@ -1670,6 +1693,7 @@ static const std::vector OPTION_DEFINITIONS = { "Must provide a hexadecimal mask (e.g., 0x1ff00).", .type = OptionType::Hex, .category = "Performance", + .picker = OptionPickerType::CpuAffinity, }, { // spice2x_ProcessorEfficiencyClass @@ -1774,6 +1798,7 @@ static const std::vector OPTION_DEFINITIONS = { "Does nothing for games that do not output to exclusive WASAPI.", .type = OptionType::Text, .category = "Audio", + .picker = OptionPickerType::AsioDriver, }, { .title = "WASAPI Dummy Context", @@ -2607,6 +2632,7 @@ static const std::vector OPTION_DEFINITIONS = { .type = OptionType::Text, .game_name = "LovePlus", .category = "Game Options (Advanced)", + .picker = OptionPickerType::DirectoryPath, }, { .title = "LovePlus Printer Output Clear", diff --git a/src/spice2x/overlay/imgui/extensions.cpp b/src/spice2x/overlay/imgui/extensions.cpp index 341e939..57da808 100644 --- a/src/spice2x/overlay/imgui/extensions.cpp +++ b/src/spice2x/overlay/imgui/extensions.cpp @@ -142,7 +142,6 @@ namespace ImGui { ImGui::PopStyleVar(); ImGui::PopStyleColor(); if (!tooltip.empty() && ImGui::IsItemHovered(TOOLTIP_FLAGS)) { - ImGui::SameLine(); ImGui::HelpTooltip(tooltip.c_str()); } ImGui::PopID(); @@ -159,7 +158,6 @@ namespace ImGui { bool clicked = ImGui::Button("\u00D7"); // multiplication sign (×) ImGui::PopStyleColor(4); if (!tooltip.empty() && ImGui::IsItemHovered(TOOLTIP_FLAGS)) { - ImGui::SameLine(); ImGui::HelpTooltip(tooltip.c_str()); } ImGui::PopID(); diff --git a/src/spice2x/overlay/imgui/impl_spice.cpp b/src/spice2x/overlay/imgui/impl_spice.cpp index a09c384..09e307b 100644 --- a/src/spice2x/overlay/imgui/impl_spice.cpp +++ b/src/spice2x/overlay/imgui/impl_spice.cpp @@ -283,7 +283,7 @@ void ImGui_ImplSpice_NewFrame() { memset(io.MouseDown, false, sizeof(io.MouseDown)); // early quit if window not in focus - if (!superexit::has_focus()) { + if (!superexit::has_focus() || rawinput::OS_WINDOW_ACTIVE) { return; } diff --git a/src/spice2x/overlay/windows/config.cpp b/src/spice2x/overlay/windows/config.cpp index d30924b..084caba 100644 --- a/src/spice2x/overlay/windows/config.cpp +++ b/src/spice2x/overlay/windows/config.cpp @@ -4,13 +4,16 @@ #include #include +#include #include #include "build/defs.h" #include "build/resource.h" #include "cfg/config.h" #include "cfg/configurator.h" +#include "external/asio/asiolist.h" #include "external/imgui/imgui_internal.h" +#include "external/imgui/misc/cpp/imgui_stdlib.h" #include "games/io.h" #include "avs/core.h" #include "avs/ea3.h" @@ -26,6 +29,7 @@ #include "util/fileutils.h" #include "util/logging.h" #include "util/resutils.h" +#include "util/scope_guard.h" #include "util/time.h" #include "util/utils.h" @@ -33,6 +37,17 @@ #undef min #endif +static int CALLBACK BrowseCallbackProc( + HWND hwnd, + UINT uMsg, + LPARAM lParam, + LPARAM lpData) { + if (uMsg == BFFM_INITIALIZED) { + SendMessageW(hwnd, BFFM_SETSELECTIONW, TRUE, lpData); + } + return 0; +} + namespace overlay::windows { // same width as dummy marker @@ -40,6 +55,8 @@ namespace overlay::windows { const auto PROJECT_URL = "https://spice2x.github.io"; constexpr ImVec4 TEXT_COLOR_GREEN(0.f, 1.f, 0.f, 1.f); + + std::unique_ptr asio_driver_list; Config::Config(overlay::SpiceOverlay *overlay) : Window(overlay) { this->title = "Configuration"; @@ -2707,17 +2724,17 @@ namespace overlay::windows { // open dialog to get path auto ofn_path = std::make_unique(512); + ofn_path[0] = L'\0'; OPENFILENAMEW ofn {}; - memset(&ofn, 0, sizeof(ofn)); ofn.lStructSize = sizeof(ofn); - ofn.hwndOwner = nullptr; - ofn.lpstrFilter = L""; ofn.lpstrFile = ofn_path.get(); ofn.nMaxFile = 512; - ofn.Flags = OFN_EXPLORER; + ofn.Flags = OFN_EXPLORER | OFN_NOCHANGEDIR; ofn.lpstrDefExt = L"txt"; + ofn.lpstrInitialDir = L"."; // check for success + auto guard = rawinput::set_os_window_focus_guard(); if (GetSaveFileNameW(&ofn)) { // update card path @@ -2905,6 +2922,246 @@ namespace overlay::windows { ImGui::EndDisabled(); } + std::string Config::build_option_value_picker_title(const OptionDefinition& definition) { + // need to make these all unique since they are also used as ID + // if not unique, append ## per ImGui rules to create unique ones + switch (definition.picker) { + case OptionPickerType::AsioDriver: + return "ASIO Driver Picker"; + case OptionPickerType::EACard: + return "EA Card Picker"; + case OptionPickerType::CpuAffinity: + return "CPU Affinity Picker"; + case OptionPickerType::FilePath: + return "File Picker"; + case OptionPickerType::DirectoryPath: + return "Folder Picker"; + default: + return "Unknown Picker"; + }; + } + + void Config::build_option_value_picker(Option& option) { + auto &definition = option.get_definition(); + if (definition.picker == OptionPickerType::AsioDriver) { + if (asio_driver_list == nullptr) { + asio_driver_list = std::make_unique(); + } + + if (asio_driver_list->driver_list.empty()) { + ImGui::TextUnformatted("No ASIO drivers found."); + } else { + ImGui::TextUnformatted("Pick from ASIO drivers:"); + ImGui::SetNextItemWidth(300.f); + if (ImGui::BeginListBox("##asiodrivers")) { + for (const auto &driver : asio_driver_list->driver_list) { + const bool is_selected = option.value == std::string(driver.name); + if (ImGui::Selectable(fmt::format("[{}] {}", driver.id, driver.name).c_str(), is_selected)) { + option.value = driver.name; + } + } + ImGui::EndListBox(); + } + } + } else if (definition.picker == OptionPickerType::EACard) { + ImGui::TextUnformatted("Generate a new card number:"); + if (ImGui::Button("Generate")) { + char new_card[17]; + generate_ea_card(new_card); + option.value = new_card; + } + } else if (definition.picker == OptionPickerType::CpuAffinity) { + ImGui::TextUnformatted("Requires restart! Showing all procs in Group 0."); + ImGui::TextUnformatted(""); + ImGui::TextUnformatted("Pick CPU cores to use:"); + const uint64_t cpu_count = GetActiveProcessorCount(0); + uint64_t affinity = 0; + if (!option.value.empty()) { + try { + affinity = std::stoull(option.value, nullptr, 16); + } catch (const std::exception &ex) { + option.value = ""; + affinity = 0; + } + } + ImGui::BeginChild( + "##cpuaffinity", + ImVec2( + 0, + ImGui::GetFrameHeightWithSpacing() * std::clamp(cpu_count / 4, 2ull, 4ull)) + ); + + bool selection_changed = false; + uint64_t set_bits = 0; + for (uint64_t i = 0; i < cpu_count; i++) { + if (i % 4 != 0) { + ImGui::SameLine(); + } + + bool selected = (affinity & (1ULL << i)) != 0; + if (ImGui::Checkbox(fmt::format("CPU {}", i).c_str(), &selected)) { + selection_changed = true; + if (selected) { + affinity |= (1ULL << i); + } else { + affinity &= ~(1ULL << i); + } + } + if (selected) { + set_bits++; + } + } + if (selection_changed) { + if (set_bits == 0) { + option.value = ""; + } else { + option.value = fmt::format("0x{:X}", affinity); + } + } + + ImGui::EndChild(); + if (option.value.empty()) { + ImGui::TextUnformatted("Using all CPUs (option default)."); + } else { + ImGui::TextUnformatted("Using selected CPUs."); + } + } else if (definition.picker == OptionPickerType::FilePath) { + if (!cfg::CONFIGURATOR_STANDALONE) { + ImGui::BeginDisabled(); + ImGui::TextUnformatted("File browser only works in spicecfg."); + } + if (ImGui::Button("Select File...")) { + // run in separate thread otherwise we get a crash + if (!file_picker_thread) { + file_picker_done = false; + file_picker_path = ""; + file_picker_thread = new std::thread([this, &definition] { + + std::wstring extensions; + if (!definition.file_extension.empty()) { + const std::wstring ext = s2ws(definition.file_extension); + // filter to file extension preferred by the option (e.g., DLL) + extensions = ext + L" Files (*." + ext + L")"; + extensions.push_back(L'\0'); + extensions += L"*." + ext; + extensions.push_back(L'\0'); + // also add "All files" filter + extensions += L"All Files (*.*)"; + extensions.push_back(L'\0'); + extensions += L"*.*"; + extensions.push_back(L'\0'); + // eol + extensions.push_back(L'\0'); + } else { + extensions = L"All Files (*.*)"; + extensions.push_back(L'\0'); + extensions += L"*.*"; + extensions.push_back(L'\0'); + extensions.push_back(L'\0'); + } + + // open dialog to get path + auto ofn_path = std::make_unique(512); + ofn_path[0] = L'\0'; + OPENFILENAMEW ofn{}; + ofn.lStructSize = sizeof(ofn); + ofn.lpstrFilter = extensions.c_str(); + ofn.lpstrFile = ofn_path.get(); + ofn.nMaxFile = 512; + ofn.nFilterIndex = 1; + ofn.Flags = OFN_EXPLORER | OFN_NOCHANGEDIR; + ofn.lpstrInitialDir = L"."; + + // check for success + auto guard = rawinput::set_os_window_focus_guard(); + if (GetSaveFileNameW(&ofn)) { + file_picker_path = std::filesystem::path(ofn_path.get()); + } + + // clean up + file_picker_done = true; + }); + } + } + + if (file_picker_done) { + file_picker_done = false; + file_picker_thread->join(); + delete file_picker_thread; + file_picker_thread = nullptr; + if (!file_picker_path.empty()) { + option.value = file_picker_path.string(); + } + } + + if (!cfg::CONFIGURATOR_STANDALONE) { + ImGui::EndDisabled(); + ImGui::TextUnformatted("File browser only works in spicecfg."); + } + } else if (definition.picker == OptionPickerType::DirectoryPath) { + if (!cfg::CONFIGURATOR_STANDALONE) { + ImGui::BeginDisabled(); + ImGui::TextUnformatted("File browser only works in spicecfg."); + } + if (ImGui::Button("Browse...")) { + // run in separate thread otherwise we get a crash + if (!file_picker_thread) { + file_picker_done = false; + file_picker_path = ""; + file_picker_thread = new std::thread([this] { + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + const auto spice_bin_path = + libutils::module_file_name(nullptr).parent_path().wstring(); + + // SHBrowseForFolderW sucks, but the alternatives are: + // 1. Use IFileDialog, which requires pulling in more Windows dependencies + // 2. use ImGui::FileBrowser + // both are acceptable but sticking to legacy UI for now for simplicity + BROWSEINFOW info{}; + info.ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE; + info.lpfn = BrowseCallbackProc; + info.lParam = reinterpret_cast(spice_bin_path.c_str()); + auto guard = rawinput::set_os_window_focus_guard(); + auto pidl = SHBrowseForFolderW(&info); + if (pidl) { + wchar_t path[MAX_PATH]; + std::filesystem::path result; + if (SHGetPathFromIDListW(pidl, path)) { + file_picker_path = path; + } + CoTaskMemFree(pidl); + } else { + file_picker_path = ""; + } + + // clean up + file_picker_done = true; + CoUninitialize(); + }); + } + } + + if (file_picker_done) { + file_picker_done = false; + file_picker_thread->join(); + delete file_picker_thread; + file_picker_thread = nullptr; + if (!file_picker_path.empty()) { + option.value = file_picker_path.string(); + } + } + + if (!cfg::CONFIGURATOR_STANDALONE) { + ImGui::EndDisabled(); + ImGui::TextUnformatted("File browser only works in spicecfg."); + } + + } else { + ImGui::TextUnformatted("No picker available for this option. How did you get here?"); + } + } + void Config::build_options( std::vector