From 7ab299f0367c43eb5ab72975ea359a857385bdc4 Mon Sep 17 00:00:00 2001 From: Alexbeav <7005125+Alexbeav@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:18:57 +0200 Subject: [PATCH] feat: add deterministic input replays --- CMakeLists.txt | 33 ++- README.md | 3 + corelink/core_link.cpp | 33 +++ corelink/core_link.hpp | 9 +- corelink/link_protocol.hpp | 20 +- corelink/link_test_main.cpp | 51 ++++- docs/BOOT_ROUTES.md | 128 +++++++++++ docs/CORE_LINK.md | 11 +- docs/CORE_RUNNER.md | 4 + docs/OVERLAY.md | 7 +- runner/host_session.cpp | 9 +- runner/host_session.hpp | 4 + runner/route.cpp | 419 ++++++++++++++++++++++++++++++++++ runner/route.hpp | 89 ++++++++ runner/runner_link.cpp | 110 ++++++++- runner/runner_link.hpp | 2 + runner/runner_main.cpp | 55 ++++- scripts/route_recover.py | 72 ++++++ tests/route_anchor_test.cmake | 35 +++ tests/route_contract_test.cpp | 99 ++++++++ tests/route_recover_test.py | 49 ++++ tests/route_test.cmake | 66 ++++++ tests/route_watch_test.cmake | 56 +++++ 23 files changed, 1347 insertions(+), 17 deletions(-) create mode 100644 docs/BOOT_ROUTES.md create mode 100644 runner/route.cpp create mode 100644 runner/route.hpp create mode 100644 scripts/route_recover.py create mode 100644 tests/route_anchor_test.cmake create mode 100644 tests/route_contract_test.cpp create mode 100644 tests/route_recover_test.py create mode 100644 tests/route_test.cmake create mode 100644 tests/route_watch_test.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 0e0fa19..c257b09 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,8 @@ if(WIN32) endif() add_library(retro_state STATIC state/state_envelope.cpp) +add_library(retro_route STATIC runner/route.cpp) +target_link_libraries(retro_route PUBLIC retro_core_support retro_corelink) target_include_directories(retro_state PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/state) target_link_libraries(retro_state PUBLIC retro_core_support) @@ -119,8 +121,35 @@ if(RETRO_RUNTIME_TOOLS) ) add_executable(retro-gpu-host-test tests/gpu_host_test.cpp runner/host_session.cpp) target_include_directories(retro-gpu-host-test PRIVATE runner) - target_link_libraries(retro-gpu-host-test PRIVATE retro_core_support) + target_link_libraries(retro-gpu-host-test PRIVATE retro_core_support retro_route) add_test(NAME gpu_host_contract COMMAND retro-gpu-host-test) + add_executable(retro-route-test tests/route_contract_test.cpp runner/host_session.cpp) + target_include_directories(retro-route-test PRIVATE runner) + target_link_libraries(retro-route-test PRIVATE retro_route) + add_test(NAME route_input_roundtrip COMMAND retro-route-test ${CMAKE_CURRENT_BINARY_DIR}/route-contract) + add_test(NAME runner_routes COMMAND ${CMAKE_COMMAND} + -DRUNNER=$ -DCORE=$ + -DLINK=$ -DOUT=${CMAKE_CURRENT_BINARY_DIR}/route-integration + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/route_test.cmake) + add_test(NAME route_watch COMMAND ${CMAKE_COMMAND} + -DRUNNER=$ -DCORE=$ + -DLINK=$ -DOUT=${CMAKE_CURRENT_BINARY_DIR}/route-watch + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/route_watch_test.cmake) + add_test(NAME route_anchor COMMAND ${CMAKE_COMMAND} + -DRUNNER=$ -DCORE=$ + -DLINK=$ -DOUT=${CMAKE_CURRENT_BINARY_DIR}/route-anchor + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/route_anchor_test.cmake) + find_package(Python3 COMPONENTS Interpreter QUIET) + if(Python3_Interpreter_FOUND) + file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/route-tmp) + add_test(NAME route_recovery COMMAND ${Python3_EXECUTABLE} -I -B + ${CMAKE_CURRENT_SOURCE_DIR}/tests/route_recover_test.py) + set_tests_properties(route_recovery PROPERTIES ENVIRONMENT + "TEMP=${CMAKE_CURRENT_BINARY_DIR}/route-tmp;TMP=${CMAKE_CURRENT_BINARY_DIR}/route-tmp;PYTHONDONTWRITEBYTECODE=1") + else() + add_test(NAME route_recovery COMMAND ${CMAKE_COMMAND} -E echo "SKIP: Python3 is unavailable") + set_tests_properties(route_recovery PROPERTIES SKIP_REGULAR_EXPRESSION "SKIP:") + endif() # Netplay (runner/net_session.hpp): recomp-net's episode driver and UDP # transport, compiled in from a checkout when one is named. Without it the # runner answers `netplay 0` and refuses --net-*. @@ -172,7 +201,7 @@ if(RETRO_RUNTIME_TOOLS) endif() # Threads: the savestate keeper hashes content off the core thread. find_package(Threads REQUIRED) - target_link_libraries(retro-core-runner PRIVATE retro_core_support retro_corelink retro_state + target_link_libraries(retro-core-runner PRIVATE retro_core_support retro_corelink retro_state retro_route Threads::Threads) target_include_directories(retro-core-runner PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated) if(_sdl) diff --git a/README.md b/README.md index 9af78e3..22580ab 100644 --- a/README.md +++ b/README.md @@ -90,3 +90,6 @@ The runner can therefore update separately from hosts and cores: This repository was split out of Retro Launcher on 2026-09-25, with its history. + +See [Input replays](docs/BOOT_ROUTES.md) for recording, anchored playback, +headless verification and link recording controls. diff --git a/corelink/core_link.cpp b/corelink/core_link.cpp index 3366be3..47163a9 100644 --- a/corelink/core_link.cpp +++ b/corelink/core_link.cpp @@ -16,6 +16,13 @@ CoreLink::~CoreLink() { channel_.close(); } +bool CoreLink::restart(const LaunchSpec& spec, std::string* error) { + stop(); + this->~CoreLink(); + new (this) CoreLink(); + return start(spec,error); +} + bool CoreLink::start(const LaunchSpec& spec, std::string* error) { auto fail = [&](const std::string& m) { if (error) *error = m; @@ -256,6 +263,13 @@ void CoreLink::handle_packet(const std::vector& buf, if (as_msg(buf, m)) exit_reason_ = m.reason; break; } + case Msg::ReplayStatus: { + ReplayStatusMsg m{}; + if (!as_msg(buf,m)) break; + m.path[sizeof m.path-1]=0; m.detail[sizeof m.detail-1]=0; + replay_status_=m; replay_pending_=false; + break; + } case Msg::StateDone: { StateDoneMsg m{}; if (!as_msg(buf, m) || !state_pending_) break; @@ -300,6 +314,20 @@ bool CoreLink::states_supported() const { (identity_.capabilities & RCORE_CAP_SAVESTATE); } +bool CoreLink::replays_supported() const { + return states_supported() && link_has_replays(identity_.protocol_minor); +} +bool CoreLink::request_recording(bool record, const fs::path& path) { + if (!replays_supported() || !can_grant()) return false; + ReplayControlMsg m{}; m.h.type=Msg::ReplayControl; m.record=record ? 1 : 0; + const auto p=path_utf8(path); + if (p.size()>=sizeof m.path) return false; + std::memcpy(m.path,p.c_str(),p.size()+1); + if (!send_msg(channel_,m)) return false; + replay_pending_=true; + return true; +} + bool CoreLink::request_state(Msg type, const fs::path& path) { if (!can_request_state()) return false; StateRequestMsg m{}; @@ -425,6 +453,11 @@ void CoreLink::on_ended(int code) { exit_code_ = code; state_ = LinkState::Ended; outstanding_ = 0; + if (replay_pending_) { + replay_pending_=false; + replay_status_.state=ReplayState::Failed; + std::strcpy(replay_status_.detail,"the runner ended before it answered"); + } if (state_pending_) { StateResult r = state_request_; r.ok = false; diff --git a/corelink/core_link.hpp b/corelink/core_link.hpp index cebebea..74121dd 100644 --- a/corelink/core_link.hpp +++ b/corelink/core_link.hpp @@ -143,6 +143,7 @@ class CoreLink { ~CoreLink(); bool start(const LaunchSpec& spec, std::string* error); + bool restart(const LaunchSpec& spec, std::string* error); // Handles every message waiting (up to timeout_ms for the first). void pump(int timeout_ms = 0); @@ -154,7 +155,7 @@ class CoreLink { // time; can_grant() says whether the previous one has finished. Nothing is // granted while a savestate request is outstanding. bool can_grant() const { - return state_ == LinkState::Ready && outstanding_ == 0 && !state_pending_; + return state_ == LinkState::Ready && outstanding_ == 0 && !state_pending_ && !replay_pending_; } // Available physical pixels before letterboxing; zeros when unknown. bool grant(const rcore_pad pads[RCORE_MAX_SEATS], std::uint32_t width = 0, @@ -175,6 +176,10 @@ class CoreLink { // The answer, once, when it has arrived. A runner that ends first // answers with a failure naming that. std::optional take_state_result(); + bool replays_supported() const; + bool request_recording(bool record, const fs::path& path = {}); + bool replay_pending() const { return replay_pending_; } + const ReplayStatusMsg& replay_status() const { return replay_status_; } // Accessory data (link 2.1, rcore rev 7; docs/CORE_ABI.md "Accessory // data"). send_accessory() queues one whole message for the accessory at @@ -249,6 +254,8 @@ class CoreLink { std::uint32_t front_ = 0; // the triple buffer's initial front slot bool have_frame_ = false; bool state_pending_ = false; + bool replay_pending_ = false; + ReplayStatusMsg replay_status_{}; StateResult state_request_; std::optional state_result_; std::deque notifies_; diff --git a/corelink/link_protocol.hpp b/corelink/link_protocol.hpp index eaba1aa..01c9b86 100644 --- a/corelink/link_protocol.hpp +++ b/corelink/link_protocol.hpp @@ -45,7 +45,9 @@ constexpr std::uint32_t kProtocolMajor = 2; // reads a 2.1 runner's with both fields zero. // 2.3: Grant appends the available drawable pixel area (rcore rev 9). // 2.4: optional core-owned GPU rings, native handle transfer and release events. -constexpr std::uint32_t kProtocolMinor = 4; +// 2.5: between-frame replay recording controls and authoritative status. +constexpr std::uint32_t kProtocolMinor = 5; +constexpr bool link_has_replays(std::uint32_t peer_minor) { return peer_minor >= 5; } constexpr bool link_has_gpu_frames(std::uint32_t peer_minor) { return kProtocolMajor > 2 || peer_minor >= 4; } @@ -154,6 +156,7 @@ enum class Msg : std::uint32_t { GpuRing = 10, // 2.4: metadata plus 3 native handles per image GpuFrame = 11, // accepted GPU picture, before its FrameDone GpuUnregister = 12, // retire a generation after all GPU users finish + ReplayStatus = 13, // hub -> runner SavesFilled = 64, // every region filled from its file; the seats at power-on Grant = 65, // run exactly one frame, with these pads @@ -164,6 +167,7 @@ enum class Msg : std::uint32_t { GpuCapabilities = 70, // 2.4: once after Hello, before core init GpuStatus = 71, // resource ownership events, not capabilities StateThumbnail = 72, // 2.4: displayed image observer for the next save + ReplayControl = 73, }; struct MsgHeader { @@ -171,6 +175,20 @@ struct MsgHeader { std::uint32_t size; // whole packet, header included }; +enum class ReplayState : std::uint32_t { Idle, Recording, Saved, Playing, TakenOver, Failed, Complete, Stopped }; +struct ReplayControlMsg { + MsgHeader h; + std::uint32_t record; // 1: start at a restored anchor, 0: stop recording + char path[4096]; // exclusive destination, never overwrite +}; +struct ReplayStatusMsg { + MsgHeader h; + ReplayState state; + std::uint64_t fields; + char path[4096]; + char detail[512]; +}; + struct GpuCapabilitiesMsg { MsgHeader h; rcore_gpu_capabilities caps; diff --git a/corelink/link_test_main.cpp b/corelink/link_test_main.cpp index 646f5b5..091fc75 100644 --- a/corelink/link_test_main.cpp +++ b/corelink/link_test_main.cpp @@ -39,6 +39,9 @@ using namespace retro::corelink; namespace { +constexpr std::uint32_t kLeft = 1u << 20, kRight = 1u << 21, + kUp = 1u << 22, kDown = 1u << 23; +constexpr std::uint32_t kStick = kLeft | kRight | kUp | kDown; [[noreturn]] void die(const std::string& m) { std::fprintf(stderr, "retro-core-link-test: %s\n", m.c_str()); @@ -63,6 +66,10 @@ std::uint32_t script_buttons(const std::string& s) { else if (t == "ddown") b |= RCORE_PAD_DPAD_DOWN; else if (t == "dleft") b |= RCORE_PAD_DPAD_LEFT; else if (t == "dright") b |= RCORE_PAD_DPAD_RIGHT; + else if (t == "left") b |= kLeft; + else if (t == "right") b |= kRight; + else if (t == "up") b |= kUp; + else if (t == "down") b |= kDown; else die("--input-script: unknown button '" + t + "'"); if (plus == std::string::npos) break; start = plus + 1; @@ -81,6 +88,8 @@ int main(int argc, char** argv) { std::uint64_t frames = 60; bool seat0 = true; bool presentation_test = false; + bool expect_route_end = false; + std::string restart_route; std::vector> script; struct StateOp { std::uint64_t at; @@ -88,6 +97,7 @@ int main(int argc, char** argv) { std::string path; }; std::vector state_ops; + std::vector record_ops; struct AccessorySend { std::uint64_t before_frame; std::uint32_t seat, slot; @@ -117,6 +127,7 @@ int main(int argc, char** argv) { v.game_package, v.describe, v.accessory_data); return 0; } else if (a == "--runner") spec.runner = utf8_path(val()); + else if (a == "--restart-route") restart_route=val(); else if (a == "--core") spec.core = utf8_path(val()); else if (a == "--rom") spec.rom = val(); else if (a == "--package") spec.package = val(); @@ -156,6 +167,7 @@ int main(int argc, char** argv) { else if (a == "--strict") spec.strict = true; else if (a == "--no-seats") seat0 = false; else if (a == "--presentation-test") presentation_test = true; + else if (a == "--expect-route-end") expect_route_end = true; else if (a == "--opt") { const std::string kv = val(); const auto eq = kv.find('='); @@ -175,6 +187,12 @@ int main(int argc, char** argv) { if (comma == std::string::npos) break; start = comma + 1; } + } else if (a == "--record-start-at") { + const auto v=val(); const auto colon=v.find(':'); + if (colon==std::string::npos) die("--record-start-at K:PATH"); + record_ops.push_back({std::strtoull(v.substr(0,colon).c_str(),nullptr,10),true,v.substr(colon+1)}); + } else if (a == "--record-stop-at") { + record_ops.push_back({std::strtoull(val().c_str(),nullptr,10),false,{}}); } else if (a == "--state-save-at") { state_op(val(), true); } else if (a == "--state-load-at") { @@ -208,6 +226,14 @@ int main(int argc, char** argv) { (link.exit_reason().empty() ? "" : ": " + link.exit_reason()) + "); see " + link.runner_log().string()); } + if (!restart_route.empty()) { + auto replay=spec; + replay.env.push_back("RETRO_PLAY_ROUTE="+restart_route); + replay.env.push_back("RETRO_ROUTE_TAKEOVER=1"); + if (!link.restart(replay,&err)) die(err); + while (link.state()==LinkState::Starting) link.pump(100); + if (link.state()!=LinkState::Ready) die("selected replay refused: "+link.exit_reason()); + } const CoreIdentity& id = link.identity(); std::printf("link: %s %s sha256 %s draft %u%s\n", id.core_id.c_str(), id.core_version.c_str(), id.sha256.c_str(), id.draft_revision, id.engine_dirty ? " (engine dirty)" : ""); @@ -243,18 +269,39 @@ int main(int argc, char** argv) { for (const auto& [at, buttons] : script) { if (at <= k) pads[0].buttons = buttons; } + const auto held = pads[0].buttons; + pads[0].buttons &= ~kStick; + pads[0].axes[RCORE_AXIS_LX] = held & kLeft ? -32767 : held & kRight ? 32767 : 0; + pads[0].axes[RCORE_AXIS_LY] = held & kUp ? 32767 : held & kDown ? -32767 : 0; } // Fixture exercises resize, unavailable/minimized, then recovery. const std::uint32_t pw = presentation_test && k != 3 ? (k == 1 ? 1280 : 2560) : 0; const std::uint32_t ph = presentation_test && k != 3 ? (k == 1 ? 720 : 1080) : 0; - if (!link.grant(pads, pw, ph)) die("grant " + std::to_string(k) + " refused"); + if (!link.grant(pads, pw, ph)) { + if (expect_route_end) { + link.pump(100); + if (link.state()==LinkState::Ended && !link.exit_code()) break; + } + die("grant " + std::to_string(k) + " refused"); + } while (link.state() == LinkState::Ready && link.frames_done() < k) link.pump(100); print_notifies(); - if (link.state() != LinkState::Ready) ok = false; + const bool clean_route_end = expect_route_end && link.state()==LinkState::Ended && !link.exit_code(); + if (link.state() != LinkState::Ready && !clean_route_end) ok = false; link.take_frame(); std::int16_t sink[4096]; while (link.drain_audio(sink, 2048)) { } + if (clean_route_end) break; + for (const StateOp& op : record_ops) { + if (op.at!=k || !ok) continue; + if (!link.request_recording(op.save,utf8_path(op.path))) die("replay request refused"); + while (link.replay_pending() && link.state()==LinkState::Ready) link.pump(100); + const auto& status=link.replay_status(); + std::printf("replay: %s at %llu state %u fields %llu %s\n",op.save ? "start" : "stop", + static_cast(k),unsigned(status.state), + static_cast(status.fields),status.detail); + } for (const StateOp& op : state_ops) { if (op.at != k || !ok) continue; const bool sent = op.save ? link.request_save_state(utf8_path(op.path)) diff --git a/docs/BOOT_ROUTES.md b/docs/BOOT_ROUTES.md new file mode 100644 index 0000000..4c861eb --- /dev/null +++ b/docs/BOOT_ROUTES.md @@ -0,0 +1,128 @@ +# Input replays + +Input replays record a play session from a cold boot or a restored mid-game +anchor and replay it in a fresh runner process. `--version` reports `boot_routes 1` in a runner with this feature. +No core ABI change is required. + +The recorder runs in `HostSession`, on the same input callbacks that headless +and link sessions use. It records the order of actual `input_get` calls, all +eight seats, connection flags, buttons and six signed axes. It also records +host clock calls. Playback supplies those values and refuses a changed +callback sequence. It does not sample desktop input on a timer. + +Each completed field has a state checkpoint, the last CPU picture hash and a +hash of the delivered PCM samples. Cores without public state hashes have +picture/audio checks only. A mark also writes a PPM picture. GPU-only +presentation has no CPU picture to hash. Use software rendering for a route +that requires exact pictures across these two runner modes. + +The file contains the initial save regions and these identities: + +- The ROM, core, runner, game package and title `game.toml`. +- The core id and version, strict mode and every resolved option. +- The process's `N64LLE_` environment variables. + +The initial saves are restored after load and before the first field. +Playback does not persist them to the recording's original save files. +Routes contain private player data. Keep them with private game inputs. + +## Record and replay + +`--record-route ` records headless input. `--route-mark FIELD:NAME` +adds a named checkpoint picture. The existing `--input-script` can supply +buttons and stick directions for scripted recording. A route file is never +overwritten. + +For ordinary play through a hub, set `RETRO_RECORD_ROUTE` to a new absolute +route path and select this runner through the hub's `--runner` option. The +runner records the pads that the hub grants. Quit the game normally to seal +the movie. No launcher change is required. The headless link test exercises +the same path without a window. + +`--play-route ` runs the movie headlessly or over the hub link. Supply the same ROM, +package, title directory and resolved configuration. Playback ignores +`--frames` and runs the movie's field count. It refuses simultaneous input +scripts, netplay, loaded states and accessories. Accessories need +their own input/state contract before they can join this format. + +A compatible frontend uses link 2.5 `ReplayControl` to start or stop recording +between frames. Start captures and restores a core snapshot before recording +inputs. The optional `A` record stores that anchor and its public digest; +boot movies remain readable. Fresh playback restores initial saves and the +anchor before the first field, and checks its digest immediately. A core must +serialize all replay-relevant state for anchored playback to work. A core that +only supports same-process restores is insufficient; playback refuses or +reports divergence instead of weakening checkpoints. Anchors are limited to +64 MiB and the entire movie to 256 MiB. + +`ReplayStatus` confirms Recording, Saved, Playing, TakenOver or Failed. Hosts +must wait for the response before granting another frame and display recording +status only after confirmation. Controls are unavailable with older peers, +netplay, accessories, or an active playback. Existing-file destinations refuse. +The frontend can list sealed `.route` files and start playback in a fresh runner +using `RETRO_PLAY_ROUTE`. `RETRO_ROUTE_VERDICT` selects the result path. +The hub supplies presentation and pacing. Normal completion ends the runner; +Quit writes `stopped`. Playback uses private save memory, leaving the frontend's +persistent save mappings alone, including after takeover. + +Watch enables `--route-takeover` through `RETRO_ROUTE_TAKEOVER=1`. First neutral +live pads arm takeover. A later button or stick movement ends replay input +authority at the next field, without a core reset. The verdict is `taken_over` +and records that field. Checks stop there. The live grants then drive the game +normally. The frontend's mapped input/deadzone is retained, with its raw-axis +fallback of 8000 as a residual-noise guard for takeover detection only. +Headless replay has no takeover path. + +## Results and core updates + +`/route-verdict.json` records `recorded`, `in_sync`, `diverged`, `failed`, +`stopped` or `taken_over`. +`--route-verdict ` selects another result file. Exit 5 means that a +checkpoint or callback sequence diverged. Exit 2 means that admission failed. +The first differing field is recorded. A picture mismatch also writes the +actual CPU picture. An identical final picture alone is not a passing replay. + +Default playback requires the exact recorded core and runner binaries. +The Watch extension can use `--route-runner-reference` (or +`RETRO_ROUTE_RUNNER_REFERENCE`) to name the preserved recording runner. Its +hash must match the movie, and the verdict names both recorded and current +runner hashes. Other identities and all checked checkpoints remain exact. +`--route-allow-core-change` explicitly compares another core. The ROM, package, +runner, existing options and expected checkpoints remain unchanged. A new +option is accepted only at the new core's declared default, and the verdict +lists it under `added_default_options`. A removed option or a changed +recorded option still refuses. A passing comparison describes that route and +configuration, not every game or future core version. + +## Interrupted recording + +The active file is `.partial`. The writer flushes each complete field. +A normal link Quit or headless Ctrl+C seals completed fields in the requested +route file. A fault leaves the partial file. An unsealed partial file refuses +normal playback. + +`scripts/route_recover.py` copies the complete field prefix into a new sealed +file. It does not overwrite the source or an existing output. The normal +runner still validates the recovered file before execution. For example: + +```powershell +& python .\scripts\route_recover.py 'C:\private\route.partial' 'C:\private\recovered.route' +``` + +The format limit is 256 MiB. Recording and parsing have bounded callback, +image and audio sizes. The checksum detects accidental file damage. It is +not a signature that authenticates another person's route. + +## Validation + +`route_input_roundtrip` checks all seats and axes, clocks, initial-save +restoration, state/picture/audio replay and explicit new-default handling. +`runner_routes` checks new-process playback, link-to-headless playback, +identity refusal, truncation, malformed field order and first divergence. +`tests/route_recover_test.py` checks complete-prefix recovery and refusals +without game data. The existing Runtime suite remains applicable. + +`route_anchor` checks recording controls, a restored mid-game anchor, +fresh-process playback and protection of an existing movie. Runtime tests use +synthetic cores; title/core-specific file-state replay must be qualified +separately. They do not establish visual UI or physical-controller acceptance. diff --git a/docs/CORE_LINK.md b/docs/CORE_LINK.md index c91a395..e1cd1f7 100644 --- a/docs/CORE_LINK.md +++ b/docs/CORE_LINK.md @@ -209,9 +209,9 @@ package; `--title-dir` then defaults to the shim's directory. `game_package 1`, the window shows the error and the hub exits 1. A background runtime update applies from the next launch, never mid-session. - **Menu:** Esc or F1, or L3 + R3 on a pad, open the paused quick menu (Resume, - Save states, Show FPS, Volume, Close game). F11 toggles fullscreen. + Save states, Replays, Show FPS, Volume, Close game). Alt+Enter toggles fullscreen. - **Overlay** (`OVERLAY.md`): F3 FPS, Tab (held) turbo, +/- volume, F7 save - states by default. Since 2026-09-27 the hub's hotkeys are the player's to + states/replays and F11 recording by default (see `BOOT_ROUTES.md`). Since 2026-09-27 the hub's hotkeys are the player's to bind (Retro-Launcher's settings page, `play.ini` `[keys]` / `[combos]`), and its pad shortcuts are `Function +