From 329343308034a92502f848f95a62081ef7eb959b Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sat, 26 Sep 2026 17:28:39 -0400 Subject: [PATCH] direct: a home page and an n64lle Settings page before Play (direct_mode 3) Direct mode no longer boots straight into the game. It opens the title's home page -- Play, Settings, Mods, Quit -- and closing the game returns to it. `--boot` keeps the old behaviour; `direct_mode` goes to 3 and docs/RELEASES.md calls out that a revision-2 command line now opens the page. The settings page ("n64lle Settings") is laid out like the Super Nintendo page and shared with the library, which opens it from an N64 platform header's "n64lle Config": - System tab: the core's declared options, player-facing and developer cards, with an "all titles / this title only" scope when opened for a title. Options layer platform < title < --opt. - Gamepads tab: four seat cards (Auto / Keyboard / a gamepad / None), each opening a controller page over a new N64 pad picture (assets/src/pad_n64.svg) with a chip per input, labelled in the core's own names; click and press to bind. Stick deadzone, reset / copy per seat. Everything is built from `retro-core-runner --describe` (Retro-Runtime feat/describe), cached per platform; a runner without it still plays and the page says what it lacks. PlaySession reads the seats and maps (src/hub/hub_core_settings) instead of a hard-coded table; the defaults reproduce that table, plus TFGH for the right stick, which left a keyboard with no C buttons. The library's play path reads the same files. Mods: the hub reads n64lle's layout (mods///manifest.toml, selection in /mods.toml, written as n64lle writes it) beside psxrecomp's, and the page writes whichever layout it found. The home page shows what the core logged about mods in the last session (core.log). "Enable all" now skips built-in provider packages, as their checkboxes already did. Tests: retro-hub-settings-test (describe parsing on real n64lle records, escapes, seats and option round-trips, seat planning, n64lle mods read/write). Checked by hand in Direct mode on Pokemon Stadium: settings saved per layer and reached the core (render=software), key capture, mods applied and refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CMakeLists.txt | 15 +- README.md | 4 +- assets/controllers/pad_n64.png | Bin 0 -> 18048 bytes assets/src/pad_n64.svg | 39 + docs/RELEASES.md | 43 +- include/retcomm/mods.hpp | 23 + src/core/mods.cpp | 260 ++++ src/hub/hub_core_settings.cpp | 789 ++++++++++++ src/hub/hub_core_settings.hpp | 225 ++++ src/hub/hub_main.cpp | 1942 ++++++++++++++++++++++++++---- src/hub/hub_model.hpp | 4 + src/hub/hub_play.cpp | 82 +- src/hub/hub_play.hpp | 4 + tests/hub_core_settings_test.cpp | 281 +++++ 14 files changed, 3420 insertions(+), 291 deletions(-) create mode 100644 assets/controllers/pad_n64.png create mode 100644 assets/src/pad_n64.svg create mode 100644 src/hub/hub_core_settings.cpp create mode 100644 src/hub/hub_core_settings.hpp create mode 100644 tests/hub_core_settings_test.cpp 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 <core> [--package <shim>] --rom <image> [--title-dir <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 0000000000000000000000000000000000000000..fc5f13860ee2c2a0d03e3db70791ef8f80569721 GIT binary patch literal 18048 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 literal 0 HcmV?d00001 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- pad_n64: a three-prong controller in the flat style of pad_snes.png, drawn + for retro-hub (not traced from any photograph or product art). Rendered to + assets/controllers/pad_n64.png with (from assets/): + rsvg-convert -w 720 -h 400 src/pad_n64.svg -o controllers/pad_n64.png + Button centres are the chip anchors in hub_main.cpp n64_pad_hits(). --> +<svg xmlns="http://www.w3.org/2000/svg" width="720" height="400" viewBox="0 0 720 400"> + <!-- L / R shoulder tabs, behind the body --> + <rect x="150" y="58" width="120" height="60" rx="16" fill="#3a48a4"/> + <rect x="450" y="58" width="120" height="60" rx="16" fill="#3a48a4"/> + <!-- Z trigger, under the centre prong --> + <rect x="330" y="336" width="60" height="34" rx="12" fill="#3a48a4"/> + <!-- body: top bar and three prongs --> + <g fill="#4659c8"> + <rect x="100" y="88" width="520" height="140" rx="70"/> + <rect x="128" y="140" width="104" height="196" rx="52"/> + <rect x="308" y="160" width="104" height="196" rx="52"/> + <rect x="488" y="140" width="104" height="196" rx="52"/> + </g> + <!-- d-pad well and cross --> + <circle cx="180" cy="150" r="54" fill="#3a48a4"/> + <rect x="163" y="112" width="34" height="76" rx="6" fill="#c8d3f5"/> + <rect x="142" y="133" width="76" height="34" rx="6" fill="#c8d3f5"/> + <!-- start --> + <circle cx="360" cy="150" r="13" fill="#f07070"/> + <!-- control stick --> + <circle cx="360" cy="250" r="40" fill="#3a48a4"/> + <circle cx="360" cy="250" r="22" fill="#c8d3f5"/> + <!-- C-button well --> + <circle cx="560" cy="131" r="43" fill="#3a48a4"/> + <!-- A, B --> + <circle cx="505" cy="195" r="19" fill="#5fa8f5"/> + <circle cx="465" cy="160" r="19" fill="#6ccb74"/> + <!-- C buttons --> + <circle cx="560" cy="105" r="12" fill="#f2d45c"/> + <circle cx="560" cy="155" r="12" fill="#f2d45c"/> + <circle cx="525" cy="130" r="12" fill="#f2d45c"/> + <circle cx="595" cy="130" r="12" fill="#f2d45c"/> +</svg> 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 <core> [--package <shim>] --rom <image> [--title-dir <dir>] \ - [--tpak1-rom <gb rom>] [--tpak1-save <sav>] [--no-gl] [--opt key=value]... + [--tpak1-rom <gb rom>] [--tpak1-save <sav>] [--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 <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 `<data dir>/sessions/<stem>/` and saves to `<data dir>/saves/<stem>/`, where `<stem>` 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 | +|---|---| +| `<data dir>/platform/<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`) | +| `<data dir>/platform/<platform>/options.ini` | option values for every title | +| `<data dir>/platform/<platform>/options/<stem>.ini` | one title's overrides; `--opt` wins over both | +| `<data dir>/platform/<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 +`<title dir>/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; +}