diff --git a/CMakeLists.txt b/CMakeLists.txt index 74e27bf..a863860 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -411,9 +411,22 @@ if(RETCOMM_HAVE_SDL3 AND RETCOMM_IMGUI_READY) endif() # A core running in the hub's window (Retro-Runtime docs/CORE_LINK.md), on # every OS the link has a transport for: Linux, macOS, Windows. - target_sources(retro-hub PRIVATE src/hub/hub_play.cpp) + target_sources(retro-hub PRIVATE src/hub/hub_play.cpp src/hub/hub_core_settings.cpp) target_link_libraries(retro-hub PRIVATE retro_corelink) target_compile_definitions(retro-hub PRIVATE RETCOMM_HUB_HAVE_PLAY=1) + # The pieces of it that need no window: --describe parsing, bindings and + # option files, and the pad table they produce. + include(CTest) + if(BUILD_TESTING) + add_executable(retro-hub-settings-test tests/hub_core_settings_test.cpp + src/hub/hub_core_settings.cpp) + target_include_directories(retro-hub-settings-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(retro-hub-settings-test PRIVATE retcomm_core retro_corelink + ${RETCOMM_HUB_SDL_TARGET}) + add_test(NAME hub_core_settings COMMAND retro-hub-settings-test + ${CMAKE_CURRENT_BINARY_DIR}/hub_core_settings_test.d) + endif() install(TARGETS retro-hub RUNTIME DESTINATION bin) # The hub's old name, retcomm-hub (until 2026-09-25), keeps working: # shortcuts, Steam entries and older self-updaters relaunch it by name. diff --git a/README.md b/README.md index adc5f21..59afe3e 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,9 @@ cp config.example.json ~/.config/retcomm/config.json To run one core directly in the hub's window, without the library (what a port's dev build and a tool use), use Direct mode: `retro-hub --run-core [--package ] --rom [--title-dir ]`. -Flags, files and runner lookup: [`docs/RELEASES.md`](docs/RELEASES.md#direct-mode). +It opens on the title's home page (Play, n64lle Settings, Mods); add +`--boot` to play at once. Flags, files and runner lookup: +[`docs/RELEASES.md`](docs/RELEASES.md#direct-mode). `launch` stages disc/ROM/BIOS sidecars next to the install and prefers a companion `.cue` for disc titles. Installs land under diff --git a/assets/controllers/pad_n64.png b/assets/controllers/pad_n64.png new file mode 100644 index 0000000..fc5f138 Binary files /dev/null and b/assets/controllers/pad_n64.png differ diff --git a/assets/src/pad_n64.svg b/assets/src/pad_n64.svg new file mode 100644 index 0000000..535091b --- /dev/null +++ b/assets/src/pad_n64.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/RELEASES.md b/docs/RELEASES.md index c7b1ae3..a642118 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -31,11 +31,15 @@ Retro-Runtime's archives do. ```sh retro-hub --run-core [--package ] --rom [--title-dir ] \ - [--tpak1-rom ] [--tpak1-save ] [--no-gl] [--opt key=value]... + [--tpak1-rom ] [--tpak1-save ] [--no-gl] [--opt key=value]... [--boot] ``` -Direct mode boots straight into the core in the hub's window and exits when the -player closes it; no library pages, no setup wizard. It is built on every OS. +Direct mode runs one title in the hub's window; no library pages, no setup +wizard. It is built on every OS. Since revision 3 it opens on the title's +**home page**: Play, the core's settings page (n64lle Settings), Mods and Quit. Closing the game +returns to the page, and Quit leaves the app. `--boot` skips the page, plays at +once and exits when the player closes the game (what every revision before 3 +did). The session itself (menu, input, fault screen) is described in Retro-Runtime's [`docs/CORE_LINK.md`](https://github.com/RetroPortingToolKit/Retro-Runtime/blob/main/docs/CORE_LINK.md), "Direct mode". @@ -47,7 +51,8 @@ The session itself (menu, input, fault screen) is described in Retro-Runtime's | `--title-dir ` | 1 | The directory holding the title's `game.toml`. Default: the shim's directory with `--package`, else the core's. | | `--tpak1-rom`, `--tpak1-save` | 1 | Transfer Pak cartridge and its save, port 1. | | `--no-gl` | 1 | Do not lend the core GL. | -| `--opt key=value` | 1 | A core option; repeatable. | +| `--opt key=value` | 1 | A core option; repeatable. Wins over a value stored by Core Settings. | +| `--boot` | 3 | Play at once and exit with the game, skipping the home page. | **Files.** Session logs go to `/sessions//` and saves to `/saves//`, where `` is the title's: the shim's file stem @@ -55,6 +60,25 @@ with `--package` (`pokemonstadium_game`), the core's otherwise (`pokemonstadium_core`). A generic core is shared by every packaged title, so it never names one. +The settings page -- the same one the library opens from an N64 platform +header's **n64lle Config** -- writes these, and every play path reads them +(`src/hub/hub_core_settings.hpp`): + +| File | Scope | +|---|---| +| `/platform//input.ini` | the four controller seats (device and maps) and the stick deadzone, every title of the platform (the core's `.rcore.toml` `platforms`) | +| `/platform//options.ini` | option values for every title | +| `/platform//options/.ini` | one title's overrides; `--opt` wins over both | +| `/platform//core_description.txt` | the last `--describe`, so the page can label things with no core at hand | + +The page is built from what the core declares, asked of the runner with +`retro-core-runner --describe` (Retro-Runtime `docs/CORE_RUNNER.md`), never +from a list compiled into the hub. A runner without `describe 1` in its +`--version` still plays; the page then uses the cached description, or says +the core's settings are unavailable and labels the pad chips generically. Mods reads the title dir's `mods/` tree, in either layout +`include/retcomm/mods.hpp` names (n64lle writes its selection to +`/mods.toml`). + **The runner.** Direct mode finds `retro-core-runner` the way the launcher does (`resolve_runner`, `src/update/runtime_update.cpp`), in this order: @@ -136,8 +160,8 @@ Its shape follows Retro-Runtime's `runtime-manifest.json`: "link_protocol": { "major": 1, "minor": 0 }, "rcore_abi": { "major": 0, "draft_revision": 5 }, "direct_mode": { - "cli_revision": 2, - "flags": ["--run-core", "--package", "--rom", "--title-dir", "--tpak1-rom", "--tpak1-save", "--no-gl", "--opt"], + "cli_revision": 3, + "flags": ["--run-core", "--package", "--rom", "--title-dir", "--tpak1-rom", "--tpak1-save", "--no-gl", "--opt", "--boot"], "runner_lookup": "RETRO_CORE_RUNNER exe_dir/retro-core-runner data_dir/runtime/<version>/retro-core-runner" }, "platforms": { @@ -176,6 +200,9 @@ the bundled SDL3. flags it passes, e.g. 2 for `--package`. Each revision still accepts every earlier revision's command line; a change that breaks that will be called out here. +- **Revision 3 changed what an earlier command line does:** it is still + accepted, but it opens the home page instead of playing at once. A tool that + needs the old behaviour passes `--boot`, and so needs revision 3. ## `retro-hub --version` @@ -188,8 +215,8 @@ commit 8f538f7218855e94cdcba437a23245a4e1be22f8 link_protocol 1.0 rcore_abi_major 0 rcore_draft_revision 5 -direct_mode 2 -direct_mode_flags --run-core --package --rom --title-dir --tpak1-rom --tpak1-save --no-gl --opt +direct_mode 3 +direct_mode_flags --run-core --package --rom --title-dir --tpak1-rom --tpak1-save --no-gl --opt --boot runner_lookup RETRO_CORE_RUNNER exe_dir/retro-core-runner data_dir/runtime/<version>/retro-core-runner ``` diff --git a/include/retcomm/mods.hpp b/include/retcomm/mods.hpp index ebc67d2..84fd8ef 100644 --- a/include/retcomm/mods.hpp +++ b/include/retcomm/mods.hpp @@ -88,7 +88,19 @@ struct ModPackageInfo { bool has_features() const { return !features.empty(); } }; +// Which engine's layout a mods tree is in. They differ in where manifests sit, +// what a manifest says, and which file holds the selection, so a write in the +// wrong one is a write nothing reads. +// +// Engine psxrecomp / snesrecomp: mods/<origin>/<id>/<version>/manifest.toml, +// [[feature]] arrays, selection in mods/state.toml +// N64lle n64lle guarded-write packages (n64lle docs/MODDING.md §5): +// mods/<origin>/<id>/manifest.toml with [target] rom_sha256 and +// [feature.<id>] tables, selection in <game>/mods.toml +enum class ModLayout { Engine, N64lle }; + struct ModScanResult { + ModLayout layout = ModLayout::Engine; std::vector<ModPackageInfo> packages; // Manifests that exist but could not be read. Surfaced rather than // dropped: a mod that silently fails to appear is a support question. @@ -133,5 +145,16 @@ ModScanResult scan_game_mods(const fs::path& game_dir, const fs::path& install_r const char* mod_origin_name(ModOrigin origin); +// The writes a mods page makes, in whichever layout `scan` found: one feature +// (feature_id empty = an all-or-nothing package), or every feature of `pkg`. +// In the n64lle layout a package's mods.toml section lists exactly the features +// that are on, so both compute that list from what the scan saw and write it +// whole -- the first write turns "manifest defaults" into an explicit list. +bool set_scanned_mod_enabled(const ModScanResult& scan, const fs::path& game_dir, + const ModPackageInfo& pkg, const std::string& feature_id, + bool enabled, std::string* error = nullptr); +bool set_scanned_mod_all(const ModScanResult& scan, const fs::path& game_dir, + const ModPackageInfo& pkg, bool enabled, std::string* error = nullptr); + } // namespace retcomm diff --git a/src/core/mods.cpp b/src/core/mods.cpp index d9a3ed4..6dc5d1b 100644 --- a/src/core/mods.cpp +++ b/src/core/mods.cpp @@ -633,11 +633,245 @@ void find_unstaged(const fs::path& install_root, const fs::path& game_root, ModS } } +// ---- n64lle guarded-write packages ------------------------------------------ +// +// Read to show and toggle them, never to apply them: the resolver that decides +// what actually lands (guards, contested bytes, the target image) is n64lle's +// (crates/n64lle-host mod_catalog / mod_plan), and a refusal is reported by the +// game at boot. What this reader mirrors is only what a page must not get +// wrong: which packages the game will see, and what mods.toml means. + +std::string unquote_key(const std::string& k) { + std::string t = trim(k); + if (t.size() >= 2 && (t.front() == '"' || t.front() == '\'') && t.back() == t.front()) + t = t.substr(1, t.size() - 2); + return t; +} + +// mods/<origin>/<id>/manifest.toml whose document has a [target] rom_sha256: +// the shape only an n64lle package has. +bool is_n64lle_manifest(const fs::path& manifest) { + bool target_sha = false; + for_each_toml_pair(read_file(manifest), [&](const std::string& section, bool aot, + const std::string& key, const std::string&) { + if (!aot && section == "target" && key == "rom_sha256") target_sha = true; + }); + return target_sha; +} + +bool read_n64lle_manifest(const fs::path& path, ModPackageInfo& out, std::string* error) { + const std::string body = read_file(path); + if (body.empty()) { + if (error) *error = path.string() + ": unreadable or empty"; + return false; + } + std::string game_id; + std::vector<std::pair<std::string, int>> writes; // feature -> guarded writes + std::string patch_feature; + auto count_patch = [&] { + if (patch_feature.empty()) return; + for (auto& w : writes) + if (w.first == patch_feature) { ++w.second; patch_feature.clear(); return; } + writes.emplace_back(patch_feature, 1); + patch_feature.clear(); + }; + for_each_toml_pair(body, [&](const std::string& section, bool, const std::string& key, + const std::string& val) { + if (key.empty()) { + count_patch(); + if (section.rfind("feature.", 0) == 0) { + ModFeatureInfo f; + f.id = section.substr(8); + f.name = f.id; // n64lle manifests carry no display names + out.features.push_back(std::move(f)); + } + return; + } + if (section.empty()) { + if (key == "id") out.id = unquote(val); + else if (key == "version") out.version = unquote(val); + } else if (section == "target" && key == "game_id") { + game_id = unquote(val); + } else if (section.rfind("feature.", 0) == 0 && key == "default_enabled" && + !out.features.empty()) { + out.features.back().default_enabled = truthy(val); + } else if (section.rfind("patch.", 0) == 0 && key == "feature") { + patch_feature = unquote(val); + } + }); + count_patch(); + if (out.id.empty()) { + if (error) *error = path.string() + ": no id"; + return false; + } + // mod_catalog.rs: a directory whose name is not its manifest's id is refused. + if (path.parent_path().filename().string() != out.id) { + if (error) + *error = path.string() + ": id '" + out.id + "' is not its directory's name '" + + path.parent_path().filename().string() + "'; the game refuses it"; + return false; + } + out.name = out.id; + if (!game_id.empty()) out.description = "For " + game_id + "."; + for (ModFeatureInfo& f : out.features) { + int n = 0; + for (const auto& w : writes) + if (w.first == f.id) n = w.second; + f.description = std::to_string(n) + " guarded write" + (n == 1 ? "" : "s") + "."; + f.group = out.id; // no groups in this format: the page heads each by its package + f.enabled = f.default_enabled; + } + return true; +} + +void scan_n64lle_origin(const fs::path& root, ModOrigin origin, ModScanResult& out) { + std::error_code ec; + if (!fs::is_directory(root, ec)) return; + for (auto it = fs::directory_iterator(root, ec); !ec && it != fs::directory_iterator(); + it.increment(ec)) { + const fs::path manifest = it->path() / "manifest.toml"; + if (!it->is_directory(ec) || !fs::is_regular_file(manifest, ec)) continue; + ModPackageInfo pkg; + pkg.origin = origin; + pkg.manifest = manifest; + std::string err; + if (read_n64lle_manifest(manifest, pkg, &err)) out.packages.push_back(std::move(pkg)); + else out.errors.push_back(err); + } +} + +// mods.toml (MODDING.md §5.3): a package with a section runs exactly the +// features its `enabled` lists; one without runs its manifest defaults. +void apply_n64lle_selection(const fs::path& path, ModScanResult& r) { + std::error_code ec; + if (!fs::is_regular_file(path, ec)) return; + const std::string body = read_file(path); + std::vector<std::pair<std::string, std::string>> sel; // package -> enabled list + for_each_toml_pair(body, [&](const std::string& section, bool, const std::string& key, + const std::string& val) { + const std::string pkg = unquote_key(section); + if (key.empty()) { + if (!pkg.empty()) sel.emplace_back(pkg, std::string()); + return; + } + if (key == "enabled" && !sel.empty() && sel.back().first == pkg) + sel.back().second = unquote(val); + }); + for (ModPackageInfo& p : r.packages) { + for (const auto& [id, list] : sel) { + if (id != p.id) continue; + std::istringstream words(list); + std::vector<std::string> on; + for (std::string w; words >> w;) on.push_back(w); + for (ModFeatureInfo& f : p.features) + f.enabled = std::find(on.begin(), on.end(), f.id) != on.end(); + } + } +} + +// Rewrites (or appends) the package's section so `enabled` lists exactly +// `on`. Every other line of mods.toml stays as it was. +bool write_n64lle_selection(const fs::path& game_dir, const ModPackageInfo& pkg, + const std::vector<std::string>& on, std::string* error) { + const fs::path path = game_dir / "mods.toml"; + std::string list; + for (const std::string& f : on) list += (list.empty() ? "" : " ") + f; + const std::string enabled_line = "enabled = \"" + list + "\""; + + std::vector<std::string> lines = split_lines(read_file(path)); + long header = -1, end = static_cast<long>(lines.size()); + for (size_t i = 0; i < lines.size(); ++i) { + const std::string t = trim(lines[i]); + if (t.empty() || t[0] != '[') continue; + if (header >= 0) { end = static_cast<long>(i); break; } + const size_t close = t.find(']'); + if (close != std::string::npos && unquote_key(t.substr(1, close - 1)) == pkg.id) + header = static_cast<long>(i); + } + if (header < 0) { + if (lines.empty()) { + lines.push_back("# Which mod features are on (n64lle docs/MODDING.md 5.3)."); + lines.push_back("# Written by the launcher's Mods page; a package with no"); + lines.push_back("# section runs its manifest's defaults."); + } + lines.push_back(""); + // Bare, dots and all, as n64lle's own writer emits it + // (mod_selection.rs): its reader takes the header text whole, so a + // quoted ["a.b"] would name a package called "\"a.b\"". + lines.push_back("[" + pkg.id + "]"); + lines.push_back("version = \"" + pkg.version + "\""); + lines.push_back(enabled_line); + } else { + bool replaced = false; + for (long i = header + 1; i < end; ++i) { + const std::string t = trim(lines[static_cast<size_t>(i)]); + if (t.rfind("enabled", 0) == 0 && t.find('=') != std::string::npos) { + lines[static_cast<size_t>(i)] = enabled_line; + replaced = true; + break; + } + } + if (!replaced) lines.insert(lines.begin() + header + 1, enabled_line); + } + std::string out; + for (const std::string& l : lines) out += l + "\n"; + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f || !(f << out)) { + if (error) *error = path.string() + ": cannot write"; + return false; + } + return true; +} + +ModScanResult scan_n64lle_mods(const fs::path& game_dir) { + ModScanResult r; + r.layout = ModLayout::N64lle; + r.root = game_dir / "mods"; + std::error_code ec; + r.root_exists = fs::is_directory(r.root, ec); + scan_n64lle_origin(r.root / "bundled", ModOrigin::Bundled, r); + scan_n64lle_origin(r.root / "installed", ModOrigin::Installed, r); + // mod_catalog.rs: the same id in both roots refuses both; neither shadows + // the other. Shown as an error, not as two rows the game will not run. + std::vector<ModPackageInfo> kept; + for (const ModPackageInfo& p : r.packages) { + const auto n = std::count_if(r.packages.begin(), r.packages.end(), + [&](const ModPackageInfo& q) { return q.id == p.id; }); + if (n == 1) kept.push_back(p); + else if (p.origin == ModOrigin::Bundled) + r.errors.push_back(p.id + " is in both mods/bundled and mods/installed; the game " + "refuses both until one is removed"); + } + r.packages = std::move(kept); + apply_n64lle_selection(game_dir / "mods.toml", r); + std::sort(r.packages.begin(), r.packages.end(), + [](const ModPackageInfo& a, const ModPackageInfo& b) { return a.id < b.id; }); + return r; +} + +// Which layout a game's mods tree is in: n64lle when mods.toml is there or any +// package directly under an origin has an n64lle manifest. +bool is_n64lle_tree(const fs::path& game_dir) { + std::error_code ec; + if (fs::is_regular_file(game_dir / "mods.toml", ec)) return true; + for (const char* origin : {"bundled", "installed"}) { + const fs::path root = game_dir / "mods" / origin; + if (!fs::is_directory(root, ec)) continue; + for (auto it = fs::directory_iterator(root, ec); !ec && it != fs::directory_iterator(); + it.increment(ec)) { + const fs::path m = it->path() / "manifest.toml"; + if (fs::is_regular_file(m, ec) && is_n64lle_manifest(m)) return true; + } + } + return false; +} + } // namespace ModScanResult scan_game_mods(const fs::path& game_dir, const fs::path& install_root) { ModScanResult r; if (game_dir.empty()) return r; + if (is_n64lle_tree(game_dir)) return scan_n64lle_mods(game_dir); r.root = game_dir / "mods"; std::error_code ec; r.root_exists = fs::is_directory(r.root, ec); @@ -669,4 +903,30 @@ ModScanResult scan_game_mods(const fs::path& game_dir, const fs::path& install_r return r; } +bool set_scanned_mod_enabled(const ModScanResult& scan, const fs::path& game_dir, + const ModPackageInfo& pkg, const std::string& feature_id, + bool enabled, std::string* error) { + if (scan.layout == ModLayout::Engine) + return set_mod_enabled(game_dir, pkg.id, feature_id, enabled, error); + std::vector<std::string> on; + for (const ModFeatureInfo& f : pkg.features) + if (f.id == feature_id ? enabled : f.enabled) on.push_back(f.id); + return write_n64lle_selection(game_dir, pkg, on, error); +} + +bool set_scanned_mod_all(const ModScanResult& scan, const fs::path& game_dir, + const ModPackageInfo& pkg, bool enabled, std::string* error) { + if (scan.layout == ModLayout::Engine) { + if (!pkg.has_features()) return set_mod_enabled(game_dir, pkg.id, {}, enabled, error); + bool ok = true; + for (const ModFeatureInfo& f : pkg.features) + ok &= set_mod_enabled(game_dir, pkg.id, f.id, enabled, error); + return ok; + } + std::vector<std::string> on; + if (enabled) + for (const ModFeatureInfo& f : pkg.features) on.push_back(f.id); + return write_n64lle_selection(game_dir, pkg, on, error); +} + } // namespace retcomm diff --git a/src/hub/hub_core_settings.cpp b/src/hub/hub_core_settings.cpp new file mode 100644 index 0000000..bb9c7d8 --- /dev/null +++ b/src/hub/hub_core_settings.cpp @@ -0,0 +1,789 @@ +#include "hub/hub_core_settings.hpp" + +#include "transport.hpp" // Retro-Runtime corelink: run_to_completion, path_utf8 + +#include <algorithm> +#include <chrono> +#include <fstream> +#include <random> +#include <sstream> + +namespace retcomm::hub { + +namespace { + +namespace corelink = ::retro::corelink; + +// Pressed, for a button target driven by an axis: the threshold the hub always +// used for the triggers' digital L2/R2. +constexpr int kAxisPressed = 16384; + +std::string trim(const std::string& s) { + const auto b = s.find_first_not_of(" \t\r\n"); + if (b == std::string::npos) return {}; + return s.substr(b, s.find_last_not_of(" \t\r\n") - b + 1); +} + +// --describe escapes \, TAB, LF and CR inside a field. +std::string unescape_field(const std::string& f) { + std::string out; + out.reserve(f.size()); + for (size_t i = 0; i < f.size(); ++i) { + if (f[i] != '\\' || i + 1 == f.size()) { + out += f[i]; + continue; + } + switch (f[++i]) { + case 't': out += '\t'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case '\\': out += '\\'; break; + default: out += '\\'; out += f[i]; break; + } + } + return out; +} + +std::vector<std::string> split_tabs(const std::string& line) { + std::vector<std::string> out; + size_t start = 0; + for (;;) { + const size_t tab = line.find('\t', start); + out.push_back(unescape_field(line.substr(start, tab - start))); + if (tab == std::string::npos) break; + start = tab + 1; + } + return out; +} + +bool parse_i64(const std::string& s, std::int64_t& v) { + try { + size_t used = 0; + v = std::stoll(s, &used); + return used == s.size(); + } catch (...) { + return false; + } +} + +struct TargetInfo { + const char* key; + const char* generic; + std::uint32_t button; // RCORE_PAD_* bit, or 0 + std::uint32_t axis; // RCORE_AXIS_* + 1, or 0 + std::int32_t dir; // for a half-axis: +1 / -1; a trigger: +1 +}; + +// Indexed by PadTarget. +constexpr TargetInfo kTargets[kPadTargetCount] = { + {"south", "South face", RCORE_PAD_SOUTH, 0, 0}, + {"east", "East face", RCORE_PAD_EAST, 0, 0}, + {"west", "West face", RCORE_PAD_WEST, 0, 0}, + {"north", "North face", RCORE_PAD_NORTH, 0, 0}, + {"l1", "L1", RCORE_PAD_L1, 0, 0}, + {"r1", "R1", RCORE_PAD_R1, 0, 0}, + {"l2", "L2 (digital)", RCORE_PAD_L2, 0, 0}, + {"r2", "R2 (digital)", RCORE_PAD_R2, 0, 0}, + {"l3", "L3", RCORE_PAD_L3, 0, 0}, + {"r3", "R3", RCORE_PAD_R3, 0, 0}, + {"start", "Start", RCORE_PAD_START, 0, 0}, + {"select", "Select", RCORE_PAD_SELECT, 0, 0}, + {"up", "D-pad up", RCORE_PAD_DPAD_UP, 0, 0}, + {"down", "D-pad down", RCORE_PAD_DPAD_DOWN, 0, 0}, + {"left", "D-pad left", RCORE_PAD_DPAD_LEFT, 0, 0}, + {"right", "D-pad right", RCORE_PAD_DPAD_RIGHT, 0, 0}, + {"lx-", "Left stick left", 0, RCORE_AXIS_LX + 1, -1}, + {"lx+", "Left stick right", 0, RCORE_AXIS_LX + 1, +1}, + {"ly-", "Left stick down", 0, RCORE_AXIS_LY + 1, -1}, + {"ly+", "Left stick up", 0, RCORE_AXIS_LY + 1, +1}, + {"rx-", "Right stick left", 0, RCORE_AXIS_RX + 1, -1}, + {"rx+", "Right stick right", 0, RCORE_AXIS_RX + 1, +1}, + {"ry-", "Right stick down", 0, RCORE_AXIS_RY + 1, -1}, + {"ry+", "Right stick up", 0, RCORE_AXIS_RY + 1, +1}, + {"lt", "Left trigger", 0, RCORE_AXIS_LT + 1, +1}, + {"rt", "Right trigger", 0, RCORE_AXIS_RT + 1, +1}, +}; + +const TargetInfo& info(PadTarget t) { return kTargets[static_cast<int>(t)]; } + +// The descriptor that names this target: the same button bit, or the same +// axis and direction. Direction 0 declares the whole axis (n64lle's "Control +// Stick (left/right)"), so it names both halves. +const CoreInputDecl* decl_for(PadTarget t, const CoreDescription& d) { + const TargetInfo& ti = info(t); + for (const CoreInputDecl& in : d.inputs) { + if (ti.button && in.button == ti.button) return ∈ + if (ti.axis && in.button == 0 && in.axis == ti.axis && + (in.axis_direction == ti.dir || in.axis_direction == 0)) + return ∈ + } + return nullptr; +} + +// Which half of a stick axis a target is, for a whole-axis label. +const char* half_word(PadTarget t) { + switch (t) { + case PadTarget::LxMinus: case PadTarget::RxMinus: return "left"; + case PadTarget::LxPlus: case PadTarget::RxPlus: return "right"; + case PadTarget::LyMinus: case PadTarget::RyMinus: return "down"; + case PadTarget::LyPlus: case PadTarget::RyPlus: return "up"; + default: return nullptr; + } +} + +std::string read_text(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +// Write through a sibling temp file and rename, so a crash mid-save leaves the +// previous settings rather than half of the new ones. +bool write_atomically(const fs::path& path, const std::string& body, std::string* error) { + std::error_code ec; + fs::create_directories(path.parent_path(), ec); + if (ec) { + if (error) *error = path.parent_path().string() + ": " + ec.message(); + return false; + } + const fs::path tmp = path.string() + ".tmp"; + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out || !(out << body) || !out.flush()) { + if (error) *error = tmp.string() + ": cannot write"; + return false; + } + } + fs::rename(tmp, path, ec); + if (ec) { + if (error) *error = path.string() + ": " + ec.message(); + fs::remove(tmp, ec); + return false; + } + return true; +} + +// [section] key = value, the subset input.ini and options/*.ini use. +template <typename Fn> +void for_each_ini(const std::string& body, Fn&& fn) { + std::istringstream in(body); + std::string line, section; + while (std::getline(in, line)) { + const std::string s = trim(line); + if (s.empty() || s[0] == '#' || s[0] == ';') continue; + if (s.front() == '[' && s.back() == ']') { + section = trim(s.substr(1, s.size() - 2)); + continue; + } + const auto eq = s.find('='); + if (eq == std::string::npos) continue; + fn(section, trim(s.substr(0, eq)), trim(s.substr(eq + 1))); + } +} + +int source_value(SDL_Gamepad* g, const PadSource& s) { + switch (s.kind) { + case PadSource::Button: + return SDL_GetGamepadButton(g, static_cast<SDL_GamepadButton>(s.code)) ? 32767 : 0; + case PadSource::AxisPlus: + return std::max(0, static_cast<int>( + SDL_GetGamepadAxis(g, static_cast<SDL_GamepadAxis>(s.code)))); + case PadSource::AxisMinus: + return std::max(0, -static_cast<int>( + SDL_GetGamepadAxis(g, static_cast<SDL_GamepadAxis>(s.code)))); + case PadSource::None: + break; + } + return 0; +} + +bool source_pressed(SDL_Gamepad* g, const PadSource& s) { + if (s.kind == PadSource::Button) + return SDL_GetGamepadButton(g, static_cast<SDL_GamepadButton>(s.code)); + return source_value(g, s) > kAxisPressed; +} + +// Target values (0..32768 per half-axis) folded into one rcore_pad. +void fold(rcore_pad& p, const int value[kPadTargetCount], const bool pressed[kPadTargetCount]) { + for (int t = 0; t < kPadButtonTargets; ++t) + if (pressed[t]) p.buttons |= kTargets[t].button; + auto clamp16 = [](int v) { return static_cast<std::int16_t>(std::clamp(v, -32768, 32767)); }; + auto T = [](PadTarget t) { return static_cast<int>(t); }; + p.axes[RCORE_AXIS_LX] = clamp16(value[T(PadTarget::LxPlus)] - value[T(PadTarget::LxMinus)]); + p.axes[RCORE_AXIS_LY] = clamp16(value[T(PadTarget::LyPlus)] - value[T(PadTarget::LyMinus)]); + p.axes[RCORE_AXIS_RX] = clamp16(value[T(PadTarget::RxPlus)] - value[T(PadTarget::RxMinus)]); + p.axes[RCORE_AXIS_RY] = clamp16(value[T(PadTarget::RyPlus)] - value[T(PadTarget::RyMinus)]); + p.axes[RCORE_AXIS_LT] = clamp16(value[T(PadTarget::Lt)]); + p.axes[RCORE_AXIS_RT] = clamp16(value[T(PadTarget::Rt)]); +} + +} // namespace + +// ---- the core's declarations ------------------------------------------------ + +bool parse_core_description(const std::string& text, CoreDescription& out, std::string* error) { + out = CoreDescription{}; + auto fail = [&](const std::string& m) { + if (error) *error = m; + return false; + }; + std::istringstream in(text); + std::string line; + bool header = false; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.find('\t') == std::string::npos) continue; // stderr, not a record + const std::vector<std::string> f = split_tabs(line); + const std::string& kind = f[0]; + if (kind == "describe") { + if (f.size() < 2 || f[1] != "1") + return fail("unknown --describe revision '" + (f.size() > 1 ? f[1] : "") + "'"); + header = true; + } else if (!header) { + continue; + } else if (kind == "core" && f.size() >= 4) { + out.core_id = f[1]; + out.core_version = f[2]; + out.platforms = f[3]; + } else if (kind == "option" && f.size() >= 10) { + CoreOptionDecl o; + o.key = f[1]; + o.type = f[2]; + std::istringstream flags(f[3]); + for (std::string fl; std::getline(flags, fl, ',');) { + if (fl == "restart") o.restart = true; + else if (fl == "netplay") o.netplay = true; + else if (fl == "developer") o.developer = true; + } + o.has_default = f[4] == "1"; + o.default_value = f[5]; + if (!parse_i64(f[6], o.int_min) || !parse_i64(f[7], o.int_max)) + return fail("option " + o.key + ": bad integer range"); + o.label = f[8]; + o.description = f[9]; + out.options.push_back(std::move(o)); + } else if (kind == "value" && f.size() >= 3) { + if (out.options.empty() || out.options.back().key != f[1]) + return fail("value for '" + f[1] + "' does not follow its option"); + out.options.back().values.push_back(f[2]); + } else if (kind == "input" && f.size() >= 5) { + std::int64_t b = 0, a = 0, dir = 0; + if (!parse_i64(f[1], b) || !parse_i64(f[2], a) || !parse_i64(f[3], dir)) + return fail("input '" + f[4] + "': bad number"); + CoreInputDecl d; + d.button = static_cast<std::uint32_t>(b); + d.axis = static_cast<std::uint32_t>(a); + d.axis_direction = static_cast<std::int32_t>(dir); + d.label = f[4]; + out.inputs.push_back(std::move(d)); + } + } + if (!header) return fail("no 'describe' header"); + out.ok = true; + return true; +} + +CoreDescription describe_core(const fs::path& runner, const fs::path& core, + const fs::path& package, int timeout_ms) { + CoreDescription d; + std::error_code ec; + std::mt19937_64 rng(static_cast<std::uint64_t>( + std::chrono::steady_clock::now().time_since_epoch().count())); + const fs::path report = + fs::temp_directory_path(ec) / ("retro-hub-describe-" + std::to_string(rng()) + ".txt"); + corelink::SpawnSpec spec; + spec.args = {corelink::path_utf8(runner), "--describe", "--core", corelink::path_utf8(core)}; + if (!package.empty()) { + spec.args.push_back("--package"); + spec.args.push_back(corelink::path_utf8(package)); + } + spec.log = report; + std::string err; + const auto code = corelink::run_to_completion(spec, timeout_ms, &err); + const std::string text = read_text(report); + fs::remove(report, ec); + if (!code) { + d.error = err; + return d; + } + std::string perr; + if (*code == 0 && parse_core_description(text, d, &perr)) { + d.raw = text; + return d; + } + // The runner's own last words say why: an unknown flag from one that + // predates --describe, or the core's load failure. + std::string last; + std::istringstream in(text); + for (std::string l; std::getline(in, l);) + if (!trim(l).empty()) last = trim(l); + d = CoreDescription{}; + d.error = "retro-core-runner --describe " + + (*code == 0 ? "printed no description (" + perr + ")" + : "exited " + std::to_string(*code)) + + (last.empty() ? std::string() : ": " + last) + + ". A runner that reports 'describe 1' in --version is needed."; + return d; +} + +// ---- input bindings --------------------------------------------------------- + +const char* pad_target_key(PadTarget t) { return info(t).key; } +const char* pad_target_generic_name(PadTarget t) { return info(t).generic; } + +std::string pad_target_label(PadTarget t, const CoreDescription& d) { + const CoreInputDecl* in = decl_for(t, d); + if (!in || in->label.empty()) return info(t).generic; + if (in->axis_direction == 0 && half_word(t)) return in->label + ": " + half_word(t); + return in->label; +} + +bool pad_target_declared(PadTarget t, const CoreDescription& d) { return decl_for(t, d) != nullptr; } + +InputBindings default_input_bindings() { + InputBindings b; + auto P = [&](PadTarget t) -> PadSource& { return b.pad[static_cast<int>(t)]; }; + auto K = [&](PadTarget t) -> SDL_Scancode& { return b.key[static_cast<int>(t)]; }; + auto btn = [](SDL_GamepadButton x) { return PadSource{PadSource::Button, x}; }; + auto plus = [](SDL_GamepadAxis x) { return PadSource{PadSource::AxisPlus, x}; }; + auto minus = [](SDL_GamepadAxis x) { return PadSource{PadSource::AxisMinus, x}; }; + P(PadTarget::South) = btn(SDL_GAMEPAD_BUTTON_SOUTH); + P(PadTarget::East) = btn(SDL_GAMEPAD_BUTTON_EAST); + P(PadTarget::West) = btn(SDL_GAMEPAD_BUTTON_WEST); + P(PadTarget::North) = btn(SDL_GAMEPAD_BUTTON_NORTH); + P(PadTarget::L1) = btn(SDL_GAMEPAD_BUTTON_LEFT_SHOULDER); + P(PadTarget::R1) = btn(SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER); + P(PadTarget::L2) = plus(SDL_GAMEPAD_AXIS_LEFT_TRIGGER); + P(PadTarget::R2) = plus(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); + P(PadTarget::L3) = btn(SDL_GAMEPAD_BUTTON_LEFT_STICK); + P(PadTarget::R3) = btn(SDL_GAMEPAD_BUTTON_RIGHT_STICK); + P(PadTarget::Start) = btn(SDL_GAMEPAD_BUTTON_START); + P(PadTarget::Select) = btn(SDL_GAMEPAD_BUTTON_BACK); + P(PadTarget::DpadUp) = btn(SDL_GAMEPAD_BUTTON_DPAD_UP); + P(PadTarget::DpadDown) = btn(SDL_GAMEPAD_BUTTON_DPAD_DOWN); + P(PadTarget::DpadLeft) = btn(SDL_GAMEPAD_BUTTON_DPAD_LEFT); + P(PadTarget::DpadRight) = btn(SDL_GAMEPAD_BUTTON_DPAD_RIGHT); + // SDL's stick Y is positive down; rcore's is positive up. + P(PadTarget::LxMinus) = minus(SDL_GAMEPAD_AXIS_LEFTX); + P(PadTarget::LxPlus) = plus(SDL_GAMEPAD_AXIS_LEFTX); + P(PadTarget::LyMinus) = plus(SDL_GAMEPAD_AXIS_LEFTY); + P(PadTarget::LyPlus) = minus(SDL_GAMEPAD_AXIS_LEFTY); + P(PadTarget::RxMinus) = minus(SDL_GAMEPAD_AXIS_RIGHTX); + P(PadTarget::RxPlus) = plus(SDL_GAMEPAD_AXIS_RIGHTX); + P(PadTarget::RyMinus) = plus(SDL_GAMEPAD_AXIS_RIGHTY); + P(PadTarget::RyPlus) = minus(SDL_GAMEPAD_AXIS_RIGHTY); + P(PadTarget::Lt) = plus(SDL_GAMEPAD_AXIS_LEFT_TRIGGER); + P(PadTarget::Rt) = plus(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); + + K(PadTarget::South) = SDL_SCANCODE_X; + K(PadTarget::East) = SDL_SCANCODE_C; + K(PadTarget::West) = SDL_SCANCODE_Z; + K(PadTarget::North) = SDL_SCANCODE_S; + K(PadTarget::L1) = SDL_SCANCODE_Q; + K(PadTarget::R1) = SDL_SCANCODE_E; + K(PadTarget::L2) = SDL_SCANCODE_LSHIFT; + K(PadTarget::Start) = SDL_SCANCODE_RETURN; + K(PadTarget::Select) = SDL_SCANCODE_BACKSPACE; + K(PadTarget::DpadUp) = SDL_SCANCODE_UP; + K(PadTarget::DpadDown) = SDL_SCANCODE_DOWN; + K(PadTarget::DpadLeft) = SDL_SCANCODE_LEFT; + K(PadTarget::DpadRight) = SDL_SCANCODE_RIGHT; + K(PadTarget::LxMinus) = SDL_SCANCODE_J; + K(PadTarget::LxPlus) = SDL_SCANCODE_L; + K(PadTarget::LyMinus) = SDL_SCANCODE_K; + K(PadTarget::LyPlus) = SDL_SCANCODE_I; + // The right stick had no keys in that table, which left a keyboard player + // without the N64's C buttons (n64lle declares them on the right stick). + // TFGH, beside the left stick's IJKL. + K(PadTarget::RyPlus) = SDL_SCANCODE_T; + K(PadTarget::RyMinus) = SDL_SCANCODE_G; + K(PadTarget::RxMinus) = SDL_SCANCODE_F; + K(PadTarget::RxPlus) = SDL_SCANCODE_H; + return b; +} + +std::string pad_source_to_string(const PadSource& s) { + switch (s.kind) { + case PadSource::Button: + if (const char* n = SDL_GetGamepadStringForButton(static_cast<SDL_GamepadButton>(s.code))) + return n; + break; + case PadSource::AxisPlus: + case PadSource::AxisMinus: + if (const char* n = SDL_GetGamepadStringForAxis(static_cast<SDL_GamepadAxis>(s.code))) + return std::string(n) + (s.kind == PadSource::AxisPlus ? "+" : "-"); + break; + case PadSource::None: + break; + } + return {}; +} + +bool pad_source_from_string(const std::string& in, PadSource& out) { + const std::string s = trim(in); + if (s.empty()) { + out = PadSource{}; + return true; + } + const char last = s.back(); + if (last == '+' || last == '-') { + const SDL_GamepadAxis a = SDL_GetGamepadAxisFromString(s.substr(0, s.size() - 1).c_str()); + if (a == SDL_GAMEPAD_AXIS_INVALID) return false; + out = PadSource{last == '+' ? PadSource::AxisPlus : PadSource::AxisMinus, a}; + return true; + } + const SDL_GamepadButton b = SDL_GetGamepadButtonFromString(s.c_str()); + if (b == SDL_GAMEPAD_BUTTON_INVALID) return false; + out = PadSource{PadSource::Button, b}; + return true; +} + +std::string pad_source_display(const PadSource& s) { + switch (s.kind) { + case PadSource::None: return "-"; + case PadSource::Button: + switch (static_cast<SDL_GamepadButton>(s.code)) { + case SDL_GAMEPAD_BUTTON_SOUTH: return "South (A / Cross)"; + case SDL_GAMEPAD_BUTTON_EAST: return "East (B / Circle)"; + case SDL_GAMEPAD_BUTTON_WEST: return "West (X / Square)"; + case SDL_GAMEPAD_BUTTON_NORTH: return "North (Y / Triangle)"; + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: return "LB / L1"; + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: return "RB / R1"; + case SDL_GAMEPAD_BUTTON_LEFT_STICK: return "Left stick press"; + case SDL_GAMEPAD_BUTTON_RIGHT_STICK: return "Right stick press"; + case SDL_GAMEPAD_BUTTON_START: return "Start"; + case SDL_GAMEPAD_BUTTON_BACK: return "Back / Select"; + case SDL_GAMEPAD_BUTTON_GUIDE: return "Guide"; + case SDL_GAMEPAD_BUTTON_DPAD_UP: return "D-pad up"; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: return "D-pad down"; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: return "D-pad left"; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: return "D-pad right"; + default: return pad_source_to_string(s); + } + case PadSource::AxisPlus: + case PadSource::AxisMinus: { + const bool plus = s.kind == PadSource::AxisPlus; + switch (static_cast<SDL_GamepadAxis>(s.code)) { + case SDL_GAMEPAD_AXIS_LEFTX: return plus ? "Left stick right" : "Left stick left"; + case SDL_GAMEPAD_AXIS_LEFTY: return plus ? "Left stick down" : "Left stick up"; + case SDL_GAMEPAD_AXIS_RIGHTX: return plus ? "Right stick right" : "Right stick left"; + case SDL_GAMEPAD_AXIS_RIGHTY: return plus ? "Right stick down" : "Right stick up"; + case SDL_GAMEPAD_AXIS_LEFT_TRIGGER: return "LT / L2"; + case SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: return "RT / R2"; + default: return pad_source_to_string(s); + } + } + } + return {}; +} + +fs::path platform_settings_dir(const fs::path& data_dir, const std::string& platform) { + // The same root the PSX and SNES Configure pages keep theirs under. + return data_dir / "platform" / platform; +} + +namespace { + +const char* seat_source_key(SeatAssign::Source s) { + switch (s) { + case SeatAssign::Auto: return "auto"; + case SeatAssign::Keyboard: return "keyboard"; + case SeatAssign::Gamepad: return "gamepad"; + case SeatAssign::None: return "none"; + } + return "auto"; +} + +// Pad and key lines for one map, as [<prefix>gamepad] / [<prefix>keyboard]. +void write_map(std::ostringstream& o, const std::string& prefix, const InputBindings& b) { + o << "\n[" << prefix << "gamepad]\n"; + for (int t = 0; t < kPadTargetCount; ++t) + o << kTargets[t].key << " = " << pad_source_to_string(b.pad[t]) << "\n"; + o << "\n[" << prefix << "keyboard]\n"; + for (int t = 0; t < kPadTargetCount; ++t) { + const char* n = b.key[t] == SDL_SCANCODE_UNKNOWN ? "" : SDL_GetScancodeName(b.key[t]); + o << kTargets[t].key << " = " << (n ? n : "") << "\n"; + } +} + +// One line of a [gamepad] / [keyboard] section into `b`. +void read_map_line(InputBindings& b, bool gamepad, const std::string& key, + const std::string& value, const fs::path& path, + std::vector<std::string>* warnings) { + int t = 0; + while (t < kPadTargetCount && key != kTargets[t].key) ++t; + if (t == kPadTargetCount) { + if (warnings) warnings->push_back(path.string() + ": unknown target '" + key + "'"); + return; + } + if (gamepad) { + PadSource src; + if (pad_source_from_string(value, src)) b.pad[t] = src; + else if (warnings) + warnings->push_back(path.string() + ": " + key + " = '" + value + + "' is not a gamepad input SDL knows; kept the default"); + return; + } + if (value.empty()) { + b.key[t] = SDL_SCANCODE_UNKNOWN; + return; + } + const SDL_Scancode sc = SDL_GetScancodeFromName(value.c_str()); + if (sc != SDL_SCANCODE_UNKNOWN) b.key[t] = sc; + else if (warnings) + warnings->push_back(path.string() + ": " + key + " = '" + value + + "' is not a key SDL knows; kept the default"); +} + +// Stick deadzone on one half-axis value (0..32768): below it is rest, above it +// is rescaled so full throw is still full throw. +int apply_deadzone(int v, int dz) { + if (dz <= 0) return v; + if (v <= dz) return 0; + return static_cast<int>((static_cast<long long>(v - dz) * 32768) / (32768 - dz)); +} + +bool is_stick_target(int t) { + return t >= static_cast<int>(PadTarget::LxMinus) && t <= static_cast<int>(PadTarget::RyPlus); +} + +} // namespace + +PlatformInput::PlatformInput() { + for (InputBindings& m : maps) m = default_input_bindings(); +} + +PlatformInput load_platform_input(const fs::path& data_dir, const std::string& platform, + std::vector<std::string>* warnings) { + PlatformInput in; + const fs::path path = platform_settings_dir(data_dir, platform) / "input.ini"; + std::error_code ec; + if (!fs::is_regular_file(path, ec)) return in; + for_each_ini(read_text(path), [&](const std::string& section, const std::string& key, + const std::string& value) { + if (section == "input") { + if (key == "deadzone") { + std::int64_t v = 0; + if (parse_i64(value, v)) in.deadzone_pct = static_cast<int>(std::clamp<std::int64_t>(v, 0, 90)); + } + return; + } + // [seatN], [seatN.gamepad], [seatN.keyboard] + if (section.size() < 5 || section.compare(0, 4, "seat") != 0) return; + const int n = section[4] - '1'; + if (n < 0 || n >= kInputSeats) return; + const std::string rest = section.substr(5); + SeatAssign& seat = in.seats[static_cast<size_t>(n)]; + if (rest.empty()) { + if (key == "source") { + if (value == "keyboard") seat.source = SeatAssign::Keyboard; + else if (value == "gamepad") seat.source = SeatAssign::Gamepad; + else if (value == "none") seat.source = SeatAssign::None; + else seat.source = SeatAssign::Auto; + } else if (key == "guid") { + seat.guid = value; + } else if (key == "name") { + seat.name = value; + } + } else if (rest == ".gamepad" || rest == ".keyboard") { + read_map_line(in.maps[static_cast<size_t>(n)], rest == ".gamepad", key, value, path, + warnings); + } + }); + for (SeatAssign& seat : in.seats) + if (seat.source == SeatAssign::Gamepad && seat.guid.empty()) seat.source = SeatAssign::Auto; + return in; +} + +bool save_platform_input(const fs::path& data_dir, const std::string& platform, + const PlatformInput& in, std::string* error) { + std::ostringstream o; + o << "# Retro Launcher controller seats for every " << platform << " title.\n" + << "# Written by retro-hub's settings page. A missing line is the default;\n" + << "# an empty binding is unbound. Gamepad inputs use SDL's names\n" + << "# (a, dpup, leftx+, lefttrigger+ ...), keys SDL's key names.\n\n" + << "[input]\ndeadzone = " << in.deadzone_pct << "\n"; + for (int n = 0; n < kInputSeats; ++n) { + const SeatAssign& seat = in.seats[static_cast<size_t>(n)]; + const std::string sec = "seat" + std::to_string(n + 1); + o << "\n[" << sec << "]\nsource = " << seat_source_key(seat.source) << "\n"; + if (seat.source == SeatAssign::Gamepad) { + if (seat.name.find_first_of("\r\n") != std::string::npos) { + if (error) *error = "a gamepad name this file cannot hold"; + return false; + } + o << "guid = " << seat.guid << "\nname = " << seat.name << "\n"; + } + write_map(o, sec + ".", in.maps[static_cast<size_t>(n)]); + } + return write_atomically(platform_settings_dir(data_dir, platform) / "input.ini", o.str(), + error); +} + +std::array<SeatPlan, kInputSeats> plan_seats(const PlatformInput& in, + const std::vector<std::string>& pad_guids) { + std::array<SeatPlan, kInputSeats> plan{}; + std::vector<bool> taken(pad_guids.size(), false); + for (int n = 0; n < kInputSeats; ++n) { + const SeatAssign& seat = in.seats[static_cast<size_t>(n)]; + if (seat.source != SeatAssign::Gamepad) continue; + for (size_t i = 0; i < pad_guids.size(); ++i) { + if (!taken[i] && pad_guids[i] == seat.guid) { + taken[i] = true; + plan[static_cast<size_t>(n)].pad = static_cast<int>(i); + break; + } + } + } + bool keyboard_seat = false; + for (int n = 0; n < kInputSeats; ++n) { + const SeatAssign& seat = in.seats[static_cast<size_t>(n)]; + if (seat.source == SeatAssign::Keyboard) { + plan[static_cast<size_t>(n)].keyboard = true; + keyboard_seat = true; + } + if (seat.source != SeatAssign::Auto) continue; + for (size_t i = 0; i < pad_guids.size(); ++i) { + if (!taken[i]) { + taken[i] = true; + plan[static_cast<size_t>(n)].pad = static_cast<int>(i); + break; + } + } + } + if (in.seats[0].source == SeatAssign::Auto && plan[0].pad < 0 && !keyboard_seat) + plan[0].keyboard = true; + return plan; +} + +std::string gamepad_guid_string(SDL_JoystickID id) { + char buf[64] = {}; + SDL_GUIDToString(SDL_GetGamepadGUIDForID(id), buf, sizeof buf); + return buf; +} + +void fill_pads_from_input(const PlatformInput& in, rcore_pad pads[RCORE_MAX_SEATS], + std::uint32_t max_seats) { + for (std::uint32_t i = 0; i < RCORE_MAX_SEATS; ++i) { + pads[i] = rcore_pad{}; + pads[i].struct_size = sizeof(rcore_pad); + } + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + std::vector<std::string> guids; + for (int i = 0; ids && i < count; ++i) guids.push_back(gamepad_guid_string(ids[i])); + const auto plan = plan_seats(in, guids); + const int dz = std::clamp(in.deadzone_pct, 0, 90) * 32768 / 100; + const bool* k = SDL_GetKeyboardState(nullptr); + const std::uint32_t seats = std::min<std::uint32_t>( + std::min<std::uint32_t>(max_seats, RCORE_MAX_SEATS), kInputSeats); + for (std::uint32_t n = 0; n < seats; ++n) { + const SeatPlan& sp = plan[n]; + const InputBindings& b = in.maps[n]; + SDL_Gamepad* g = sp.pad >= 0 ? SDL_GetGamepadFromID(ids[sp.pad]) : nullptr; + if (!g && !sp.keyboard) continue; + rcore_pad& p = pads[n]; + p.connected = 1; + int value[kPadTargetCount]; + bool pressed[kPadTargetCount]; + for (int t = 0; t < kPadTargetCount; ++t) { + if (g) { + value[t] = source_value(g, b.pad[t]); + if (is_stick_target(t)) value[t] = apply_deadzone(value[t], dz); + pressed[t] = b.pad[t].kind != PadSource::None && + (b.pad[t].kind == PadSource::Button ? source_pressed(g, b.pad[t]) + : value[t] > kAxisPressed); + } else { + pressed[t] = b.key[t] != SDL_SCANCODE_UNKNOWN && k && k[b.key[t]]; + value[t] = pressed[t] ? 32767 : 0; + } + } + fold(p, value, pressed); + } + SDL_free(ids); +} + +bool save_description_cache(const fs::path& data_dir, const std::string& platform, + const CoreDescription& d, std::string* error) { + if (!d.ok || d.raw.empty()) { + if (error) *error = "nothing to cache"; + return false; + } + return write_atomically(platform_settings_dir(data_dir, platform) / "core_description.txt", + d.raw, error); +} + +CoreDescription load_description_cache(const fs::path& data_dir, const std::string& platform) { + CoreDescription d; + const fs::path path = platform_settings_dir(data_dir, platform) / "core_description.txt"; + std::error_code ec; + if (!fs::is_regular_file(path, ec)) { + d.error = "no core has described itself for " + platform + " yet"; + return d; + } + std::string err; + if (!parse_core_description(read_text(path), d, &err)) { + d = CoreDescription{}; + d.error = path.string() + ": " + err; + return d; + } + d.cached = true; + return d; +} + +// ---- option values ---------------------------------------------------------- + +namespace { +fs::path options_path(const fs::path& data_dir, const std::string& platform, + const std::string& title_key) { + if (title_key.empty()) return platform_settings_dir(data_dir, platform) / "options.ini"; + return platform_settings_dir(data_dir, platform) / "options" / (title_key + ".ini"); +} +} // namespace + +std::map<std::string, std::string> load_core_options(const fs::path& data_dir, + const std::string& platform, + const std::string& title_key) { + std::map<std::string, std::string> out; + const fs::path path = options_path(data_dir, platform, title_key); + std::error_code ec; + if (!fs::is_regular_file(path, ec)) return out; + for_each_ini(read_text(path), [&](const std::string& section, const std::string& key, + const std::string& value) { + if (section == "options" && !key.empty()) out[key] = value; + }); + return out; +} + +bool save_core_options(const fs::path& data_dir, const std::string& platform, + const std::string& title_key, + const std::map<std::string, std::string>& values, std::string* error) { + std::ostringstream o; + o << "# Retro Launcher core options for " + << (title_key.empty() ? "every " + platform + " title" : title_key) << ".\n" + << "# Written by retro-hub's settings page; passed to the core at load.\n" + << "# Only changed options are listed -- the rest are the core's defaults.\n\n" + << "[options]\n"; + for (const auto& [k, v] : values) { + if (k.find_first_of("=\n\r[") != std::string::npos || + v.find_first_of("\n\r") != std::string::npos) { + if (error) *error = "option '" + k + "': a key or value this file cannot hold"; + return false; + } + o << k << " = " << v << "\n"; + } + return write_atomically(options_path(data_dir, platform, title_key), o.str(), error); +} + +std::map<std::string, std::string> layer_core_options( + const std::map<std::string, std::string>& platform, + const std::map<std::string, std::string>& title, + const std::map<std::string, std::string>& command_line) { + std::map<std::string, std::string> out = platform; + for (const auto& [k, v] : title) out[k] = v; + for (const auto& [k, v] : command_line) out[k] = v; + return out; +} + +} // namespace retcomm::hub diff --git a/src/hub/hub_core_settings.hpp b/src/hub/hub_core_settings.hpp new file mode 100644 index 0000000..4a63713 --- /dev/null +++ b/src/hub/hub_core_settings.hpp @@ -0,0 +1,225 @@ +#pragma once + +// What a core declares about itself, and the player's settings built on it: +// seats and bindings shared by every title of a platform, and option values +// for the platform with a per-title layer over them. Both the library and +// Direct mode read the same files, so a pad set up in one is set up in the +// other. +// +// <data_dir>/platform/<platform>/input.ini seats, maps, deadzone +// <data_dir>/platform/<platform>/options.ini option values (every title) +// <data_dir>/platform/<platform>/options/<key>.ini option values (one title) +// <data_dir>/platform/<platform>/core_description.txt the last --describe +// +// The declarations come from the core itself, through +// `retro-core-runner --describe` (Retro-Runtime docs/CORE_RUNNER.md): the hub +// never loads a core in its own process. A runner that predates --describe +// leaves the description empty and says so; nothing here invents a label or a +// choice the core did not declare. + +#include "rcore/rcore.h" + +#include <SDL3/SDL.h> + +#include <array> +#include <cstdint> +#include <filesystem> +#include <map> +#include <string> +#include <vector> + +namespace retcomm::hub { + +namespace fs = std::filesystem; + +// ---- the core's declarations ------------------------------------------------ + +struct CoreOptionDecl { + std::string key; + std::string type; // enum | bool | int | string (or the raw number) + bool restart = false, netplay = false, developer = false; + bool has_default = false; + std::string default_value; + std::int64_t int_min = 0, int_max = 0; + std::vector<std::string> values; // enum choices, declared order + std::string label; + std::string description; +}; + +struct CoreInputDecl { + std::uint32_t button = 0; // one RCORE_PAD_* bit, or 0 + std::uint32_t axis = 0; // RCORE_AXIS_* + 1, or 0 + std::int32_t axis_direction = 0; + std::string label; +}; + +struct CoreDescription { + bool ok = false; + std::string core_id, core_version, platforms; + std::vector<CoreOptionDecl> options; + std::vector<CoreInputDecl> inputs; + // Why ok is false: a runner without --describe, a core that failed to load. + std::string error; + // The --describe text it was parsed from, for the cache. + std::string raw; + // Read back from core_description.txt rather than asked of a core now. + bool cached = false; +}; + +// Parses `--describe` output (format revision 1). False, with *error set, on a +// missing header or an unknown revision; unknown record kinds are skipped so a +// later runner can add records without breaking this host. +bool parse_core_description(const std::string& text, CoreDescription& out, std::string* error); + +// Runs `runner --describe --core <core> [--package <package>]`. +CoreDescription describe_core(const fs::path& runner, const fs::path& core, + const fs::path& package, int timeout_ms = 10000); + +// The last description a page got for this platform, so the settings page can +// label things when no core is at hand (the library with no title open). +// Stale by nature: `cached` is set, and a page says which core it came from. +bool save_description_cache(const fs::path& data_dir, const std::string& platform, + const CoreDescription& d, std::string* error); +CoreDescription load_description_cache(const fs::path& data_dir, const std::string& platform); + +// ---- input bindings --------------------------------------------------------- + +// Everything a seat can drive: the sixteen rcore buttons, then each half of +// each stick axis, then the two analog triggers. +enum class PadTarget : int { + // Buttons, in RCORE_PAD_* bit order (bit i = target i). + South, East, West, North, L1, R1, L2, R2, L3, R3, Start, Select, + DpadUp, DpadDown, DpadLeft, DpadRight, + // Half-axes: rcore positive is right / up. + LxMinus, LxPlus, LyMinus, LyPlus, RxMinus, RxPlus, RyMinus, RyPlus, + // Triggers, 0..32767. + Lt, Rt, + Count +}; +constexpr int kPadTargetCount = static_cast<int>(PadTarget::Count); +constexpr int kPadButtonTargets = 16; + +// The stable key a target has in input.ini ("south", "lx-", "lt", ...). +const char* pad_target_key(PadTarget t); +// What the target is called when the core declared nothing for it. +const char* pad_target_generic_name(PadTarget t); +// The core's own label for this target, if it declared one ("A", "C-Up"). +std::string pad_target_label(PadTarget t, const CoreDescription& d); +// True when the core declared a descriptor for this target. A target it did not +// declare still passes through to the core; the page lists it lower down. +bool pad_target_declared(PadTarget t, const CoreDescription& d); + +// One physical input on a gamepad: a button, or one direction of an axis. +struct PadSource { + enum Kind : int { None, Button, AxisPlus, AxisMinus } kind = None; + int code = 0; // SDL_GamepadButton or SDL_GamepadAxis + bool operator==(const PadSource& o) const { return kind == o.kind && code == o.code; } + bool operator!=(const PadSource& o) const { return !(*this == o); } +}; + +struct InputBindings { + std::array<PadSource, kPadTargetCount> pad{}; + std::array<SDL_Scancode, kPadTargetCount> key{}; + bool operator==(const InputBindings& o) const { return pad == o.pad && key == o.key; } + bool operator!=(const InputBindings& o) const { return !(*this == o); } +}; + +// The table the hub always used before bindings existed: SDL's positional +// face buttons, sticks and triggers one to one; the keyboard as X/C/Z/S faces, +// Q/E shoulders, arrows, IJKL for the left stick. A gamepad plays exactly as +// it did before; the keyboard gains TFGH for the right stick, which that table +// lacked (so a keyboard had no C buttons). +InputBindings default_input_bindings(); + +std::string pad_source_to_string(const PadSource& s); // "a", "lefty+", "" +bool pad_source_from_string(const std::string& s, PadSource& out); +std::string pad_source_display(const PadSource& s); // for the page + +// ---- seats ------------------------------------------------------------------- + +constexpr int kInputSeats = 4; + +// What drives one controller port. +struct SeatAssign { + enum Source : int { + Auto, // the next gamepad not claimed by another seat; port 1 falls + // back to the keyboard when there is none + Keyboard, + Gamepad, // the pad with this GUID, when it is connected + None, // unplugged + }; + Source source = Auto; + std::string guid; // SDL GUID string, Gamepad only + std::string name; // what the pad called itself when it was chosen + bool operator==(const SeatAssign& o) const { + return source == o.source && guid == o.guid && name == o.name; + } + bool operator!=(const SeatAssign& o) const { return !(*this == o); } +}; + +struct PlatformInput { + std::array<SeatAssign, kInputSeats> seats{}; + // Each seat's own maps: a gamepad map and a keyboard map. The one the seat + // reads is its device's (an Auto seat can read either). + std::array<InputBindings, kInputSeats> maps; + // Stick deadzone, percent of full throw, every seat. 0 = raw (the default: + // what the hub always sent, and every N64 game has a deadzone of its own). + int deadzone_pct = 0; + PlatformInput(); + bool operator==(const PlatformInput& o) const { + return seats == o.seats && maps == o.maps && deadzone_pct == o.deadzone_pct; + } + bool operator!=(const PlatformInput& o) const { return !(*this == o); } +}; + +fs::path platform_settings_dir(const fs::path& data_dir, const std::string& platform); + +// A missing file, or a missing key, is the default; a value that names nothing +// SDL knows is dropped and reported in *warnings. +PlatformInput load_platform_input(const fs::path& data_dir, const std::string& platform, + std::vector<std::string>* warnings = nullptr); +bool save_platform_input(const fs::path& data_dir, const std::string& platform, + const PlatformInput& in, std::string* error); + +// Which device each seat reads this frame, given the connected pads' GUIDs in +// SDL's order. Pure, so it is testable without hardware: +// 1. Gamepad seats claim the first unclaimed pad with their GUID; +// 2. Auto seats take the remaining pads in order; +// 3. Keyboard seats read the keyboard; +// 4. an Auto port 1 left without a pad reads the keyboard, unless another +// seat is set to Keyboard. +struct SeatPlan { + int pad = -1; // index into the connected list, or -1 + bool keyboard = false; + bool connected() const { return pad >= 0 || keyboard; } +}; +std::array<SeatPlan, kInputSeats> plan_seats(const PlatformInput& in, + const std::vector<std::string>& pad_guids); + +std::string gamepad_guid_string(SDL_JoystickID id); + +// One frame of pads, from the connected devices and the seats' maps. +void fill_pads_from_input(const PlatformInput& in, rcore_pad pads[RCORE_MAX_SEATS], + std::uint32_t max_seats); + +// ---- option values ---------------------------------------------------------- + +// key -> value, only the options the player changed; everything else is the +// core's default. `title_key` names a title's file: the session name the play +// path already uses for it (its package's stem in Direct mode). An empty +// title_key is the platform's file, which every title reads under its own. +std::map<std::string, std::string> load_core_options(const fs::path& data_dir, + const std::string& platform, + const std::string& title_key); +bool save_core_options(const fs::path& data_dir, const std::string& platform, + const std::string& title_key, + const std::map<std::string, std::string>& values, std::string* error); + +// What a session is given: the platform's values, the title's over them, the +// command line's over both. +std::map<std::string, std::string> layer_core_options( + const std::map<std::string, std::string>& platform, + const std::map<std::string, std::string>& title, + const std::map<std::string, std::string>& command_line); + +} // namespace retcomm::hub diff --git a/src/hub/hub_main.cpp b/src/hub/hub_main.cpp index 63a398b..98a0ab2 100644 --- a/src/hub/hub_main.cpp +++ b/src/hub/hub_main.cpp @@ -18,6 +18,7 @@ #include "retcomm/snes_platform_settings.hpp" #include "retcomm/romm_saves.hpp" #include "retcomm/mods.hpp" +#include "retcomm/core_titles.hpp" #include "retcomm/self_update.hpp" #include "imgui.h" @@ -47,6 +48,9 @@ #include <memory> #include <mutex> #include <thread> +#include <fstream> +#include <future> +#include <map> #include <cctype> #include <cmath> #include <cstdio> @@ -527,6 +531,22 @@ using retcomm::hub::HubModel; using retcomm::hub::Theme; using retcomm::hub::TitleRow; +#if defined(RETCOMM_HUB_HAVE_PLAY) +// The core settings page ("n64lle Settings"), defined with Direct mode below: +// the library reaches it from a platform header's Configure button. +bool core_settings_platform(const std::string& platform); +const char* core_settings_button_label(const std::string& platform); +bool core_settings_dirty(); +bool save_core_settings(HubModel& hub, std::string* err); +void close_core_settings(HubModel& hub); +void open_core_settings_for_library(HubModel& hub, const std::string& platform, + const TitleRow* row); +bool core_settings_capture_event(HubModel& hub, const SDL_Event& e); +void draw_core_settings_panel(HubModel& hub, const Theme& th, BoxartCache& boxart); +#else +bool core_settings_platform(const std::string&) { return false; } +#endif + void SDLCALL on_folder_dialog(void* userdata, const char* const* filelist, int /*filter*/) { auto* hub = static_cast<HubModel*>(userdata); if (!hub) return; @@ -965,7 +985,7 @@ void cancel_snes_gesture_capture(HubModel& hub); bool any_settings_page_open(const HubModel& hub) { return hub.show_settings || hub.show_romm_settings || hub.show_psx_settings || - hub.show_snes_settings; + hub.show_snes_settings || hub.show_core_settings; } bool active_settings_dirty(const HubModel& hub) { @@ -973,6 +993,9 @@ bool active_settings_dirty(const HubModel& hub) { if (hub.show_romm_settings) return hub.romm_settings.dirty; if (hub.show_psx_settings) return hub.psx_settings.dirty; if (hub.show_snes_settings) return hub.snes_settings.dirty; +#if defined(RETCOMM_HUB_HAVE_PLAY) + if (hub.show_core_settings) return core_settings_dirty(); +#endif return false; } @@ -994,6 +1017,11 @@ bool save_active_settings(HubModel& hub) { } else if (hub.show_snes_settings) { what = "Super Nintendo settings"; ok = hub.save_snes_settings(&err); +#if defined(RETCOMM_HUB_HAVE_PLAY) + } else if (hub.show_core_settings) { + what = "core settings"; + ok = save_core_settings(hub, &err); +#endif } else { return false; } @@ -1052,6 +1080,9 @@ void close_settings_pages(HubModel& hub) { hub.snes_settings.configuring_player = -1; cancel_snes_bind_capture(hub); cancel_snes_gesture_capture(hub); +#if defined(RETCOMM_HUB_HAVE_PLAY) + if (hub.show_core_settings) close_core_settings(hub); // drops the draft +#endif } // Panel width. Shared by the slide, the dim and the click-outside test. @@ -1710,10 +1741,19 @@ PageHeaderAction draw_page_header(const Theme& th, const char* header, const float content_right = ImGui::GetWindowPos().x + ImGui::GetWindowContentRegionMax().x; ImGui::Dummy(ImVec2(ImGui::GetContentRegionAvail().x, row_h)); + const bool core_cfg = !config_platform.empty() && core_settings_platform(config_platform); const bool show_configure = - !config_platform.empty() && retcomm::platform_has_config_section(config_platform); + !config_platform.empty() && + (retcomm::platform_has_config_section(config_platform) || core_cfg); +#if defined(RETCOMM_HUB_HAVE_PLAY) + const char* cfg_label = + !show_configure ? "" + : core_cfg ? core_settings_button_label(config_platform) + : retcomm::platform_config_button_label(config_platform); +#else const char* cfg_label = show_configure ? retcomm::platform_config_button_label(config_platform) : ""; +#endif const float cfg_w = show_configure ? ImGui::CalcTextSize(cfg_label).x + kBtnPadX * 2.f : 0.f; @@ -1739,10 +1779,15 @@ PageHeaderAction draw_page_header(const Theme& th, const char* header, if (accent_button(cfg_label, th, ImVec2(cfg_w, btn_h))) action = PageHeaderAction::Configure; if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayNormal)) { - ImGui::SetTooltip( - "Global %s settings (Display, Audio, Input, Hotkeys).\n" - "Applied to titles on install, update, and launch unless excluded.", - snes ? "Super Nintendo" : "PlayStation"); + if (core_cfg) + ImGui::SetTooltip("Controllers and the core's options for every %s title.\n" + "Read by each game when it starts.", + platform_display_name(config_platform)); + else + ImGui::SetTooltip( + "Global %s settings (Display, Audio, Input, Hotkeys).\n" + "Applied to titles on install, update, and launch unless excluded.", + snes ? "Super Nintendo" : "PlayStation"); } } ImGui::PopStyleVar(); @@ -1769,6 +1814,12 @@ void draw_library(HubModel& hub, BoxartCache& boxart, const Theme& th) { const std::string config_platform = on_titles ? hub.library_platform : std::string(); switch (draw_page_header(th, header, config_platform)) { case PageHeaderAction::Configure: +#if defined(RETCOMM_HUB_HAVE_PLAY) + if (core_settings_platform(hub.library_platform)) { + open_core_settings_for_library(hub, hub.library_platform, nullptr); + break; + } +#endif if (retcomm::is_snes_platform(hub.library_platform)) hub.open_snes_settings(); else hub.open_psx_settings(); break; @@ -3362,6 +3413,7 @@ void draw_title_info_panel(HubModel& hub, const TitleRow& row, BoxartCache& boxa // install that is the release dir; for an AppImage it is the data dir AppRun // cds into, not the read-only payload. fs::path title_game_dir(const HubModel& hub, const TitleRow& row) { + if (!row.game_dir_override.empty()) return fs::path(row.game_dir_override); if (row.binary_path.empty()) return {}; const fs::path bin(row.binary_path); if (bin.filename() == "AppRun" && !row.install_root.empty()) { @@ -3475,12 +3527,8 @@ void draw_mods_page(HubModel& hub, const Theme& th) { std::string err; bool ok = true; for (const retcomm::ModPackageInfo& p : st.scan.packages) { - if (p.has_features()) { - for (const retcomm::ModFeatureInfo& f : p.features) - ok &= retcomm::set_mod_enabled(title_game_dir(hub, row), p.id, f.id, on, &err); - } else { - ok &= retcomm::set_mod_enabled(title_game_dir(hub, row), p.id, {}, on, &err); - } + if (!p.builtin) + ok &= retcomm::set_scanned_mod_all(st.scan, title_game_dir(hub, row), p, on, &err); } if (!ok) hub.append_log("Mod toggle failed: " + err); refresh_mods_page(hub, row); @@ -3597,7 +3645,8 @@ void draw_mods_page(HubModel& hub, const Theme& th) { ImGui::BeginDisabled(p.builtin); if (ImGui::Checkbox("##en", &on) && !p.builtin) { std::string err; - if (retcomm::set_mod_enabled(title_game_dir(hub, row), p.id, fid, on, &err)) + if (retcomm::set_scanned_mod_enabled(st.scan, title_game_dir(hub, row), p, + fid, on, &err)) refresh_mods_page(hub, row); else hub.append_log("Mod toggle failed: " + err); @@ -4143,6 +4192,12 @@ void draw_detail(HubModel& hub, BoxartCache& boxart, const Theme& th, SDL_Window row.platform.empty() ? "LIBRARY" : platform_display_name(row.platform); switch (draw_page_header(th, header, row.platform)) { case PageHeaderAction::Configure: +#if defined(RETCOMM_HUB_HAVE_PLAY) + if (core_settings_platform(row.platform)) { + open_core_settings_for_library(hub, row.platform, &row); + break; + } +#endif if (retcomm::is_snes_platform(row.platform)) hub.open_snes_settings(); else hub.open_psx_settings(); break; @@ -8806,11 +8861,15 @@ void draw_log_overlay(HubModel& hub, const Theme& th, SDL_Window* window) { } // namespace #if defined(RETCOMM_HUB_HAVE_PLAY) -// Direct mode (Retro-Runtime docs/HOST_LIFECYCLE.md §3): `--run-core` boots straight into a -// core in this window and exits when the player closes it. No library pages, -// no setup wizard -- the same host a standalone release is. +// Direct mode (Retro-Runtime docs/HOST_LIFECYCLE.md §3): `--run-core` runs one core's +// title in this window. No library pages, no setup wizard -- the same host a +// standalone release is. It opens on the title's home page (run_direct_home); +// `--boot` plays at once and exits when the player closes the game. struct DirectPlay { bool active = false; + // --boot: play at once and exit with the game (the pre-revision-3 Direct + // mode). Without it Direct mode opens the title's home page first. + bool boot = false; retcomm::hub::PlayArgs args; }; @@ -8828,6 +8887,7 @@ DirectPlay parse_direct_play(int argc, char** argv) { else if (a == "--tpak1-rom" && has_val) d.args.tpak_rom = argv[++i]; else if (a == "--tpak1-save" && has_val) d.args.tpak_save = argv[++i]; else if (a == "--no-gl") d.args.gl = false; + else if (a == "--boot") d.boot = true; else if (a == "--opt" && has_val) { const std::string kv = argv[++i]; const auto eq = kv.find('='); @@ -8947,209 +9007,1675 @@ int run_direct_error(SDL_Window* window, UiScale& ui, const std::string& what, return 1; } -int run_direct_play(SDL_Window* window, UiScale& ui, const DirectPlay& d, const HubModel& hub) { - retcomm::hub::PlaySession play; - const bool packaged = !d.args.package.empty(); - // Sessions and saves belong to the title. With a package that is the shim - // (<slug>_game): the core is the generic one every packaged title shares. - const std::string stem = (packaged ? d.args.package : d.args.core).stem().string(); - // resolve_runner: RETRO_CORE_RUNNER (override), else the newest of the - // bundled runner and the ones the runtime updater installed. - const retcomm::ResolvedRunner rr = retcomm::resolve_runner(hub.paths, hub.exe_dir); - std::fprintf(stderr, "retro-hub: runner: %s: %s (%s)\n", - rr.source.empty() ? "none" : rr.source.c_str(), - rr.path.empty() ? "-" : rr.path.string().c_str(), rr.note.c_str()); - DirectRuntimeUpdate update; - update.start(hub); - if (rr.path.empty()) { - return run_direct_error(window, ui, "No usable retro-core-runner was found.", - {rr.note, - "Place retro-core-runner beside retro-hub, or set " - "RETRO_CORE_RUNNER to its path."}, - &update); - } - if (packaged && rr.game_package == 0) { - return run_direct_error( - window, ui, "This runner cannot load a game package.", - {"--package " + d.args.package.string() + " needs a retro-core-runner that reports " - "game_package 1 in --version.", - "The " + rr.source + " runner " + corelink_version_label(rr) + " at " + - rr.path.string() + " does not (" + rr.note + ").", - "Use a newer Retro-Runtime runner (RETRO_CORE_RUNNER=<path>, or beside retro-hub)."}, - &update); - } - const fs::path runner = rr.path; - const fs::path session = hub.paths.data_dir / "sessions" / stem; - const fs::path saves = hub.paths.data_dir / "saves" / stem; - std::string err; - if (!play.start(d.args, runner, session, saves, &err)) { - return run_direct_error(window, ui, "Cannot start " + d.args.core.string(), {err}, nullptr); - } - std::fprintf(stderr, "retro-hub: running %s%s%s (session %s, saves %s)\n", - d.args.core.string().c_str(), packaged ? " with package " : "", - packaged ? d.args.package.string().c_str() : "", session.string().c_str(), - saves.string().c_str()); - bool running = true; - while (running && !play.finished()) { - hub_sync_open_gamepads(); - SDL_Event e; - while (SDL_PollEvent(&e)) { - scale_mouse_event(e, ui.coords); - if (!play.handle_event(e)) ImGui_ImplSDL3_ProcessEvent(&e); - if (e.type == SDL_EVENT_QUIT) running = false; - if (e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED && - e.window.windowID == SDL_GetWindowID(window)) - running = false; - if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat && e.key.key == SDLK_F11) { - const bool fs_now = (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0; - SDL_SetWindowFullscreen(window, !fs_now); - } +// ---- Direct home ------------------------------------------------------------ +// +// Direct mode's own page (direct_mode 3): what a port's run script lands on. +// One title -- the one on the command line -- with Play, and everything a +// player sets up before pressing it: controls, the core's settings, mods. +// Leaving the game comes back here; Quit leaves the app. `--boot` skips the +// page and plays at once (run_direct_play), for scripts. +// +// Nothing here is a second copy of the library's settings. Controls are the +// platform's bindings and options the title's values (hub_core_settings.hpp), +// the same files the library's play path reads; the Mods page is the +// library's, pointed at the title dir. + +// The title dir a session will use: PlaySession::start's rule. +fs::path direct_title_dir(const retcomm::hub::PlayArgs& a) { + if (!a.title_dir.empty()) return a.title_dir; + return (a.package.empty() ? a.core : a.package).parent_path(); +} + +// Sessions, saves and option values belong to the title: the shim's stem for a +// package (the generic core is shared by every packaged title), else the core's. +std::string direct_title_key(const retcomm::hub::PlayArgs& a) { + return (a.package.empty() ? a.core : a.package).stem().string(); +} + +std::string toml_string_value(const std::string& raw) { + std::string v = raw; + const auto hash = v.find(" #"); + if (v.empty() || v[0] != '"') v = v.substr(0, hash); + while (!v.empty() && std::isspace(static_cast<unsigned char>(v.back()))) v.pop_back(); + while (!v.empty() && std::isspace(static_cast<unsigned char>(v.front()))) v.erase(0, 1); + if (v.size() >= 2 && v.front() == '"') { + const auto close = v.find('"', 1); + if (close != std::string::npos) return v.substr(1, close - 1); + } + return v; +} + +// `key = value` lines of one [section] of a small TOML file, as written: what +// the page shows, never what the core resolves (it reads these files itself). +std::map<std::string, std::string> read_toml_section(const fs::path& p, + const std::string& want) { + std::map<std::string, std::string> out; + std::ifstream in(p); + std::string line, section; + while (std::getline(in, line)) { + const auto b = line.find_first_not_of(" \t"); + if (b == std::string::npos || line[b] == '#') continue; + if (line[b] == '[') { + const auto e = line.find(']', b); + section = e == std::string::npos ? "" : line.substr(b + 1, e - b - 1); + continue; } - play.tick(); - ImGui_ImplOpenGL3_NewFrame(); - ImGui_ImplSDL3_NewFrame(); - apply_ui_scale_frame(window, ui); - ImGui::NewFrame(); - play.draw(); - ImGui::Render(); - int fb_w = 0, fb_h = 0; - SDL_GetWindowSizeInPixels(window, &fb_w, &fb_h); - glViewport(0, 0, fb_w, fb_h); - glClearColor(0.f, 0.f, 0.f, 1.f); - glClear(GL_COLOR_BUFFER_BIT); - ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); - SDL_GL_SwapWindow(window); + const auto eq = line.find('='); + if (section != want || eq == std::string::npos) continue; + std::string k = line.substr(b, eq - b); + while (!k.empty() && std::isspace(static_cast<unsigned char>(k.back()))) k.pop_back(); + out[k] = toml_string_value(line.substr(eq + 1)); } - play.shutdown(); - return 0; + return out; } -#endif - -#if !defined(RETCOMM_COMMIT) -#define RETCOMM_COMMIT "" -#endif - -#if defined(RETCOMM_HUB_HAVE_PLAY) -// Revision of the Direct-mode command line (the flags parse_direct_play reads). -// It goes up whenever that set of flags changes, so a tool can require the -// revision that introduced the flags it passes; each revision still accepts -// every earlier revision's command line unless docs/RELEASES.md says otherwise. -// 1 --run-core --rom --title-dir --tpak1-rom --tpak1-save --no-gl --opt -// 2 + --package (a GAME_PACKAGE core's game shim) -constexpr int kDirectModeCliRevision = 2; -#endif -// `retro-hub --version`: one `key value` per line, exit 0, before SDL starts, -// so a tool (and the release packager) can check an unpacked hub without a -// display. Same shape as `retro-core-runner --version` (Retro-Runtime -// docs/RELEASES.md); the bare-archive contract is in docs/RELEASES.md. -int print_hub_version() { - const char* commit = RETCOMM_COMMIT; - std::printf("retro-hub %s\n", RETCOMM_VERSION); - std::printf("version %s\n", RETCOMM_VERSION); - std::printf("commit %s\n", commit[0] ? commit : "unknown"); -#if defined(RETCOMM_HUB_HAVE_PLAY) - std::printf("link_protocol %u.%u\n", static_cast<unsigned>(retro::corelink::kProtocolMajor), - static_cast<unsigned>(retro::corelink::kProtocolMinor)); - std::printf("rcore_abi_major %u\n", static_cast<unsigned>(RCORE_ABI_MAJOR)); - std::printf("rcore_draft_revision %u\n", static_cast<unsigned>(RCORE_DRAFT_REVISION)); - std::printf("direct_mode %d\n", kDirectModeCliRevision); - std::printf("direct_mode_flags --run-core --package --rom --title-dir --tpak1-rom " - "--tpak1-save --no-gl --opt\n"); - // resolve_runner (src/update/runtime_update.cpp): the override, else the - // newest of the bundled runner and the ones the runtime updater installed. - std::printf("runner_lookup RETRO_CORE_RUNNER exe_dir/retro-core-runner " - "data_dir/runtime/<version>/retro-core-runner\n"); -#else - std::printf("direct_mode 0\n"); // a build without the hub <-> runner link -#endif - return 0; +// The core's platform, from the sidecar the runner already insists on +// (<core>.rcore.toml, [core] platforms = ["n64"]): known before --describe +// answers, and when it cannot. +std::string rcore_manifest_platform(const fs::path& core) { + fs::path m = core; + m.replace_extension(".rcore.toml"); + const auto core_sec = read_toml_section(m, "core"); + const auto it = core_sec.find("platforms"); + if (it == core_sec.end()) return {}; + std::string v = it->second; // ["n64"] after toml_string_value's trim + const auto q = v.find('"'); + if (q == std::string::npos) return {}; + const auto q2 = v.find('"', q + 1); + return q2 == std::string::npos ? std::string() : v.substr(q + 1, q2 - q - 1); +} + +// Loads the player's seats and option values into a session's arguments: +// the platform's options, the title's over them, and the command line's --opt +// over both (it is the most specific request). +void apply_player_settings(retcomm::hub::PlayArgs& args, const fs::path& data_dir, + const std::string& platform, const std::string& title_key) { + if (platform.empty()) return; + std::vector<std::string> warnings; + args.input = retcomm::hub::load_platform_input(data_dir, platform, &warnings); + for (const std::string& w : warnings) std::fprintf(stderr, "retro-hub: %s\n", w.c_str()); + args.options = retcomm::hub::layer_core_options( + retcomm::hub::load_core_options(data_dir, platform, ""), + retcomm::hub::load_core_options(data_dir, platform, title_key), args.options); } -int main(int argc, char** argv) { - for (int i = 1; i < argc; ++i) { - if (std::string(argv[i]) == "--version") return print_hub_version(); - } - // `--play <title-id>`: start normally, press Play on that title, exit when - // it closes. What `retcomm launch` hands a core title to, so a Steam - // shortcut waits for the game. - std::string play_title_id; - for (int i = 1; i + 1 < argc; ++i) { - if (std::string(argv[i]) == "--play") play_title_id = argv[i + 1]; +namespace { + +// ---- Core settings page ------------------------------------------------------- +// +// "n64lle Settings": one page for a platform whose titles run through an rcore +// core, laid out the way the Super Nintendo page is -- a System tab of cards +// (the core's options) and a Gamepads tab of seat cards, each opening a +// controller page over a picture of the pad. The library opens it from the +// platform header's Configure button; Direct mode from the title's home page. +// +// What is on it comes from two places and no third: the core's own +// declarations (retro-core-runner --describe, cached per platform for when no +// core is at hand), and the files hub_core_settings.hpp names. Edits are a +// draft until Save, as on every other settings page. + +struct CoreSettingsPage { + std::string platform; + // A title the page was opened for. With one, the System tab can edit that + // title's layer instead of every title's. + std::string title_key, title_name; + bool title_scope = false; + + std::future<retcomm::hub::CoreDescription> describing; + retcomm::hub::CoreDescription desc; + bool desc_ready = false; + std::string described_from; // which core, for the page's footnote + + retcomm::hub::PlatformInput input, input_saved; + std::map<std::string, std::string> plat_opts, plat_saved, title_opts, title_saved; + + bool gamepads_tab = false; + bool show_developer = false; + int configuring = -1; // seat whose controller page is open + bool map_keyboard = false; // that page shows the keyboard map (Auto seats) + int cap_target = -1; + bool cap_pad = false; + std::uint64_t cap_armed_ns = 0; + std::string status; + + bool dirty() const { + return input != input_saved || plat_opts != plat_saved || title_opts != title_saved; } -#if defined(RETCOMM_HUB_HAVE_PLAY) - const DirectPlay direct = parse_direct_play(argc, argv); - if (direct.active && direct.args.rom.empty()) { - std::fprintf(stderr, "retro-hub: --run-core needs --rom <image>\n"); - return 2; +}; + +CoreSettingsPage& core_settings_page() { + static CoreSettingsPage p; + return p; +} + +// Platforms whose settings are the core's rather than an installed app's +// files (retcomm::platform_has_config_section is the latter). +bool core_settings_platform(const std::string& platform) { return platform == "n64"; } + +const char* core_settings_button_label(const std::string& platform) { + return platform == "n64" ? "n64lle Config" : "Core Config"; +} + +// A core the library knows for this platform: the title's own when it has +// one, else the first registered core title of the platform. +fs::path library_core_for(const HubModel& hub, const std::string& platform, + const std::string& title_id) { + fs::path found; + for (const retcomm::Title& t : hub.catalog.titles) { + if (t.platform != platform || t.core_manifest.empty()) continue; + retcomm::CoreTitle ct; + if (!retcomm::read_core_title(t.core_manifest, ct, nullptr)) continue; + if (t.id == title_id) return ct.library; + if (found.empty()) found = ct.library; + } + return found; +} + +void cancel_core_capture(CoreSettingsPage& p) { p.cap_target = -1; } + +// Opens the page. `core`/`package` name what to describe; `known` is a +// description the caller already has (Direct mode asked at startup). +void open_core_settings(HubModel& hub, const std::string& platform, const std::string& title_key, + const std::string& title_name, const fs::path& core, + const fs::path& package, + const retcomm::hub::CoreDescription* known = nullptr) { + CoreSettingsPage& p = core_settings_page(); + const fs::path& data = hub.paths.data_dir; + p.platform = platform; + p.title_key = title_key; + p.title_name = title_name; + p.title_scope = false; + p.input = p.input_saved = retcomm::hub::load_platform_input(data, platform); + p.plat_opts = p.plat_saved = retcomm::hub::load_core_options(data, platform, ""); + p.title_opts = p.title_saved = + title_key.empty() ? std::map<std::string, std::string>{} + : retcomm::hub::load_core_options(data, platform, title_key); + p.gamepads_tab = false; + p.configuring = -1; + p.status.clear(); + cancel_core_capture(p); + + const fs::path lib = core.empty() ? library_core_for(hub, platform, title_key) : core; + if (known && known->ok) { + p.desc = *known; + p.desc_ready = true; + p.described_from = core.string(); + } else if (!lib.empty()) { + const retcomm::ResolvedRunner rr = retcomm::resolve_runner(hub.paths, hub.exe_dir); + p.desc_ready = false; + p.described_from = lib.string(); + if (rr.path.empty()) { + std::promise<retcomm::hub::CoreDescription> pr; + retcomm::hub::CoreDescription none; + none.error = "No usable retro-core-runner was found (" + rr.note + ")."; + pr.set_value(none); + p.describing = pr.get_future(); + } else { + p.describing = std::async(std::launch::async, [runner = rr.path, lib, package] { + return retcomm::hub::describe_core(runner, lib, package); + }); + } + } else { + p.desc = retcomm::hub::load_description_cache(data, platform); + p.desc_ready = true; + p.described_from.clear(); } -#endif - // Steam only reads its screen-keyboard hint at startup. - retcomm::hub::osk_configure_hints(); + hub.show_settings = false; + hub.show_romm_settings = false; + hub.show_psx_settings = false; + hub.show_snes_settings = false; + hub.show_mods_page = false; + hub.show_core_settings = true; +} - if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) { - std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); - return 1; +// Once per frame while the page is open: pick up the description when it +// lands, keep the cache current, and fall back to the cache on failure. +void tick_core_settings(HubModel& hub) { + CoreSettingsPage& p = core_settings_page(); + if (p.desc_ready || !p.describing.valid() || + p.describing.wait_for(std::chrono::seconds(0)) != std::future_status::ready) + return; + p.desc = p.describing.get(); + p.desc_ready = true; + if (p.desc.ok) { + std::string err; + if (!retcomm::hub::save_description_cache(hub.paths.data_dir, p.platform, p.desc, &err)) + hub.append_log("Core description cache not written: " + err); + } else { + const std::string why = p.desc.error; + retcomm::hub::CoreDescription cached = + retcomm::hub::load_description_cache(hub.paths.data_dir, p.platform); + if (cached.ok) { + p.desc = cached; + p.status = "Showing the last description this platform's core gave: " + why; + } } +} - const char* glsl = "#version 150"; - SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2); - SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); - SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); - SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); +bool save_core_settings(HubModel& hub, std::string* err) { + CoreSettingsPage& p = core_settings_page(); + const fs::path& data = hub.paths.data_dir; + if (!retcomm::hub::save_platform_input(data, p.platform, p.input, err)) return false; + if (!retcomm::hub::save_core_options(data, p.platform, "", p.plat_opts, err)) return false; + if (!p.title_key.empty() && + !retcomm::hub::save_core_options(data, p.platform, p.title_key, p.title_opts, err)) + return false; + p.input_saved = p.input; + p.plat_saved = p.plat_opts; + p.title_saved = p.title_opts; + hub.append_log("Wrote " + p.platform + " settings to " + + retcomm::hub::platform_settings_dir(data, p.platform).string()); + return true; +} - // HIGH_PIXEL_DENSITY asks for a native-resolution backbuffer where the - // platform scales windows for us (macOS retina, Wayland); on Windows and X11 - // it is a no-op. Either way the pixel size is the truth and UiScale turns it - // back into the logical 1280x800 this UI is written against. - SDL_Window* window = - SDL_CreateWindow("Retro Launcher", 1280, 800, - SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | - SDL_WINDOW_HIGH_PIXEL_DENSITY); - if (!window) { - std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); - return 1; +void close_core_settings(HubModel& hub) { + CoreSettingsPage& p = core_settings_page(); + p.input = p.input_saved; + p.plat_opts = p.plat_saved; + p.title_opts = p.title_saved; + p.configuring = -1; + cancel_core_capture(p); + hub.show_core_settings = false; +} + +// ---- binding capture -------------------------------------------------------- + +constexpr std::uint64_t kCaptureSettleNs = 300'000'000ull; // the press that armed it +constexpr std::uint64_t kCaptureTimeoutNs = 6'000'000'000ull; +constexpr int kCaptureAxis = 24000; + +void begin_core_capture(CoreSettingsPage& p, int target, bool pad) { + p.cap_target = target; + p.cap_pad = pad; + p.cap_armed_ns = SDL_GetTicksNS(); +} + +// Every SDL event while a capture is armed, before ImGui. True = consumed. +// A pad capture takes every pad event (so the button being bound does not also +// press something on the page) and gives up after a few idle seconds; a key +// capture leaves the pad free to drive the dialog. +bool core_settings_capture_event(HubModel& hub, const SDL_Event& e) { + CoreSettingsPage& p = core_settings_page(); + if (!hub.show_core_settings || p.configuring < 0 || p.cap_target < 0) return false; + retcomm::hub::InputBindings& map = p.input.maps[static_cast<size_t>(p.configuring)]; + const bool settled = SDL_GetTicksNS() - p.cap_armed_ns > kCaptureSettleNs; + using retcomm::hub::PadSource; + if (p.cap_pad) { + const bool pad_event = e.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || + e.type == SDL_EVENT_GAMEPAD_BUTTON_UP || + e.type == SDL_EVENT_GAMEPAD_AXIS_MOTION; + if (!pad_event) { + if (e.type == SDL_EVENT_KEY_DOWN && e.key.key == SDLK_ESCAPE) { + cancel_core_capture(p); + return true; + } + return false; + } + if (!settled) return true; + PadSource got; + if (e.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && + e.gbutton.button != SDL_GAMEPAD_BUTTON_GUIDE) // Guide opens the game's menu + got = {PadSource::Button, e.gbutton.button}; + else if (e.type == SDL_EVENT_GAMEPAD_AXIS_MOTION && e.gaxis.value > kCaptureAxis) + got = {PadSource::AxisPlus, e.gaxis.axis}; + else if (e.type == SDL_EVENT_GAMEPAD_AXIS_MOTION && e.gaxis.value < -kCaptureAxis && + e.gaxis.axis != SDL_GAMEPAD_AXIS_LEFT_TRIGGER && + e.gaxis.axis != SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) + got = {PadSource::AxisMinus, e.gaxis.axis}; + if (got.kind != PadSource::None) { + map.pad[static_cast<size_t>(p.cap_target)] = got; + cancel_core_capture(p); + } + return true; } - SDL_GLContext gl = SDL_GL_CreateContext(window); - if (!gl) { - std::fprintf(stderr, "SDL_GL_CreateContext failed: %s\n", SDL_GetError()); - return 1; + if (e.type != SDL_EVENT_KEY_DOWN || e.key.repeat) return false; + if (e.key.key == SDLK_ESCAPE) { + cancel_core_capture(p); + return true; } - SDL_GL_MakeCurrent(window, gl); - SDL_GL_SetSwapInterval(1); + if (!settled) return true; + map.key[static_cast<size_t>(p.cap_target)] = e.key.scancode; + cancel_core_capture(p); + return true; +} - IMGUI_CHECKVERSION(); - ImGui::CreateContext(); - ImGuiIO& io = ImGui::GetIO(); - io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; - // Controller first: the D-pad / left stick move the focus ring, A activates, - // B backs out. Mouse use hides the ring again (ConfigNavCursorVisibleAuto). - io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; - io.IniFilename = nullptr; - // RETCOMM_HUB_NAV_DEBUG=1 prints ImGui's own focus/nav event log to the - // terminal — the only honest way to see where a controller's focus went. - if (const char* nav_dbg = std::getenv("RETCOMM_HUB_NAV_DEBUG"); nav_dbg && nav_dbg[0] == '1') { - ImGuiContext& g = *ImGui::GetCurrentContext(); - g.DebugLogFlags |= ImGuiDebugLogFlags_EventFocus | ImGuiDebugLogFlags_EventNav | - ImGuiDebugLogFlags_OutputToTTY; - } +// ---- System tab ---------------------------------------------------------------- - // The atlas has to be built before the first frame, and config.json is not - // loaded yet (that needs hub.paths below), so start from the display's own - // scale. A pinned config ui_scale is applied a few lines further down. - UiScale ui = resolve_ui_scale(window, 0.f); - load_hub_fonts(ui.px); +// How a bool option spells true and false. The rcore ABI does not say +// (rcore.h: RCORE_OPT_BOOL has no stated encoding), so only a core whose own +// default shows the spelling gets a checkbox; any other is a text field. +bool bool_spelling(const retcomm::hub::CoreOptionDecl& o, std::string& t, std::string& f) { + if (o.default_value == "true" || o.default_value == "false") { t = "true"; f = "false"; return true; } + if (o.default_value == "1" || o.default_value == "0") { t = "1"; f = "0"; return true; } + return false; +} - const Theme th = retcomm::hub::crt_theme(); - retcomm::hub::apply_imgui_style(th); +// A section heading from a key's prefix: video.renderer -> VIDEO. +std::string option_group(const std::string& key) { + std::string g = key.substr(0, key.find('.')); + for (char& c : g) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c))); + return g == key ? std::string("OTHER") : g; +} + +// One option as a settings row. In title scope the value may be inherited: +// the row then shows the all-titles value and offers it as its first choice. +void draw_core_option_row(CoreSettingsPage& p, const retcomm::hub::CoreOptionDecl& o, + const Theme& th) { + std::map<std::string, std::string>& layer = p.title_scope ? p.title_opts : p.plat_opts; + const auto own = layer.find(o.key); + const bool set = own != layer.end(); + std::string inherited = o.has_default ? o.default_value : std::string(); + if (p.title_scope) + if (const auto it = p.plat_opts.find(o.key); it != p.plat_opts.end()) inherited = it->second; + const std::string cur = set ? own->second : inherited; + const std::string label = o.label.empty() ? o.key : o.label; + const char* inherit_word = p.title_scope ? "All titles" : "Default"; + + ImGui::PushID(o.key.c_str()); + std::string t, f; + if (o.type == "enum" && !o.values.empty()) { + settings_row(label.c_str(), th, kSettingsCtrlW); + const std::string preview = set ? cur : std::string(inherit_word) + ": " + cur; + if (ImGui::BeginCombo("##v", preview.c_str())) { + if (ImGui::Selectable((std::string(inherit_word) + " (" + inherited + ")").c_str(), + !set)) + layer.erase(o.key); + for (const std::string& v : o.values) { + if (ImGui::Selectable(v.c_str(), set && v == cur)) layer[o.key] = v; + if (set && v == cur) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } else if (o.type == "bool" && bool_spelling(o, t, f)) { + bool on = cur == t; + if (settings_checkbox(label.c_str(), "##v", th, &on)) layer[o.key] = on ? t : f; + } else if (o.type == "int") { + settings_row(label.c_str(), th, kSettingsCtrlW); + long long v = 0; + try { v = std::stoll(cur); } catch (...) { v = o.int_min; } + // Committed on every edit: v is rebuilt from the draft each frame, so + // waiting for the field to lose focus would read back the old value. + if (ImGui::InputScalar("##v", ImGuiDataType_S64, &v)) { + if (o.int_min < o.int_max) v = std::clamp<long long>(v, o.int_min, o.int_max); + layer[o.key] = std::to_string(v); + } + } else { + settings_row(label.c_str(), th, kSettingsCtrlW * 1.4f); + char buf[512]; + copy_buf(buf, sizeof buf, set ? cur : std::string()); + const std::string hint = inherited.empty() ? std::string("unset") : inherited; + if (ImGui::InputTextWithHint("##v", hint.c_str(), buf, sizeof buf)) { + if (buf[0]) layer[o.key] = buf; + else layer.erase(o.key); + } + } + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayNormal)) { + ImGui::SetTooltip("%s\n\n%s%s%s%s", o.description.c_str(), o.key.c_str(), + o.has_default ? " \xC2\xB7 default " : "", + o.has_default ? o.default_value.c_str() : "", + o.restart ? "\nApplies the next time a game starts." : ""); + } + ImGui::PopID(); +} - ImGui_ImplSDL3_InitForOpenGL(window, gl); - // Any connected pad drives the hub, not only the first one SDL enumerated. - ImGui_ImplSDL3_SetGamepadMode(ImGui_ImplSDL3_GamepadMode_AutoAll); +void draw_core_option_group(CoreSettingsPage& p, const Theme& th, bool developer) { + std::string group; + for (const retcomm::hub::CoreOptionDecl& o : p.desc.options) { + if (o.developer != developer) continue; + const std::string g = option_group(o.key); + if (g != group) { + if (!group.empty()) ImGui::Dummy(ImVec2(0, 8)); + group = g; + ImGui::TextColored(th.text_muted, "%s", g.c_str()); + ImGui::Separator(); + } + draw_core_option_row(p, o, th); + } +} + +void draw_core_system_tab(HubModel& hub, CoreSettingsPage& p, const Theme& th, float panel_h, + float col_w, float gap) { + ImGui::BeginChild("core_player_opts", ImVec2(col_w, panel_h), ImGuiChildFlags_Borders, + page_wheel_flags(hub)); + if (!p.desc_ready) { + ImGui::TextDisabled("Asking the core what it declares..."); + } else if (!p.desc.ok) { + ImGui::TextColored(th.warn, "The core's settings are unavailable."); + ImGui::PushStyleColor(ImGuiCol_Text, th.text_muted); + ImGui::TextWrapped("%s", p.desc.error.c_str()); + ImGui::TextWrapped("Open a %s title once (Direct mode, or Play from the library) with " + "a runner that reports 'describe 1', and this page remembers what " + "its core declared.", + platform_display_name(p.platform)); + ImGui::PopStyleColor(); + } else { + draw_core_option_group(p, th, false); + } + ImGui::EndChild(); + + ImGui::SameLine(0.f, gap); + ImGui::BeginChild("core_dev_opts", ImVec2(0, panel_h), ImGuiChildFlags_Borders, + page_wheel_flags(hub)); + size_t dev = 0; + for (const auto& o : p.desc.options) dev += o.developer ? 1 : 0; + ImGui::TextColored(th.text_muted, "DEVELOPER"); + ImGui::Separator(); + ImGui::PushStyleColor(ImGuiCol_Text, th.text_muted); + ImGui::TextWrapped( + "Options the core marks as developer-only: diagnostics, and the engine's policy knobs. " + "Leave them alone to play; an unset knob is the engine's own default."); + ImGui::PopStyleColor(); + const std::string lbl = "Show developer options (" + std::to_string(dev) + ")"; + ImGui::BeginDisabled(dev == 0); + if (settings_checkbox(lbl.c_str(), "##showdev", th, &p.show_developer)) {} + ImGui::EndDisabled(); + if (p.show_developer && p.desc.ok) { + ImGui::Dummy(ImVec2(0, 6)); + draw_core_option_group(p, th, true); + } + // Values stored for keys the core no longer declares: the runner refuses an + // --opt it does not know, so they are shown to be removed, not hidden. + std::map<std::string, std::string>& layer = p.title_scope ? p.title_opts : p.plat_opts; + std::vector<std::string> stale; + if (p.desc.ok) + for (const auto& [k, v] : layer) + if (std::none_of(p.desc.options.begin(), p.desc.options.end(), + [&](const retcomm::hub::CoreOptionDecl& o) { return o.key == k; })) + stale.push_back(k); + if (!stale.empty()) { + ImGui::Dummy(ImVec2(0, 10)); + ImGui::TextColored(th.warn, "NOT DECLARED BY THIS CORE"); + ImGui::Separator(); + for (const std::string& k : stale) { + ImGui::PushID(k.c_str()); + const std::string row = k + " = " + layer[k]; + settings_row(row.c_str(), th, 110.f); + if (ImGui::Button("Remove", ImVec2(110.f, 0))) layer.erase(k); + ImGui::PopID(); + } + } + ImGui::EndChild(); +} + +// ---- Gamepads tab ------------------------------------------------------------- + +struct CorePadHit { + retcomm::hub::PadTarget target; + float nx, ny; // on pad_n64.png (720x400), the SVG's button centres +}; + +const CorePadHit* n64_pad_hits(int* count) { + using retcomm::hub::PadTarget; + static const CorePadHit kHits[] = { + {PadTarget::L1, 210.f / 720, 82.f / 400}, {PadTarget::R1, 510.f / 720, 82.f / 400}, + {PadTarget::DpadUp, 180.f / 720, 120.f / 400}, {PadTarget::DpadDown, 180.f / 720, 180.f / 400}, + {PadTarget::DpadLeft, 145.f / 720, 150.f / 400}, {PadTarget::DpadRight, 215.f / 720, 150.f / 400}, + {PadTarget::Start, 360.f / 720, 150.f / 400}, + {PadTarget::LyPlus, 360.f / 720, 212.f / 400}, {PadTarget::LyMinus, 360.f / 720, 288.f / 400}, + {PadTarget::LxMinus, 322.f / 720, 250.f / 400}, {PadTarget::LxPlus, 398.f / 720, 250.f / 400}, + {PadTarget::L2, 360.f / 720, 356.f / 400}, + {PadTarget::West, 465.f / 720, 160.f / 400}, {PadTarget::South, 505.f / 720, 195.f / 400}, + {PadTarget::RyPlus, 560.f / 720, 105.f / 400}, {PadTarget::RyMinus, 560.f / 720, 155.f / 400}, + {PadTarget::RxMinus, 525.f / 720, 130.f / 400}, {PadTarget::RxPlus, 595.f / 720, 130.f / 400}, + }; + *count = static_cast<int>(sizeof(kHits) / sizeof(kHits[0])); + return kHits; +} + +constexpr float kN64CropU0 = 0.10f; +constexpr float kN64CropV0 = 0.12f; +constexpr float kN64CropU1 = 0.90f; +constexpr float kN64CropV1 = 0.95f; + +// A chip's worth of text for a pad input. +std::string pad_source_short(const retcomm::hub::PadSource& s) { + using retcomm::hub::PadSource; + if (s.kind == PadSource::None) return "-"; + if (s.kind == PadSource::Button) { + switch (static_cast<SDL_GamepadButton>(s.code)) { + case SDL_GAMEPAD_BUTTON_SOUTH: return "A"; + case SDL_GAMEPAD_BUTTON_EAST: return "B"; + case SDL_GAMEPAD_BUTTON_WEST: return "X"; + case SDL_GAMEPAD_BUTTON_NORTH: return "Y"; + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: return "LB"; + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: return "RB"; + case SDL_GAMEPAD_BUTTON_LEFT_STICK: return "LS click"; + case SDL_GAMEPAD_BUTTON_RIGHT_STICK: return "RS click"; + case SDL_GAMEPAD_BUTTON_START: return "Start"; + case SDL_GAMEPAD_BUTTON_BACK: return "Back"; + case SDL_GAMEPAD_BUTTON_DPAD_UP: return "D-Up"; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: return "D-Down"; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: return "D-Left"; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: return "D-Right"; + default: return retcomm::hub::pad_source_to_string(s); + } + } + const bool plus = s.kind == PadSource::AxisPlus; + switch (static_cast<SDL_GamepadAxis>(s.code)) { + case SDL_GAMEPAD_AXIS_LEFTX: return plus ? "LS Right" : "LS Left"; + case SDL_GAMEPAD_AXIS_LEFTY: return plus ? "LS Down" : "LS Up"; + case SDL_GAMEPAD_AXIS_RIGHTX: return plus ? "RS Right" : "RS Left"; + case SDL_GAMEPAD_AXIS_RIGHTY: return plus ? "RS Down" : "RS Up"; + case SDL_GAMEPAD_AXIS_LEFT_TRIGGER: return "LT"; + case SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: return "RT"; + default: return retcomm::hub::pad_source_to_string(s); + } +} + +struct ConnectedPad { + std::string guid, name; +}; + +std::vector<ConnectedPad> connected_pads() { + std::vector<ConnectedPad> out; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + for (int i = 0; ids && i < count; ++i) { + const char* n = SDL_GetGamepadNameForID(ids[i]); + out.push_back({retcomm::hub::gamepad_guid_string(ids[i]), n ? n : "Gamepad"}); + } + SDL_free(ids); + return out; +} + +void draw_core_seat_source_combo(CoreSettingsPage& p, int seat, + const std::vector<ConnectedPad>& pads) { + using retcomm::hub::SeatAssign; + SeatAssign& s = p.input.seats[static_cast<size_t>(seat)]; + std::string preview; + switch (s.source) { + case SeatAssign::Auto: preview = "Auto"; break; + case SeatAssign::Keyboard: preview = "Keyboard"; break; + case SeatAssign::None: preview = "None (unplugged)"; break; + case SeatAssign::Gamepad: preview = s.name.empty() ? s.guid : s.name; break; + } + if (!ImGui::BeginCombo("##src", preview.c_str())) return; + if (ImGui::Selectable("Auto (next free gamepad)", s.source == SeatAssign::Auto)) + s = {SeatAssign::Auto, "", ""}; + if (ImGui::Selectable("Keyboard", s.source == SeatAssign::Keyboard)) + s = {SeatAssign::Keyboard, "", ""}; + bool listed = false; + for (size_t i = 0; i < pads.size(); ++i) { + const bool sel = s.source == SeatAssign::Gamepad && s.guid == pads[i].guid; + listed |= sel; + const std::string item = pads[i].name + "##pad" + std::to_string(i); + if (ImGui::Selectable(item.c_str(), sel)) s = {SeatAssign::Gamepad, pads[i].guid, pads[i].name}; + } + if (s.source == SeatAssign::Gamepad && !listed) + ImGui::Selectable((preview + " (not connected)").c_str(), true); + if (ImGui::Selectable("None (unplugged)", s.source == SeatAssign::None)) + s = {SeatAssign::None, "", ""}; + ImGui::EndCombo(); +} + +// The pad picture with a chip on every input the core has, the SNES page's +// shape. Clicking a chip starts a capture for it. +void draw_core_pad_map(CoreSettingsPage& p, const Theme& th, bool keyboard, BoxartCache& boxart) { + using namespace retcomm::hub; + const InputBindings& map = p.input.maps[static_cast<size_t>(p.configuring)]; + fs::path art = find_hub_asset_file("controllers", "pad_n64.png"); + const BoxartTexture* tex = art.empty() ? nullptr : boxart.get("n64:pad", art); + + ImGui::BeginChild("core_map_panel", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::TextColored(th.text_muted, keyboard ? "KEYBOARD BINDINGS" : "GAMEPAD BINDINGS"); + ImGui::TextColored(th.text_muted, + keyboard ? "Click a button, then press the key to bind (Esc cancels). " + "Chips light while a bound key is held." + : "Click a button, then press the gamepad button or push the " + "stick or trigger that should drive it."); + ImGui::Separator(); + + const float avail_w = ImGui::GetContentRegionAvail().x; + const float avail_h = std::max(140.f, ImGui::GetContentRegionAvail().y - 4.f); + const float crop_w = kN64CropU1 - kN64CropU0; + const float crop_h = kN64CropV1 - kN64CropV0; + const float aspect = (720.f / 400.f) * (crop_w / crop_h); + constexpr float kEdgePad = 4.f; + float img_w = std::max(1.f, avail_w - kEdgePad * 2.f); + float img_h = img_w / aspect; + if (img_h > avail_h - kEdgePad * 2.f) { + img_h = std::max(1.f, avail_h - kEdgePad * 2.f); + img_w = img_h * aspect; + } + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const float img_x = origin.x + (avail_w - img_w) * 0.5f; + const float img_y = origin.y + (avail_h - img_h) * 0.5f; + ImGui::Dummy(ImVec2(avail_w, avail_h)); + ImDrawList* dl = ImGui::GetWindowDrawList(); + if (tex && tex->gl_id) { + dl->AddImage(static_cast<ImTextureID>(static_cast<intptr_t>(tex->gl_id)), + ImVec2(img_x, img_y), ImVec2(img_x + img_w, img_y + img_h), + ImVec2(kN64CropU0, kN64CropV0), ImVec2(kN64CropU1, kN64CropV1)); + } else { + dl->AddRectFilled(ImVec2(img_x, img_y), ImVec2(img_x + img_w, img_y + img_h), + ImGui::ColorConvertFloat4ToU32(th.control)); + dl->AddText(ImVec2(img_x + 12.f, img_y + 12.f), ImGui::ColorConvertFloat4ToU32(th.warn), + art.empty() ? "pad art missing (assets/controllers/pad_n64.png)" + : "pad art failed to load"); + } + + int n = 0; + const CorePadHit* hits = n64_pad_hits(&n); + const float ui_scale = std::clamp(img_w / 570.f, 0.95f, 2.1f); + const float chip_w = 64.f * ui_scale; + const float chip_h = 18.f * ui_scale; + int nkeys = 0; + const bool* keys = keyboard ? SDL_GetKeyboardState(&nkeys) : nullptr; + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2.f * ui_scale, 0.f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 3.f * ui_scale); + ImGui::SetWindowFontScale(0.74f * ui_scale); + for (int i = 0; i < n; ++i) { + const int t = static_cast<int>(hits[i].target); + std::string bound; + bool live = false; + if (keyboard) { + const SDL_Scancode sc = map.key[static_cast<size_t>(t)]; + bound = sc == SDL_SCANCODE_UNKNOWN ? "-" : SDL_GetScancodeName(sc); + live = sc > 0 && sc < nkeys && keys[sc]; + } else { + bound = pad_source_short(map.pad[static_cast<size_t>(t)]); + } + const bool capturing = p.cap_target == t && p.cap_pad == !keyboard; + const float cx = img_x + ((hits[i].nx - kN64CropU0) / crop_w) * img_w; + const float cy = img_y + ((hits[i].ny - kN64CropV0) / crop_h) * img_h; + ImGui::SetCursorScreenPos(ImVec2(cx - chip_w * 0.5f, cy - chip_h * 0.5f)); + ImGui::PushID(t); + if (capturing) ImGui::PushStyleColor(ImGuiCol_Button, th.accent); + else if (live) ImGui::PushStyleColor(ImGuiCol_Button, th.good_button); + else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(th.control.x, th.control.y, th.control.z, 0.9f)); + if (ImGui::Button(capturing ? "[ ... ]" : bound.c_str(), ImVec2(chip_w, chip_h))) + begin_core_capture(p, t, !keyboard); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayNormal)) { + const PadTarget pt = static_cast<PadTarget>(t); + ImGui::SetTooltip("%s (%s)\nBound: %s", pad_target_label(pt, p.desc).c_str(), + pad_target_generic_name(pt), + keyboard ? bound.c_str() + : pad_source_display(map.pad[static_cast<size_t>(t)]).c_str()); + } + ImGui::PopID(); + } + ImGui::SetWindowFontScale(1.f); + ImGui::PopStyleVar(2); + ImGui::EndChild(); +} + +// One seat's controller page, as a modal over the seat cards: the Super +// Nintendo page's shape (header row, the pad inside a sized child, a footer). +void draw_core_configure_modal(CoreSettingsPage& p, const Theme& th, BoxartCache& boxart) { + using namespace retcomm::hub; + if (p.configuring < 0 || p.configuring >= kInputSeats) return; + const int seat = p.configuring; + SeatAssign& sa = p.input.seats[static_cast<size_t>(seat)]; + + ImGui::OpenPopup("##core_pad_cfg"); + const ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowSize(ImVec2(std::min(1100.f, vp->WorkSize.x * 0.96f), + std::min(860.f, vp->WorkSize.y * 0.94f)), + ImGuiCond_Appearing); + ImGui::SetNextWindowPos(vp->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("##core_pad_cfg", nullptr, ImGuiWindowFlags_None)) return; + + ImGui::TextColored(th.accent, "CONTROLLER - PLAYER %d", seat + 1); + ImGui::SameLine(); + { + constexpr float kCloseW = 80.f; + ImGui::SetCursorPosX(ImGui::GetWindowContentRegionMax().x - kCloseW); + if (ImGui::Button("Close", ImVec2(kCloseW, 0))) { + cancel_core_capture(p); + p.configuring = -1; + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return; + } + } + ImGui::Separator(); + ImGui::PushID(seat); + + // The map is the seat's device. An Auto seat can end up on either (port 1 + // falls back to the keyboard), so it is the one seat that picks. + const bool is_auto = sa.source == SeatAssign::Auto; + if (!is_auto) p.map_keyboard = sa.source == SeatAssign::Keyboard; + if (ImGui::BeginTable("core_cfg_top", is_auto ? 3 : 2, + ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoSavedSettings)) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::TextColored(th.text_muted, "Input source"); + if (is_auto) { + ImGui::TableNextColumn(); + ImGui::TextColored(th.text_muted, "Map shown"); + } + ImGui::TableNextColumn(); + ImGui::TextColored(th.text_muted, "Stick deadzone (all seats)"); + + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(-1.f); + draw_core_seat_source_combo(p, seat, connected_pads()); + if (is_auto) { + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(-1.f); + int which = p.map_keyboard ? 1 : 0; + static const char* kMaps[] = {"Gamepad", "Keyboard"}; + if (ImGui::Combo("##map", &which, kMaps, 2)) { + p.map_keyboard = which == 1; + cancel_core_capture(p); + } + } + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(-1.f); + int dz = p.input.deadzone_pct; + if (ImGui::SliderInt("##dz", &dz, 0, 50, dz == 0 ? "Off" : "%d%%")) p.input.deadzone_pct = dz; + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayNormal)) + ImGui::SetTooltip("Stick travel ignored around the centre, then rescaled so a full " + "push is still full. Off sends the stick as the pad reports it; " + "most N64 games have a deadzone of their own."); + ImGui::EndTable(); + } + + const float footer_h = ImGui::GetFrameHeightWithSpacing() * 2.f + 14.f; + const float body_h = std::max(220.f, ImGui::GetContentRegionAvail().y - footer_h); + ImGui::BeginChild("core_cfg_body", ImVec2(0, body_h), ImGuiChildFlags_None); + draw_core_pad_map(p, th, p.map_keyboard, boxart); + ImGui::EndChild(); + + constexpr float kBtnW = 150.f; + if (ImGui::Button("Reset this seat", ImVec2(kBtnW, 0))) { + p.input.maps[static_cast<size_t>(seat)] = default_input_bindings(); + p.status = "Player " + std::to_string(seat + 1) + "'s maps back to the defaults"; + } + ImGui::SameLine(0, 8); + if (ImGui::Button("Copy to all seats", ImVec2(kBtnW, 0))) { + for (int i = 0; i < kInputSeats; ++i) + p.input.maps[static_cast<size_t>(i)] = p.input.maps[static_cast<size_t>(seat)]; + p.status = "Copied player " + std::to_string(seat + 1) + "'s maps to every seat"; + } + if (!p.status.empty()) ImGui::TextColored(th.text_muted, "%s", p.status.c_str()); + + // The capture prompt, over the pad. + if (p.cap_target >= 0 && !ImGui::IsPopupOpen("Bind###core_bind")) + ImGui::OpenPopup("Bind###core_bind"); + center_modal_next(); + if (ImGui::BeginPopupModal("Bind###core_bind", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) { + if (p.cap_target < 0) { + ImGui::CloseCurrentPopup(); + } else { + const PadTarget pt = static_cast<PadTarget>(p.cap_target); + const std::string label = pad_target_label(pt, p.desc); + if (p.cap_pad) { + const std::uint64_t left = + kCaptureTimeoutNs - + std::min(kCaptureTimeoutNs, SDL_GetTicksNS() - p.cap_armed_ns); + ImGui::Text("Press a gamepad button, or push a stick or trigger, for %s.", + label.c_str()); + ImGui::TextColored(th.text_muted, "Cancels by itself in %d s, or with Esc.", + static_cast<int>(left / 1'000'000'000ull) + 1); + if (left == 0) cancel_core_capture(p); + } else { + ImGui::Text("Press a key for %s.", label.c_str()); + ImGui::TextColored(th.text_muted, "Esc cancels."); + } + ImGui::Dummy(ImVec2(0, 6)); + if (ImGui::Button("Unbind") && p.cap_target >= 0) { + InputBindings& m = p.input.maps[static_cast<size_t>(seat)]; + if (p.cap_pad) m.pad[static_cast<size_t>(p.cap_target)] = {}; + else m.key[static_cast<size_t>(p.cap_target)] = SDL_SCANCODE_UNKNOWN; + cancel_core_capture(p); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) cancel_core_capture(p); + } + ImGui::EndPopup(); + } + + ImGui::PopID(); + ImGui::EndPopup(); +} + +void draw_core_gamepads_tab(HubModel& hub, CoreSettingsPage& p, const Theme& th, float panel_h, + BoxartCache& boxart) { + using namespace retcomm::hub; + ImGui::BeginChild("core_gamepads", ImVec2(0, panel_h), ImGuiChildFlags_Borders, + page_wheel_flags(hub)); + ImGui::TextColored(th.text_muted, "CONTROLLERS"); + ImGui::Separator(); + ImGui::PushStyleColor(ImGuiCol_Text, th.text_muted); + ImGui::TextWrapped( + "Seats 1-4: the console's four controller ports. Each seat carries its own device and " + "maps \xe2\x80\x94 open it with Configure. Auto seats take the gamepads that are not " + "claimed, in the order they connected; with none, port 1 is the keyboard."); + ImGui::PopStyleColor(); + ImGui::Dummy(ImVec2(0, 8)); + + const std::vector<ConnectedPad> pads = connected_pads(); + std::vector<std::string> guids; + for (const auto& c : pads) guids.push_back(c.guid); + const auto plan = plan_seats(p.input, guids); + + const float gap = th.spacing_md; + const float availw = ImGui::GetContentRegionAvail().x; + int cols = std::clamp(static_cast<int>((availw + gap) / (280.f + gap)), 1, 4); + if (cols == 3) cols = 2; // four seats: a 2x2 grid reads better than 3+1 + const float cardw = (availw - gap * static_cast<float>(cols - 1)) / static_cast<float>(cols); + + for (int s = 0; s < kInputSeats; ++s) { + if (s % cols) ImGui::SameLine(0, gap); + else if (s) ImGui::Dummy(ImVec2(0, gap)); + ImGui::PushID(s); + ImGui::BeginChild("seatcard", ImVec2(cardw, 0), + ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + ImGui::TextColored(th.text_muted, "PLAYER %d", s + 1); + ImGui::Dummy(ImVec2(0, 4)); + ImGui::SetNextItemWidth(-1.f); + draw_core_seat_source_combo(p, s, pads); + ImGui::Dummy(ImVec2(0, 4)); + + const float cw = ImGui::GetContentRegionAvail().x; + const float half = (cw - th.spacing_sm) * 0.5f; + constexpr float btnh = 32.f; + if (ImGui::Button("Configure", ImVec2(half, btnh))) { + cancel_core_capture(p); + p.status.clear(); + p.configuring = s; + p.map_keyboard = p.input.seats[static_cast<size_t>(s)].source == SeatAssign::Keyboard || + (plan[static_cast<size_t>(s)].keyboard); + } + ImGui::SameLine(0, th.spacing_sm); + { + const SeatPlan& sp = plan[static_cast<size_t>(s)]; + std::string st = "no device"; + if (sp.pad >= 0) st = pads[static_cast<size_t>(sp.pad)].name; + else if (sp.keyboard) st = "keyboard"; + const bool on = sp.connected(); + const float sw = 18.f + ImGui::CalcTextSize(st.c_str()).x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.f, (half - sw) * 0.5f)); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (btnh - ImGui::GetTextLineHeight()) * 0.5f); + const ImVec2 c = ImGui::GetCursorScreenPos(); + constexpr float r = 4.f; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(c.x + r, c.y + ImGui::GetTextLineHeight() * 0.5f), r, + ImGui::ColorConvertFloat4ToU32(on ? th.good : th.text_muted)); + ImGui::Dummy(ImVec2(r * 2.f + 6.f, 0)); + ImGui::SameLine(0, 0); + ImGui::PushClipRect(ImGui::GetCursorScreenPos(), + ImVec2(ImGui::GetWindowPos().x + + ImGui::GetWindowContentRegionMax().x, + ImGui::GetCursorScreenPos().y + btnh), + true); + ImGui::TextColored(on ? th.good : th.text_muted, "%s", st.c_str()); + ImGui::PopClipRect(); + } + ImGui::EndChild(); + ImGui::PopID(); + } + ImGui::EndChild(); + draw_core_configure_modal(p, th, boxart); +} + +// The page. `standalone` is Direct mode, where nothing else carries the +// header's Save / Cancel, so the footer is the only place they live. +void draw_core_settings_panel(HubModel& hub, const Theme& th, BoxartCache& boxart) { + CoreSettingsPage& p = core_settings_page(); + tick_core_settings(hub); + + ImGui::BeginChild("core_settings", ImVec2(0, 0), ImGuiChildFlags_Borders, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + std::string title = p.desc.ok ? p.desc.core_id : std::string(platform_display_name(p.platform)); + for (char& c : title) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c))); + ImGui::PushStyleColor(ImGuiCol_Text, th.text_muted); + ImGui::Text("%s SETTINGS", title.c_str()); + ImGui::PopStyleColor(); + + constexpr float kTabW = 110.f; + { + const float right = ImGui::GetWindowContentRegionMax().x; + const float wrap_x = right - kTabW - 12.f; + const float desc_y = ImGui::GetCursorPosY(); + ImGui::PushTextWrapPos(wrap_x); + if (p.gamepads_tab) { + ImGui::TextWrapped( + "Controller seats for every %s title, here and in the library. Saved to " + "input.ini; the next game started reads them.", + platform_display_name(p.platform)); + } else { + std::string from; + if (p.desc_ready && p.desc.ok) + from = " Declared by " + p.desc.core_id + " " + p.desc.core_version + + (p.desc.cached ? " (remembered from an earlier run)." : "."); + ImGui::TextWrapped( + "What the core says it can be told when a game starts. Every %s title reads " + "these; a title can override them, and a --opt on the command line wins over " + "both.%s", + platform_display_name(p.platform), from.c_str()); + } + ImGui::PopTextWrapPos(); + ImGui::SameLine(); + ImGui::SetCursorPosY(desc_y); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), right - kTabW)); + if (p.gamepads_tab) { + if (good_button("System", th, ImVec2(kTabW, 0))) { + p.gamepads_tab = false; + p.configuring = -1; + cancel_core_capture(p); + } + } else if (good_button("Gamepads", th, ImVec2(kTabW, 0))) { + p.gamepads_tab = true; + } + } + ImGui::Separator(); + + // Which layer the System tab edits, when the page was opened for a title. + if (!p.gamepads_tab && !p.title_key.empty()) { + ImGui::AlignTextToFramePadding(); + ImGui::TextColored(th.text_muted, "Applies to"); + ImGui::SameLine(); + const std::string all = std::string("All ") + platform_display_name(p.platform) + " titles"; + const std::string one = (p.title_name.empty() ? p.title_key : p.title_name) + " only"; + if (p.title_scope ? ImGui::Button(all.c_str()) : accent_button(all.c_str(), th)) + p.title_scope = false; + ImGui::SameLine(); + if (p.title_scope ? accent_button(one.c_str(), th) : ImGui::Button(one.c_str())) + p.title_scope = true; + if (p.title_scope) { + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextColored(th.text_muted, "%zu overridden", p.title_opts.size()); + } + } + + const float footer_h = ImGui::GetFrameHeight() + 24.f; + const float gap = 12.f; + const float avail_x = ImGui::GetContentRegionAvail().x; + const float col_w = std::max(300.f, (avail_x - gap) * 0.5f); + const float panel_h = std::max(120.f, ImGui::GetContentRegionAvail().y - footer_h); + if (p.gamepads_tab) draw_core_gamepads_tab(hub, p, th, panel_h, boxart); + else draw_core_system_tab(hub, p, th, panel_h, col_w, gap); + + ImGui::Dummy(ImVec2(0, 12)); + const float footer_y = ImGui::GetCursorPosY(); + constexpr float kResetW = 150.f; + if (accent_button("Save", th, ImVec2(160, 0))) { + std::string err; + if (!save_core_settings(hub, &err)) { + hub.append_log(p.platform + " settings save failed: " + err); + p.status = "Save failed: " + err; + } else { + hub.show_toast("Saved!"); + close_core_settings(hub); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) close_core_settings(hub); + if (p.dirty()) { + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextColored(th.warn, "unsaved changes"); + } + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + { + // Clipped short of Reset: a deep data dir must not run under it. + const ImVec2 at = ImGui::GetCursorScreenPos(); + const float stop = ImGui::GetWindowPos().x + ImGui::GetWindowContentRegionMax().x - + kResetW - 12.f; + ImGui::PushClipRect(at, ImVec2(std::max(at.x, stop), at.y + ImGui::GetFrameHeight()), true); + ImGui::TextColored(th.text_muted, "%s", + retcomm::hub::platform_settings_dir(hub.paths.data_dir, p.platform) + .string() + .c_str()); + ImGui::PopClipRect(); + } + ImGui::SetCursorPos(ImVec2(ImGui::GetWindowContentRegionMax().x - kResetW, footer_y)); + if (ImGui::Button("Reset to Default", ImVec2(kResetW, 0))) { + cancel_core_capture(p); + if (p.gamepads_tab) { + const auto seats = p.input.seats; + p.input = retcomm::hub::PlatformInput{}; + p.input.seats = seats; + } else if (p.title_scope) { + p.title_opts.clear(); + } else { + p.plat_opts.clear(); + } + } + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayNormal)) { + ImGui::SetTooltip(p.gamepads_tab + ? "Every seat's maps and the deadzone back to the defaults.\n" + "Seat assignments are left unchanged." + : (p.title_scope ? "Drop this title's overrides: it reads the " + "all-titles values again." + : "Every option back to the core's own default.")); + } + ImGui::EndChild(); +} + +bool core_settings_dirty() { return core_settings_page().dirty(); } + +// The library's way in: the platform header's Configure, with the title when +// it was pressed on a title's page. +void open_core_settings_for_library(HubModel& hub, const std::string& platform, + const TitleRow* row) { + open_core_settings(hub, platform, row ? row->id : std::string(), + row ? row->name : std::string(), {}, {}); +} + +} // namespace + +// ---- Direct home ------------------------------------------------------------ + +enum class DirectPage { Home, Mods }; + +struct DirectHome { + DirectPlay d; + fs::path title_dir; + std::string title_key; + std::string platform; + std::string name, cartid, region; + + std::future<retcomm::hub::CoreDescription> describing; + retcomm::hub::CoreDescription desc; + bool desc_ready = false; + + DirectPage page = DirectPage::Home; + bool focus_pending = true; + + retcomm::ModScanResult mods; + std::string status; // the last thing that happened + bool status_bad = false; + std::vector<std::string> session_notes; // the last session's "mods:" lines +}; + +std::string platform_label(const std::string& platform) { + return platform.empty() ? std::string("this platform") : platform_display_name(platform); +} + +void set_direct_status(DirectHome& h, const std::string& s, bool bad) { + h.status = s; + h.status_bad = bad; + std::fprintf(stderr, "retro-hub: %s\n", s.c_str()); +} + +void refresh_direct_mods(DirectHome& h) { h.mods = retcomm::scan_game_mods(h.title_dir); } + +// What the game said about mods during the session just ended. The core logs +// its mod plan (applied, refused, or none) at load through rcore log(), which +// the runner writes to core.log as `<level>\t<text>`; a line the core indents +// continues the one before it. +std::vector<std::string> session_mod_lines(const fs::path& core_log) { + std::vector<std::string> out; + std::ifstream in(core_log); + bool in_mods = false; + for (std::string l; std::getline(in, l);) { + const auto tab = l.find('\t'); + const std::string text = tab == std::string::npos ? l : l.substr(tab + 1); + if (text.rfind("mods:", 0) == 0) { + out.push_back(text); + in_mods = true; + } else if (in_mods && !text.empty() && text[0] == ' ') { + out.push_back(text); + } else { + in_mods = false; + } + if (out.size() > 8) out.erase(out.begin()); + } + return out; +} + +void take_direct_focus(DirectHome& h) { + if (!h.focus_pending) return; + h.focus_pending = false; + ImGui::SetKeyboardFocusHere(); + ImGui::SetNavCursorVisible(true); +} + +void open_direct_page(DirectHome& h, HubModel& hub, DirectPage p) { + h.page = p; + h.focus_pending = true; + if (p == DirectPage::Mods) { + hub.show_mods_page = true; + hub.mods_focus_pending = true; + mods_page_state().title_id.clear(); // rescan on entry + } + if (p == DirectPage::Home) refresh_direct_mods(h); +} + +enum class DirectAction { None, Play, Quit }; + +void draw_direct_info(DirectHome& h, const HubModel& hub, const Theme& th) { + ImGui::BeginChild("direct_info", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::SetWindowFontScale(1.6f); + ImGui::TextWrapped("%s", h.name.c_str()); + ImGui::SetWindowFontScale(1.f); + std::string sub = platform_label(h.platform); + if (!h.cartid.empty()) sub += " \xC2\xB7 " + h.cartid; + if (!h.region.empty()) sub += " \xC2\xB7 region " + h.region; + ImGui::TextColored(th.text_muted, "%s", sub.c_str()); + + ImGui::Dummy(ImVec2(0, 14)); + ImGui::TextColored(th.text_muted, "Core"); + if (!h.desc_ready) { + ImGui::TextDisabled("Asking the core what it declares..."); + } else if (h.desc.ok) { + ImGui::Text("%s %s", h.desc.core_id.c_str(), h.desc.core_version.c_str()); + } else { + ImGui::TextColored(th.warn, "Its declarations are unavailable"); + ImGui::PushStyleColor(ImGuiCol_Text, th.text_muted); + ImGui::TextWrapped("%s", h.desc.error.c_str()); + ImGui::PopStyleColor(); + } + + ImGui::Dummy(ImVec2(0, 14)); + ImGui::TextColored(th.text_muted, "Files"); + ImGui::PushStyleColor(ImGuiCol_Text, th.text_muted); + auto row = [](const char* k, const std::string& v) { + if (v.empty()) return; + ImGui::TextWrapped("%s %s", k, v.c_str()); + }; + row("Game ", h.d.args.package.empty() ? h.d.args.core.string() : h.d.args.package.string()); + row("Core ", h.d.args.core.string()); + row("ROM ", h.d.args.rom); + row("Title ", h.title_dir.string()); + row("Saves ", (hub.paths.data_dir / "saves" / h.title_key).string()); + ImGui::PopStyleColor(); + ImGui::EndChild(); +} + +DirectAction draw_direct_actions(DirectHome& h, HubModel& hub, const Theme& th) { + DirectAction act = DirectAction::None; + ImGui::BeginChild("direct_actions", ImVec2(0, 0), + ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); + const float w = ImGui::GetContentRegionAvail().x; + const float big = ImGui::GetTextLineHeight() * 2.4f; + take_direct_focus(h); + if (good_button("Play", th, ImVec2(w, big))) act = DirectAction::Play; + if (!h.status.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, h.status_bad ? th.warn : th.text_muted); + ImGui::TextWrapped("%s", h.status.c_str()); + ImGui::PopStyleColor(); + } + ImGui::Dummy(ImVec2(0, 10)); + + const std::string settings_label = + (h.desc.ok ? h.desc.core_id : platform_label(h.platform)) + " Settings"; + if (ImGui::Button(settings_label.c_str(), ImVec2(w, 0))) { + open_core_settings(hub, h.platform, h.title_key, h.name, h.d.args.core, h.d.args.package, + h.desc_ready ? &h.desc : nullptr); + } + ImGui::TextColored(th.text_muted, "Controllers, video and the core's options"); + ImGui::Dummy(ImVec2(0, 6)); + + if (ImGui::Button("Mods", ImVec2(w, 0))) open_direct_page(h, hub, DirectPage::Mods); + { + size_t on = 0; + for (const auto& p : h.mods.packages) + for (const auto& f : p.features) on += f.enabled ? 1 : 0; + if (h.mods.packages.empty()) + ImGui::TextColored(th.text_muted, "None installed"); + else + ImGui::TextColored(th.text_muted, "%zu package%s, %zu feature%s on", + h.mods.packages.size(), h.mods.packages.size() == 1 ? "" : "s", + on, on == 1 ? "" : "s"); + if (!h.mods.errors.empty()) + ImGui::TextColored(th.warn, "%zu package%s the game will refuse", h.mods.errors.size(), + h.mods.errors.size() == 1 ? "" : "s"); + } + if (!h.session_notes.empty()) { + ImGui::Dummy(ImVec2(0, 4)); + ImGui::TextColored(th.text_muted, "Last session"); + ImGui::PushStyleColor(ImGuiCol_Text, th.text_muted); + for (const std::string& l : h.session_notes) ImGui::TextWrapped("%s", l.c_str()); + ImGui::PopStyleColor(); + } + + ImGui::Dummy(ImVec2(0, 18)); + if (ImGui::Button("Quit", ImVec2(w, 0))) act = DirectAction::Quit; + ImGui::EndChild(); + return act; +} + +// Header band of a page under Home: Back, and what the page is. +bool draw_direct_subpage_header(const Theme& th, const char* title) { + const bool back = ImGui::Button("Back"); + ImGui::SameLine(); + ImGui::SetWindowFontScale(1.3f); + ImGui::AlignTextToFramePadding(); + ImGui::TextColored(th.text_muted, "%s", title); + ImGui::SetWindowFontScale(1.f); + ImGui::Separator(); + return back; +} + +// Starts the session. False with *why set when it cannot, and the page says so. +bool start_direct_session(retcomm::hub::PlaySession& play, const DirectHome& h, + const HubModel& hub, std::string* why) { + const retcomm::ResolvedRunner rr = retcomm::resolve_runner(hub.paths, hub.exe_dir); + std::fprintf(stderr, "retro-hub: runner: %s: %s (%s)\n", + rr.source.empty() ? "none" : rr.source.c_str(), + rr.path.empty() ? "-" : rr.path.string().c_str(), rr.note.c_str()); + if (rr.path.empty()) { + *why = "No usable retro-core-runner was found (" + rr.note + + "). Place retro-core-runner beside retro-hub, or set RETRO_CORE_RUNNER."; + return false; + } + if (!h.d.args.package.empty() && rr.game_package == 0) { + *why = "The " + rr.source + " runner " + corelink_version_label(rr) + " at " + + rr.path.string() + " cannot load a game package (no 'game_package 1' in --version)."; + return false; + } + retcomm::hub::PlayArgs args = h.d.args; + apply_player_settings(args, hub.paths.data_dir, h.platform, h.title_key); + const fs::path session = hub.paths.data_dir / "sessions" / h.title_key; + const fs::path saves = hub.paths.data_dir / "saves" / h.title_key; + std::string err; + if (!play.start(args, rr.path, session, saves, &err)) { + *why = "Cannot start " + args.core.string() + ": " + err; + return false; + } + std::fprintf(stderr, "retro-hub: running %s (session %s, %zu option(s))\n", + h.name.c_str(), session.string().c_str(), args.options.size()); + return true; +} + +int run_direct_home(SDL_Window* window, UiScale& ui, const Theme& th, const DirectPlay& d, + HubModel& hub) { + DirectHome h; + h.d = d; + h.title_dir = direct_title_dir(d.args); + h.title_key = direct_title_key(d.args); + h.platform = rcore_manifest_platform(d.args.core); + const auto game = read_toml_section(h.title_dir / "game.toml", "game"); + auto field = [&](const char* k) { + const auto it = game.find(k); + return it == game.end() ? std::string() : it->second; + }; + h.name = field("name"); + if (h.name.empty()) h.name = h.title_key; + h.cartid = field("cartid"); + h.region = field("region"); + SDL_SetWindowTitle(window, (h.name + " - Retro Launcher").c_str()); + + { + const retcomm::ResolvedRunner rr = retcomm::resolve_runner(hub.paths, hub.exe_dir); + const fs::path core = d.args.core, pkg = d.args.package; + if (rr.path.empty()) { + std::promise<retcomm::hub::CoreDescription> p; + retcomm::hub::CoreDescription none; + none.error = "No usable retro-core-runner was found (" + rr.note + ")."; + p.set_value(none); + h.describing = p.get_future(); + } else { + h.describing = std::async(std::launch::async, [runner = rr.path, core, pkg] { + return retcomm::hub::describe_core(runner, core, pkg); + }); + } + } + // The library's Mods page reads the selected row: this title, its mods + // where the core reads them (the title dir). + { + TitleRow row; + row.id = h.title_key; + row.name = h.name; + row.platform = h.platform; + row.kind = "core"; + row.game_dir_override = h.title_dir.string(); + std::lock_guard<std::mutex> lock(hub.mu); + hub.rows = {row}; + hub.selected = 0; + } + refresh_direct_mods(h); + + DirectRuntimeUpdate update; + update.start(hub); + retcomm::hub::BoxartCache boxart; + + std::unique_ptr<retcomm::hub::PlaySession> play; + bool running = true; + while (running) { + hub_sync_open_gamepads(); + if (!h.desc_ready && h.describing.valid() && + h.describing.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + h.desc = h.describing.get(); + h.desc_ready = true; + if (h.platform.empty() && h.desc.ok) h.platform = h.desc.platforms; + if (h.desc.ok) { + std::string err; + retcomm::hub::save_description_cache(hub.paths.data_dir, h.platform, h.desc, &err); + } + std::fprintf(stderr, "retro-hub: describe: %s\n", + h.desc.ok ? (h.desc.core_id + " " + h.desc.core_version + ", " + + std::to_string(h.desc.options.size()) + " option(s), " + + std::to_string(h.desc.inputs.size()) + " input(s)").c_str() + : h.desc.error.c_str()); + } + + SDL_Event e; + while (SDL_PollEvent(&e)) { + scale_mouse_event(e, ui.coords); + if (e.type == SDL_EVENT_QUIT) running = false; + if (e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED && + e.window.windowID == SDL_GetWindowID(window)) + running = false; + if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat && e.key.key == SDLK_F11) { + const bool fs_now = (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0; + SDL_SetWindowFullscreen(window, !fs_now); + continue; + } + if (play) { + if (!play->handle_event(e)) ImGui_ImplSDL3_ProcessEvent(&e); + continue; + } + if (core_settings_capture_event(hub, e)) continue; + ImGui_ImplSDL3_ProcessEvent(&e); + } + if (play) play->tick(); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplSDL3_NewFrame(); + apply_ui_scale_frame(window, ui); + ImGui::NewFrame(); + + if (play) { + play->draw(); + if (play->finished()) { + play->shutdown(); + play.reset(); + h.session_notes = session_mod_lines(hub.paths.data_dir / "sessions" / + h.title_key / "core.log"); + set_direct_status(h, "Session ended.", false); + h.focus_pending = true; + } + } else { + const ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(vp->WorkPos); + ImGui::SetNextWindowSize(vp->WorkSize); + ImGui::Begin("##direct", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBringToFrontOnFocus); + DirectAction act = DirectAction::None; + if (hub.show_core_settings) { + draw_core_settings_panel(hub, th, boxart); + if (!hub.show_core_settings) h.focus_pending = true; + } else if (h.page == DirectPage::Mods) { + if (draw_direct_subpage_header(th, "")) { + open_direct_page(h, hub, DirectPage::Home); + } else { + ImGui::BeginChild("mods_page_host", ImVec2(0, 0), ImGuiChildFlags_NavFlattened); + draw_mods_page(hub, th); + ImGui::EndChild(); + } + } else { + draw_page_header(th, platform_label(h.platform).c_str(), ""); + const float total = ImGui::GetContentRegionAvail().x; + const float right = std::clamp(total * 0.38f, 300.f, 440.f); + ImGui::BeginChild("direct_info_host", + ImVec2(total - right - ImGui::GetStyle().ItemSpacing.x, 0)); + draw_direct_info(h, hub, th); + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("direct_actions_host", ImVec2(right, 0), + ImGuiChildFlags_NavFlattened); + act = draw_direct_actions(h, hub, th); + ImGui::EndChild(); + } + // B / Escape backs out of a page, unless something in front owns it + // or the settings page holds edits (Save or Cancel decides those). + const bool back_pressed = + !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel) && + !ImGui::IsAnyItemActive() && + (ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false) || + ImGui::IsKeyPressed(ImGuiKey_Escape, false)); + if (back_pressed) { + if (hub.show_core_settings) { + if (!core_settings_page().dirty() && core_settings_page().cap_target < 0) { + close_core_settings(hub); + h.focus_pending = true; + } + } else if (h.page != DirectPage::Home) { + open_direct_page(h, hub, DirectPage::Home); + } + } + ImGui::End(); + + if (act == DirectAction::Quit) running = false; + if (act == DirectAction::Play) { + auto p = std::make_unique<retcomm::hub::PlaySession>(); + std::string why; + if (start_direct_session(*p, h, hub, &why)) { + play = std::move(p); + h.session_notes.clear(); + set_direct_status(h, "Playing.", false); + } else { + set_direct_status(h, why, true); + } + } + } + + ImGui::Render(); + int fb_w = 0, fb_h = 0; + SDL_GetWindowSizeInPixels(window, &fb_w, &fb_h); + glViewport(0, 0, fb_w, fb_h); + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + SDL_GL_SwapWindow(window); + } + if (play) play->shutdown(); + return 0; +} + +int run_direct_play(SDL_Window* window, UiScale& ui, const DirectPlay& d, const HubModel& hub) { + retcomm::hub::PlaySession play; + const bool packaged = !d.args.package.empty(); + // Sessions and saves belong to the title. With a package that is the shim + // (<slug>_game): the core is the generic one every packaged title shares. + const std::string stem = (packaged ? d.args.package : d.args.core).stem().string(); + // resolve_runner: RETRO_CORE_RUNNER (override), else the newest of the + // bundled runner and the ones the runtime updater installed. + const retcomm::ResolvedRunner rr = retcomm::resolve_runner(hub.paths, hub.exe_dir); + std::fprintf(stderr, "retro-hub: runner: %s: %s (%s)\n", + rr.source.empty() ? "none" : rr.source.c_str(), + rr.path.empty() ? "-" : rr.path.string().c_str(), rr.note.c_str()); + DirectRuntimeUpdate update; + update.start(hub); + if (rr.path.empty()) { + return run_direct_error(window, ui, "No usable retro-core-runner was found.", + {rr.note, + "Place retro-core-runner beside retro-hub, or set " + "RETRO_CORE_RUNNER to its path."}, + &update); + } + if (packaged && rr.game_package == 0) { + return run_direct_error( + window, ui, "This runner cannot load a game package.", + {"--package " + d.args.package.string() + " needs a retro-core-runner that reports " + "game_package 1 in --version.", + "The " + rr.source + " runner " + corelink_version_label(rr) + " at " + + rr.path.string() + " does not (" + rr.note + ").", + "Use a newer Retro-Runtime runner (RETRO_CORE_RUNNER=<path>, or beside retro-hub)."}, + &update); + } + const fs::path runner = rr.path; + const fs::path session = hub.paths.data_dir / "sessions" / stem; + const fs::path saves = hub.paths.data_dir / "saves" / stem; + // The same bindings and option values the home page sets up. + retcomm::hub::PlayArgs args = d.args; + apply_player_settings(args, hub.paths.data_dir, rcore_manifest_platform(d.args.core), stem); + std::string err; + if (!play.start(args, runner, session, saves, &err)) { + return run_direct_error(window, ui, "Cannot start " + d.args.core.string(), {err}, nullptr); + } + std::fprintf(stderr, "retro-hub: running %s%s%s (session %s, saves %s)\n", + d.args.core.string().c_str(), packaged ? " with package " : "", + packaged ? d.args.package.string().c_str() : "", session.string().c_str(), + saves.string().c_str()); + bool running = true; + while (running && !play.finished()) { + hub_sync_open_gamepads(); + SDL_Event e; + while (SDL_PollEvent(&e)) { + scale_mouse_event(e, ui.coords); + if (!play.handle_event(e)) ImGui_ImplSDL3_ProcessEvent(&e); + if (e.type == SDL_EVENT_QUIT) running = false; + if (e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED && + e.window.windowID == SDL_GetWindowID(window)) + running = false; + if (e.type == SDL_EVENT_KEY_DOWN && !e.key.repeat && e.key.key == SDLK_F11) { + const bool fs_now = (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0; + SDL_SetWindowFullscreen(window, !fs_now); + } + } + play.tick(); + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplSDL3_NewFrame(); + apply_ui_scale_frame(window, ui); + ImGui::NewFrame(); + play.draw(); + ImGui::Render(); + int fb_w = 0, fb_h = 0; + SDL_GetWindowSizeInPixels(window, &fb_w, &fb_h); + glViewport(0, 0, fb_w, fb_h); + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + SDL_GL_SwapWindow(window); + } + play.shutdown(); + return 0; +} +#endif + +#if !defined(RETCOMM_COMMIT) +#define RETCOMM_COMMIT "" +#endif + +#if defined(RETCOMM_HUB_HAVE_PLAY) +// Revision of the Direct-mode command line (the flags parse_direct_play reads). +// It goes up whenever that set of flags changes, so a tool can require the +// revision that introduced the flags it passes; each revision still accepts +// every earlier revision's command line unless docs/RELEASES.md says otherwise. +// 1 --run-core --rom --title-dir --tpak1-rom --tpak1-save --no-gl --opt +// 2 + --package (a GAME_PACKAGE core's game shim) +// 3 + --boot. Without it Direct mode now opens the title's home page +// (Play, Controls, Core Settings, Mods) instead of playing at once; +// --boot is the revision-2 behaviour. +constexpr int kDirectModeCliRevision = 3; +#endif + +// `retro-hub --version`: one `key value` per line, exit 0, before SDL starts, +// so a tool (and the release packager) can check an unpacked hub without a +// display. Same shape as `retro-core-runner --version` (Retro-Runtime +// docs/RELEASES.md); the bare-archive contract is in docs/RELEASES.md. +int print_hub_version() { + const char* commit = RETCOMM_COMMIT; + std::printf("retro-hub %s\n", RETCOMM_VERSION); + std::printf("version %s\n", RETCOMM_VERSION); + std::printf("commit %s\n", commit[0] ? commit : "unknown"); +#if defined(RETCOMM_HUB_HAVE_PLAY) + std::printf("link_protocol %u.%u\n", static_cast<unsigned>(retro::corelink::kProtocolMajor), + static_cast<unsigned>(retro::corelink::kProtocolMinor)); + std::printf("rcore_abi_major %u\n", static_cast<unsigned>(RCORE_ABI_MAJOR)); + std::printf("rcore_draft_revision %u\n", static_cast<unsigned>(RCORE_DRAFT_REVISION)); + std::printf("direct_mode %d\n", kDirectModeCliRevision); + std::printf("direct_mode_flags --run-core --package --rom --title-dir --tpak1-rom " + "--tpak1-save --no-gl --opt --boot\n"); + // resolve_runner (src/update/runtime_update.cpp): the override, else the + // newest of the bundled runner and the ones the runtime updater installed. + std::printf("runner_lookup RETRO_CORE_RUNNER exe_dir/retro-core-runner " + "data_dir/runtime/<version>/retro-core-runner\n"); +#else + std::printf("direct_mode 0\n"); // a build without the hub <-> runner link +#endif + return 0; +} + +int main(int argc, char** argv) { + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--version") return print_hub_version(); + } + // `--play <title-id>`: start normally, press Play on that title, exit when + // it closes. What `retcomm launch` hands a core title to, so a Steam + // shortcut waits for the game. + std::string play_title_id; + for (int i = 1; i + 1 < argc; ++i) { + if (std::string(argv[i]) == "--play") play_title_id = argv[i + 1]; + } +#if defined(RETCOMM_HUB_HAVE_PLAY) + const DirectPlay direct = parse_direct_play(argc, argv); + if (direct.active && direct.args.rom.empty()) { + std::fprintf(stderr, "retro-hub: --run-core needs --rom <image>\n"); + return 2; + } +#endif + + // Steam only reads its screen-keyboard hint at startup. + retcomm::hub::osk_configure_hints(); + + if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) { + std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); + return 1; + } + + const char* glsl = "#version 150"; + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); + SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); + + // HIGH_PIXEL_DENSITY asks for a native-resolution backbuffer where the + // platform scales windows for us (macOS retina, Wayland); on Windows and X11 + // it is a no-op. Either way the pixel size is the truth and UiScale turns it + // back into the logical 1280x800 this UI is written against. + SDL_Window* window = + SDL_CreateWindow("Retro Launcher", 1280, 800, + SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | + SDL_WINDOW_HIGH_PIXEL_DENSITY); + if (!window) { + std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); + return 1; + } + SDL_GLContext gl = SDL_GL_CreateContext(window); + if (!gl) { + std::fprintf(stderr, "SDL_GL_CreateContext failed: %s\n", SDL_GetError()); + return 1; + } + SDL_GL_MakeCurrent(window, gl); + SDL_GL_SetSwapInterval(1); + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + // Controller first: the D-pad / left stick move the focus ring, A activates, + // B backs out. Mouse use hides the ring again (ConfigNavCursorVisibleAuto). + io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; + io.IniFilename = nullptr; + // RETCOMM_HUB_NAV_DEBUG=1 prints ImGui's own focus/nav event log to the + // terminal — the only honest way to see where a controller's focus went. + if (const char* nav_dbg = std::getenv("RETCOMM_HUB_NAV_DEBUG"); nav_dbg && nav_dbg[0] == '1') { + ImGuiContext& g = *ImGui::GetCurrentContext(); + g.DebugLogFlags |= ImGuiDebugLogFlags_EventFocus | ImGuiDebugLogFlags_EventNav | + ImGuiDebugLogFlags_OutputToTTY; + } + + // The atlas has to be built before the first frame, and config.json is not + // loaded yet (that needs hub.paths below), so start from the display's own + // scale. A pinned config ui_scale is applied a few lines further down. + UiScale ui = resolve_ui_scale(window, 0.f); + load_hub_fonts(ui.px); + + const Theme th = retcomm::hub::crt_theme(); + retcomm::hub::apply_imgui_style(th); + + ImGui_ImplSDL3_InitForOpenGL(window, gl); + // Any connected pad drives the hub, not only the first one SDL enumerated. + ImGui_ImplSDL3_SetGamepadMode(ImGui_ImplSDL3_GamepadMode_AutoAll); ImGui_ImplOpenGL3_Init(glsl); HubModel hub; @@ -9184,7 +10710,8 @@ int main(int argc, char** argv) { #if defined(RETCOMM_HUB_HAVE_PLAY) if (direct.active) { - const int rc = run_direct_play(window, ui, direct, hub); + const int rc = direct.boot ? run_direct_play(window, ui, direct, hub) + : run_direct_home(window, ui, th, direct, hub); ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplSDL3_Shutdown(); ImGui::DestroyContext(); @@ -9294,6 +10821,9 @@ int main(int argc, char** argv) { } continue; } +#endif +#if defined(RETCOMM_HUB_HAVE_PLAY) + if (core_settings_capture_event(hub, e)) continue; // binding a seat's input #endif if (poll_snes_bind_capture(hub, e) || poll_psx_bind_capture(hub, e)) { // Still feed ImGui so the modal stays responsive, but skip @@ -9404,6 +10934,10 @@ int main(int argc, char** argv) { args.core = req->core_library; args.rom = req->rom; args.title_dir = req->title_dir; + // The platform's bindings and this title's option values: the + // files Direct mode's Controls and Core Settings pages write. + apply_player_settings(args, hub.paths.data_dir, + rcore_manifest_platform(args.core), req->title_id); const fs::path session = hub.paths.data_dir / "sessions" / req->title_id; const fs::path saves = hub.paths.data_dir / "saves" / req->title_id; auto p = std::make_unique<retcomm::hub::PlaySession>(); @@ -9565,6 +11099,12 @@ int main(int argc, char** argv) { ImGui::BeginChild("snes_settings_host", ImVec2(0, 0), ImGuiChildFlags_NavFlattened); draw_snes_settings_panel(hub, th, boxart); ImGui::EndChild(); +#if defined(RETCOMM_HUB_HAVE_PLAY) + } else if (hub.show_core_settings) { + ImGui::BeginChild("core_settings_host", ImVec2(0, 0), ImGuiChildFlags_NavFlattened); + draw_core_settings_panel(hub, th, boxart); + ImGui::EndChild(); +#endif } else if (hub.library_nav == retcomm::hub::LibraryNav::Detail) { // One title, full window: the page splits itself into info + actions. ImGui::BeginChild("title_page_host", ImVec2(0, 0), ImGuiChildFlags_NavFlattened); diff --git a/src/hub/hub_model.hpp b/src/hub/hub_model.hpp index d9bc968..217ed02 100644 --- a/src/hub/hub_model.hpp +++ b/src/hub/hub_model.hpp @@ -208,6 +208,9 @@ struct TitleRow { std::string active_texture_pack; std::string install_root; std::string binary_path; + // Where the game reads its mods/ tree when that is not beside binary_path: + // Direct mode's title dir, where a game package's game.toml and mods live. + std::string game_dir_override; std::string runtime; // "native" | "wine" std::string author; // GitHub owner from release.github std::string github_url; // source repo URL @@ -462,6 +465,7 @@ struct HubModel { bool show_romm_settings = false; bool show_psx_settings = false; // global PlayStation Configure page bool show_snes_settings = false; // global Super Nintendo Configure page + bool show_core_settings = false; // an rcore platform's settings (n64lle Settings) bool show_setup = false; // first-time library/BIOS/RomM wizard SetupPath setup_path = SetupPath::Chooser; // Advanced wizard: 0 = Retro data folder, 1 = library roots (+ optional diff --git a/src/hub/hub_play.cpp b/src/hub/hub_play.cpp index d59b97e..fb96c44 100644 --- a/src/hub/hub_play.cpp +++ b/src/hub/hub_play.cpp @@ -19,12 +19,6 @@ constexpr double kTargetQueuedMs = 60.0; constexpr std::uint64_t kPersistEveryNs = 5 * kNsPerSec; constexpr std::uint32_t kSeats = 4; -std::int16_t axis_of(SDL_Gamepad* g, SDL_GamepadAxis a, bool invert = false) { - const int v = SDL_GetGamepadAxis(g, a); - if (!invert) return static_cast<std::int16_t>(v); - return static_cast<std::int16_t>(std::clamp(-v, -32768, 32767)); -} - // The last `n` lines of a text file, for the fault screen. std::vector<std::string> tail_lines(const fs::path& p, std::size_t n) { std::ifstream in(p); @@ -92,80 +86,8 @@ void PlaySession::set_paused(bool paused) { } void PlaySession::fill_pads(rcore_pad pads[RCORE_MAX_SEATS]) const { - for (std::uint32_t i = 0; i < RCORE_MAX_SEATS; ++i) { - pads[i] = rcore_pad{}; - pads[i].struct_size = sizeof(rcore_pad); - } - int count = 0; - SDL_JoystickID* ids = SDL_GetGamepads(&count); - std::uint32_t seat = 0; - for (int i = 0; ids && i < count && seat < kSeats; ++i) { - SDL_Gamepad* g = SDL_GetGamepadFromID(ids[i]); - if (!g) continue; - rcore_pad& p = pads[seat++]; - p.connected = 1; - struct Map { - SDL_GamepadButton b; - std::uint32_t bit; - }; - static constexpr Map kMap[] = { - {SDL_GAMEPAD_BUTTON_SOUTH, RCORE_PAD_SOUTH}, - {SDL_GAMEPAD_BUTTON_EAST, RCORE_PAD_EAST}, - {SDL_GAMEPAD_BUTTON_WEST, RCORE_PAD_WEST}, - {SDL_GAMEPAD_BUTTON_NORTH, RCORE_PAD_NORTH}, - {SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, RCORE_PAD_L1}, - {SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, RCORE_PAD_R1}, - {SDL_GAMEPAD_BUTTON_LEFT_STICK, RCORE_PAD_L3}, - {SDL_GAMEPAD_BUTTON_RIGHT_STICK, RCORE_PAD_R3}, - {SDL_GAMEPAD_BUTTON_START, RCORE_PAD_START}, - {SDL_GAMEPAD_BUTTON_BACK, RCORE_PAD_SELECT}, - {SDL_GAMEPAD_BUTTON_DPAD_UP, RCORE_PAD_DPAD_UP}, - {SDL_GAMEPAD_BUTTON_DPAD_DOWN, RCORE_PAD_DPAD_DOWN}, - {SDL_GAMEPAD_BUTTON_DPAD_LEFT, RCORE_PAD_DPAD_LEFT}, - {SDL_GAMEPAD_BUTTON_DPAD_RIGHT, RCORE_PAD_DPAD_RIGHT}, - }; - for (const Map& m : kMap) { - if (SDL_GetGamepadButton(g, m.b)) p.buttons |= m.bit; - } - // rcore: LY/RY positive = up; SDL: positive = down. - p.axes[RCORE_AXIS_LX] = axis_of(g, SDL_GAMEPAD_AXIS_LEFTX); - p.axes[RCORE_AXIS_LY] = axis_of(g, SDL_GAMEPAD_AXIS_LEFTY, true); - p.axes[RCORE_AXIS_RX] = axis_of(g, SDL_GAMEPAD_AXIS_RIGHTX); - p.axes[RCORE_AXIS_RY] = axis_of(g, SDL_GAMEPAD_AXIS_RIGHTY, true); - p.axes[RCORE_AXIS_LT] = axis_of(g, SDL_GAMEPAD_AXIS_LEFT_TRIGGER); - p.axes[RCORE_AXIS_RT] = axis_of(g, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER); - if (p.axes[RCORE_AXIS_LT] > 16384) p.buttons |= RCORE_PAD_L2; - if (p.axes[RCORE_AXIS_RT] > 16384) p.buttons |= RCORE_PAD_R2; - } - SDL_free(ids); - - // No gamepad: the keyboard is port 1. - if (seat == 0) { - rcore_pad& p = pads[0]; - p.connected = 1; - const bool* k = SDL_GetKeyboardState(nullptr); - struct KMap { - SDL_Scancode sc; - std::uint32_t bit; - }; - static constexpr KMap kKeys[] = { - {SDL_SCANCODE_X, RCORE_PAD_SOUTH}, {SDL_SCANCODE_C, RCORE_PAD_EAST}, - {SDL_SCANCODE_Z, RCORE_PAD_WEST}, {SDL_SCANCODE_S, RCORE_PAD_NORTH}, - {SDL_SCANCODE_Q, RCORE_PAD_L1}, {SDL_SCANCODE_E, RCORE_PAD_R1}, - {SDL_SCANCODE_LSHIFT, RCORE_PAD_L2}, {SDL_SCANCODE_RETURN, RCORE_PAD_START}, - {SDL_SCANCODE_BACKSPACE, RCORE_PAD_SELECT}, {SDL_SCANCODE_UP, RCORE_PAD_DPAD_UP}, - {SDL_SCANCODE_DOWN, RCORE_PAD_DPAD_DOWN}, {SDL_SCANCODE_LEFT, RCORE_PAD_DPAD_LEFT}, - {SDL_SCANCODE_RIGHT, RCORE_PAD_DPAD_RIGHT}, - }; - for (const KMap& m : kKeys) { - if (k[m.sc]) p.buttons |= m.bit; - } - // IJKL as the left stick, full throw. - p.axes[RCORE_AXIS_LX] = static_cast<std::int16_t>((k[SDL_SCANCODE_L] ? 32767 : 0) - - (k[SDL_SCANCODE_J] ? 32767 : 0)); - p.axes[RCORE_AXIS_LY] = static_cast<std::int16_t>((k[SDL_SCANCODE_I] ? 32767 : 0) - - (k[SDL_SCANCODE_K] ? 32767 : 0)); - } + // The platform's seats: which device each port reads, and its map. + fill_pads_from_input(args_.input, pads, kSeats); } void PlaySession::grant_if_due() { diff --git a/src/hub/hub_play.hpp b/src/hub/hub_play.hpp index 379403a..ea076eb 100644 --- a/src/hub/hub_play.hpp +++ b/src/hub/hub_play.hpp @@ -6,6 +6,7 @@ // retro-core-runner, reached through corelink::CoreLink. #include "core_link.hpp" // Retro-Runtime: retro_corelink +#include "hub/hub_core_settings.hpp" #include <SDL3/SDL.h> @@ -32,6 +33,9 @@ struct PlayArgs { fs::path tpak_save; std::map<std::string, std::string> options; bool gl = true; + // Which device each seat reads and what its inputs drive + // (hub_core_settings.hpp): the platform's saved seats, or the defaults. + PlatformInput input; }; class PlaySession { diff --git a/tests/hub_core_settings_test.cpp b/tests/hub_core_settings_test.cpp new file mode 100644 index 0000000..ae722e2 --- /dev/null +++ b/tests/hub_core_settings_test.cpp @@ -0,0 +1,281 @@ +// hub_core_settings: --describe parsing, the bindings and option files, and +// the labels a page shows. Run as `retro-hub-settings-test <scratch dir>`. +// +// The describe records below are real: retro-core-runner --describe against +// n64lle's generic core 0.374.0 (2026-09-26), trimmed. + +#include "hub/hub_core_settings.hpp" +#include "retcomm/mods.hpp" + +#include <cstdio> +#include <filesystem> +#include <fstream> +#include <sstream> +#include <string> + +namespace fs = std::filesystem; +using namespace retcomm::hub; + +namespace { + +int g_failures = 0; + +void check(bool ok, const char* what) { + if (!ok) { + std::fprintf(stderr, "FAIL: %s\n", what); + ++g_failures; + } +} + +const char* kN64 = + "describe\t1\n" + "core\tn64lle\t0.374.0\tn64\n" + "option\tvideo.renderer\tenum\trestart,netplay\t1\ttitle\t0\t0\tRenderer\tThe RDP rasterizer.\n" + "value\tvideo.renderer\ttitle\n" + "value\tvideo.renderer\tsoftware\n" + "value\tvideo.renderer\topengl\n" + "value\tvideo.renderer\tlle-software\n" + "option\tdeveloper.state_hash_every\tint\trestart,developer\t1\t0\t0\t1000000\tState-hash " + "trace interval\tEvery N fields.\n" + "option\tengine.cache\tstring\trestart,developer\t0\t\t0\t0\tN64LLE_CACHE\tA knob.\n" + "input\t1\t0\t0\tA\n" + "input\t4\t0\t0\tB\n" + "input\t64\t0\t0\tZ\n" + "input\t0\t1\t0\tControl Stick (left/right)\n" + "input\t0\t2\t0\tControl Stick (up/down)\n" + "input\t0\t4\t1\tC-Up\n" + "input\t0\t4\t-1\tC-Down\n"; + +void test_parse_n64() { + CoreDescription d; + std::string err; + check(parse_core_description(kN64, d, &err), "n64 description parses"); + check(d.ok && d.core_id == "n64lle" && d.core_version == "0.374.0" && d.platforms == "n64", + "core line"); + check(d.options.size() == 3, "three options"); + const CoreOptionDecl& r = d.options[0]; + check(r.key == "video.renderer" && r.type == "enum" && r.restart && r.netplay && + !r.developer && r.has_default && r.default_value == "title", + "renderer option fields"); + check(r.values.size() == 4 && r.values[3] == "lle-software", "renderer values in order"); + check(d.options[1].int_max == 1000000 && d.options[1].developer, "int range and flags"); + check(!d.options[2].has_default && d.options[2].default_value.empty(), "no default"); + check(d.inputs.size() == 7, "seven inputs"); + + check(pad_target_label(PadTarget::South, d) == "A", "South is A"); + check(pad_target_label(PadTarget::West, d) == "B", "West is B"); + check(pad_target_label(PadTarget::L2, d) == "Z", "L2 is Z"); + check(pad_target_label(PadTarget::LyPlus, d) == "Control Stick (up/down): up", + "a whole-axis descriptor names each half"); + check(pad_target_label(PadTarget::RyPlus, d) == "C-Up", "C-Up on the right stick's up"); + check(pad_target_label(PadTarget::RyMinus, d) == "C-Down", "C-Down on its down"); + check(!pad_target_declared(PadTarget::East, d), "East is not an N64 input"); + check(pad_target_label(PadTarget::East, d) == pad_target_generic_name(PadTarget::East), + "an undeclared target keeps its generic name"); +} + +void test_parse_edges() { + CoreDescription d; + std::string err; + // Escapes, and stderr noise mixed in (the spawn log holds both streams). + check(parse_core_description("core: loading\n" + "describe\t1\n" + "option\tk\tstring\t-\t0\t\t0\t0\ttab\\there\tline\\nnext \\\\ end\n", + d, &err), + "escaped description parses"); + check(d.options.size() == 1 && d.options[0].label == "tab\there" && + d.options[0].description == "line\nnext \\ end", + "escapes decode"); + check(!parse_core_description("describe\t2\n", d, &err), "unknown revision refused"); + check(!parse_core_description("retro-core-runner: unknown flag --describe\n", d, &err), + "no header refused"); + check(!parse_core_description("describe\t1\nvalue\tnope\tx\n", d, &err), + "an orphan value refused"); +} + +void test_bindings(const fs::path& dir) { + const InputBindings def = default_input_bindings(); + std::vector<std::string> warn; + check(load_platform_input(dir, "n64", &warn) == PlatformInput{}, "no file is the defaults"); + check(PlatformInput{}.maps[3] == def && PlatformInput{}.deadzone_pct == 0, + "every seat starts on the default maps, deadzone off"); + + // The defaults are the table the hub used before bindings existed. + auto pad = [&](PadTarget t) { return def.pad[static_cast<int>(t)]; }; + check(pad(PadTarget::South) == PadSource{PadSource::Button, SDL_GAMEPAD_BUTTON_SOUTH}, + "South from SDL South"); + check(pad(PadTarget::L2) == PadSource{PadSource::AxisPlus, SDL_GAMEPAD_AXIS_LEFT_TRIGGER}, + "L2 from the left trigger"); + check(pad(PadTarget::LyPlus) == PadSource{PadSource::AxisMinus, SDL_GAMEPAD_AXIS_LEFTY}, + "stick up is SDL's negative Y"); + check(def.key[static_cast<int>(PadTarget::South)] == SDL_SCANCODE_X, "X key is South"); + check(def.key[static_cast<int>(PadTarget::RyPlus)] == SDL_SCANCODE_T && + def.key[static_cast<int>(PadTarget::RxPlus)] == SDL_SCANCODE_H, + "the keyboard reaches the right stick (n64lle's C buttons)"); + + PlatformInput in; + in.deadzone_pct = 12; + in.seats[0] = {SeatAssign::Keyboard, "", ""}; + in.seats[1] = {SeatAssign::Gamepad, "03000000de280000ff11000001000000", "Steam Pad"}; + in.seats[3] = {SeatAssign::None, "", ""}; + in.maps[1].pad[static_cast<int>(PadTarget::South)] = {PadSource::Button, SDL_GAMEPAD_BUTTON_EAST}; + in.maps[1].pad[static_cast<int>(PadTarget::R3)] = {}; + in.maps[0].key[static_cast<int>(PadTarget::Start)] = SDL_SCANCODE_SPACE; + in.maps[0].key[static_cast<int>(PadTarget::L2)] = SDL_SCANCODE_UNKNOWN; + std::string err; + check(save_platform_input(dir, "n64", in, &err), "seats save"); + warn.clear(); + check(load_platform_input(dir, "n64", &warn) == in, "seats round-trip"); + check(warn.empty(), "a saved file loads without warnings"); + check(load_platform_input(dir, "psx") == PlatformInput{}, "another platform is untouched"); + + PadSource s; + check(pad_source_from_string("lefttrigger+", s) && s.kind == PadSource::AxisPlus, + "axis source parses"); + check(!pad_source_from_string("nonsense", s), "unknown source refused"); +} + +void test_seat_plan() { + PlatformInput in; // all Auto + auto p = plan_seats(in, {}); + check(p[0].keyboard && !p[1].connected(), "no pads: the keyboard is port 1"); + p = plan_seats(in, {"g1", "g2"}); + check(p[0].pad == 0 && p[1].pad == 1 && !p[0].keyboard && !p[2].connected(), + "Auto seats take pads in order, no keyboard"); + + in.seats[1] = {SeatAssign::Gamepad, "g1", "first"}; + p = plan_seats(in, {"g1", "g2"}); + check(p[1].pad == 0 && p[0].pad == 1, "a Gamepad seat claims its pad before Auto seats"); + p = plan_seats(in, {"g2"}); + check(!p[1].connected() && p[0].pad == 0, "a Gamepad seat whose pad is absent is unplugged"); + + in.seats[2] = {SeatAssign::Keyboard, "", ""}; + p = plan_seats(in, {}); + check(p[2].keyboard && !p[0].keyboard, "a Keyboard seat stops port 1's keyboard fallback"); + in.seats[0] = {SeatAssign::None, "", ""}; + p = plan_seats(in, {"g2"}); + check(!p[0].connected() && p[3].pad == 0, "None is unplugged; the pad goes to the next Auto"); +} + +void test_options(const fs::path& dir) { + check(load_core_options(dir, "n64", "pokemonstadiumtest_game").empty(), "no options file"); + std::string perr; + check(save_core_options(dir, "n64", "", {{"video.renderer", "software"}, {"a", "1"}}, &perr), + "platform options save"); + check(load_core_options(dir, "n64", "").at("video.renderer") == "software", + "platform options round-trip"); + const auto layered = layer_core_options({{"video.renderer", "software"}, {"a", "1"}}, + {{"video.renderer", "opengl"}}, {{"a", "2"}}); + check(layered.at("video.renderer") == "opengl" && layered.at("a") == "2", + "platform < title < command line"); + std::map<std::string, std::string> v{{"video.renderer", "opengl"}, + {"developer.state_hash_every", "60"}}; + std::string err; + check(save_core_options(dir, "n64", "pokemonstadiumtest_game", v, &err), "options save"); + check(load_core_options(dir, "n64", "pokemonstadiumtest_game") == v, "options round-trip"); + check(!save_core_options(dir, "n64", "t", {{"k", "a\nb"}}, &err), + "a value with a newline refused"); +} + +void write(const fs::path& p, const std::string& body) { + fs::create_directories(p.parent_path()); + std::ofstream(p, std::ios::binary) << body; +} + +std::string slurp(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +// An n64lle guarded-write package (n64lle docs/MODDING.md 5.1) in a title dir. +void test_n64lle_mods(const fs::path& dir) { + const fs::path game = dir / "title"; + write(game / "mods/bundled/yourname.first-mod/manifest.toml", + "format_version = 1\n" + "id = \"yourname.first-mod\"\n" + "version = \"0.1.0\"\n\n" + "[target]\n" + "game_id = \"pokemon-stadium\"\n" + "rom_sha256 = \"502f6082a6436012a8b61419435dec1388869a90ed870e87e2d7bee88f831519\"\n\n" + "[feature.main]\n" + "default_enabled = true\n\n" + "[feature.other]\n\n" + "[patch.one]\nfeature = \"main\"\naddress = \"0x10\"\nexpected = \"00\"\nreplace = \"01\"\n" + "[patch.two]\nfeature = \"main\"\naddress = \"0x11\"\nexpected = \"00\"\nreplace = \"01\"\n"); + // Named for another id: the game refuses it, so the page must not list it. + write(game / "mods/installed/wrong-dir/manifest.toml", + "format_version = 1\nid = \"other\"\nversion = \"1.0.0\"\n[target]\n" + "game_id = \"x\"\nrom_sha256 = \"00\"\n[feature.main]\n"); + + retcomm::ModScanResult r = retcomm::scan_game_mods(game); + check(r.layout == retcomm::ModLayout::N64lle, "n64lle layout detected"); + check(r.packages.size() == 1 && r.errors.size() == 1, "one package, one refusal"); + if (r.packages.size() != 1) return; + const retcomm::ModPackageInfo& p = r.packages[0]; + check(p.id == "yourname.first-mod" && p.version == "0.1.0", "id and version"); + check(p.features.size() == 2 && p.features[0].id == "main" && p.features[0].enabled && + !p.features[1].enabled, + "features with manifest defaults"); + check(p.features[0].description == "2 guarded writes.", "writes counted per feature"); + + std::string err; + // Turning `other` on makes the section explicit: main (a default) stays on. + check(retcomm::set_scanned_mod_enabled(r, game, p, "other", true, &err), "toggle writes"); + const std::string sel = slurp(game / "mods.toml"); + check(sel.find("\n[yourname.first-mod]\n") != std::string::npos, + "dotted id bare, as n64lle writes it"); + check(sel.find("enabled = \"main other\"") != std::string::npos, "exact enabled list"); + r = retcomm::scan_game_mods(game); + check(r.packages.size() == 1 && r.packages[0].features[0].enabled && + r.packages[0].features[1].enabled, + "rescan reads the selection back"); + + check(retcomm::set_scanned_mod_all(r, game, r.packages[0], false, &err), "disable all"); + const std::string off = slurp(game / "mods.toml"); + check(off.find("enabled = \"\"") != std::string::npos, "all off is an empty list"); + check(off.find("version = \"0.1.0\"") != std::string::npos, "version line kept"); + r = retcomm::scan_game_mods(game); + check(!r.packages[0].features[0].enabled, "an empty list overrides a default"); + + // The same id in both roots refuses both. + write(game / "mods/installed/yourname.first-mod/manifest.toml", + slurp(game / "mods/bundled/yourname.first-mod/manifest.toml")); + r = retcomm::scan_game_mods(game); + check(r.packages.empty(), "a duplicate id shows neither copy"); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: %s <scratch dir>\n", argv[0]); + return 2; + } + const fs::path dir = argv[1]; + std::error_code ec; + fs::remove_all(dir, ec); + test_parse_n64(); + test_parse_edges(); + { + CoreDescription d; + std::string err; + check(!load_description_cache(dir, "n64").ok, "no cache yet"); + parse_core_description(kN64, d, &err); + d.raw = kN64; + check(save_description_cache(dir, "n64", d, &err), "cache saves"); + const CoreDescription c = load_description_cache(dir, "n64"); + check(c.ok && c.cached && c.options.size() == 3, "cache reads back, marked cached"); + } + test_bindings(dir); + test_seat_plan(); + test_options(dir); + test_n64lle_mods(dir); + if (g_failures) { + std::fprintf(stderr, "%d check(s) failed\n", g_failures); + return 1; + } + std::printf("hub_core_settings: all checks passed\n"); + return 0; +}