From 569ee6e370a4574623266bfff8251c1e9b3ecae3 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sat, 26 Sep 2026 17:28:17 -0400 Subject: [PATCH] runner: --describe prints a core's options and input labels, no ROM needed `retro-core-runner --describe --core [--package ]` loads the core and checks its .rcore.toml exactly as a run does, then prints what it declares -- options (type, flags, default, range, label, description, enum values) and input descriptors -- as TAB-separated records, one per line, with \ TAB LF CR escaped. It never calls init, load or deinit, so a frontend can build a settings page from the core's own declarations without a ROM or a session. `--version` gains `describe 1`, and runner_probe reads it into RunnerVersion::describe (0 for older runners), so a host can tell support before asking. Tests: five runner_describe_* cases compare stdout byte for byte (plain, package, package_none, unwanted, no_core); the fake cores now declare options and inputs whose fields carry a TAB, LF, CR and backslash. Format and checks documented in docs/CORE_RUNNER.md, the --version key in docs/RELEASES.md. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 17 ++++- corelink/link_test_main.cpp | 5 +- corelink/runner_probe.cpp | 3 + corelink/runner_probe.hpp | 3 + docs/CORE_LINK.md | 2 + docs/CORE_RUNNER.md | 59 +++++++++++++++- docs/RELEASES.md | 6 ++ runner/runner_main.cpp | 137 +++++++++++++++++++++++++++++++----- tests/describe_test.cmake | 85 ++++++++++++++++++++++ tests/rcore_fake_core.c | 35 ++++++++- 10 files changed, 327 insertions(+), 25 deletions(-) create mode 100644 tests/describe_test.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index a73e239..22f98b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -143,7 +143,7 @@ if(RETRO_RUNTIME_TOOLS) # --version: what release packaging and hosts read back from the binary. add_test(NAME runner_version COMMAND retro-core-runner --version) set_tests_properties(runner_version PROPERTIES PASS_REGULAR_EXPRESSION - "version ${RETRO_RUNTIME_VERSION}\ncommit [^\n]+\nlink_protocol [0-9]+\\.[0-9]+\n.*gl [01]\ngame_package 1\n") + "version ${RETRO_RUNTIME_VERSION}\ncommit [^\n]+\nlink_protocol [0-9]+\\.[0-9]+\n.*gl [01]\ngame_package 1\ndescribe 1\n") # Link: the same, driven through retro_corelink the way a host does. add_test(NAME link_fake_core COMMAND retro-core-link-test --runner $ @@ -155,7 +155,7 @@ if(RETRO_RUNTIME_TOOLS) # probe_runner: what a host reads to choose and verify a runner. add_test(NAME runner_probe COMMAND retro-core-link-test --probe $) set_tests_properties(runner_probe PROPERTIES PASS_REGULAR_EXPRESSION - "probe: version ${RETRO_RUNTIME_VERSION}, link [0-9]+\\.[0-9]+, rcore ABI [0-9]+, compatible, game_package 1") + "probe: version ${RETRO_RUNTIME_VERSION}, link [0-9]+\\.[0-9]+, rcore ABI [0-9]+, compatible, game_package 1, describe 1") # A crash mid-session: the runner dies at frame 5 with no unload. The hub # must see it end and still write the save as frames 1-4 left it -- the # save memory is the hub's (docs/CORE_LINK.md). @@ -185,4 +185,17 @@ if(RETRO_RUNTIME_TOOLS) -DOUT=${CMAKE_CURRENT_BINARY_DIR}/test-package-${_case} -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/package_test.cmake) endforeach() + # --describe: the fake cores' declarations as TAB records, without --rom; + # and the refusals (--package for a plain core, a core that is not there). + foreach(_case plain package package_none unwanted no_core) + add_test(NAME runner_describe_${_case} + COMMAND ${CMAKE_COMMAND} + "-DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}" + -DCASE=${_case} + -DRUNNER=$ + -DPKG_CORE=$ + -DPLAIN_CORE=$ + -DPACKAGE=${CMAKE_CURRENT_SOURCE_DIR}/tests/fake_package.txt + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/describe_test.cmake) + endforeach() endif() diff --git a/corelink/link_test_main.cpp b/corelink/link_test_main.cpp index 74a48d1..17b3d89 100644 --- a/corelink/link_test_main.cpp +++ b/corelink/link_test_main.cpp @@ -76,10 +76,11 @@ int main(int argc, char** argv) { RunnerVersion v; std::string err; if (!probe_runner(utf8_path(val()), v, &err)) die(err); - std::printf("probe: version %s, link %u.%u, rcore ABI %u, %s, game_package %u\n", + std::printf("probe: version %s, link %u.%u, rcore ABI %u, %s, game_package %u, " + "describe %u\n", v.version.c_str(), v.link_major, v.link_minor, v.abi_major, v.compatible() ? "compatible" : "NOT compatible with this host", - v.game_package); + v.game_package, v.describe); return 0; } else if (a == "--runner") spec.runner = utf8_path(val()); else if (a == "--core") spec.core = utf8_path(val()); diff --git a/corelink/runner_probe.cpp b/corelink/runner_probe.cpp index 8a68bb6..ca6df50 100644 --- a/corelink/runner_probe.cpp +++ b/corelink/runner_probe.cpp @@ -67,6 +67,9 @@ bool probe_runner(const fs::path& runner, RunnerVersion& out, std::string* error if (fields.count("game_package") && !num("game_package", v.game_package)) { return fail("--version printed an unreadable game_package line"); } + if (fields.count("describe") && !num("describe", v.describe)) { + return fail("--version printed an unreadable describe line"); + } const std::string link = fields["link_protocol"]; const auto dot = link.find('.'); bool ok = !v.version.empty() && dot != std::string::npos && num("rcore_abi_major", v.abi_major) && diff --git a/corelink/runner_probe.hpp b/corelink/runner_probe.hpp index 87acf33..6e6507a 100644 --- a/corelink/runner_probe.hpp +++ b/corelink/runner_probe.hpp @@ -22,6 +22,9 @@ struct RunnerVersion { // 1 when the runner takes --package (GAME_PACKAGE cores); 0 for a runner // from before it did, which prints no game_package line. std::uint32_t game_package = 0; + // 1 when the runner answers --describe (a core's declared options and + // inputs, no ROM; docs/CORE_RUNNER.md); 0 for a runner from before it. + std::uint32_t describe = 0; // Whether this host can drive it: the link major and the rcore ABI major // are this host's own. bool compatible() const { diff --git a/docs/CORE_LINK.md b/docs/CORE_LINK.md index b54bc8e..b40d372 100644 --- a/docs/CORE_LINK.md +++ b/docs/CORE_LINK.md @@ -76,6 +76,8 @@ hub runner hub that needs it as data hashes the file it passed. `retro-core-link-test --package` drives it, and `probe_runner` reads `game_package` from `--version` (`RunnerVersion::game_package`, 0 for a runner from before it). + It reads `describe` the same way (`RunnerVersion::describe`): whether the + runner answers `--describe`, which is not a link session (`CORE_RUNNER.md`). - **Input rides inside each Grant**, so the contract's "identical within one frame" holds by construction. - **SavesFilled carries the seats as they stand before frame 1.** A core may diff --git a/docs/CORE_RUNNER.md b/docs/CORE_RUNNER.md index 7d88113..dc9e294 100644 --- a/docs/CORE_RUNNER.md +++ b/docs/CORE_RUNNER.md @@ -53,9 +53,14 @@ This page covers what exists and how it is checked. --tpak1-save --tpak1-rtc --gl --strict --no-seats --replay-at --opt --input-script --list-options`). +5. **Describes a core** (`--describe`, 2026-09-26) for a host building a + settings page: `--describe --core [--package ]`, no + `--rom`. See "--describe" below. + `--version` prints the release version, commit, link protocol and rcore ABI -compiled in, whether `--gl` is available, and `game_package 1` (this runner -takes `--package`), and exits 0 (`RELEASES.md`). +compiled in, whether `--gl` is available, `game_package 1` (this runner +takes `--package`) and `describe 1` (this runner answers `--describe`), and +exits 0 (`RELEASES.md`). Exit codes: @@ -66,6 +71,48 @@ Exit codes: | 2 | refused before the core ran: arguments, load, ABI, manifest or game package | | 3 | the core broke the contract: an undeclared option key | +## `--describe` + +What a core declares, printed without a ROM and without a session. The runner +loads the library and checks its sidecar exactly as for a run (a refusal is +exit 2, message on stderr, nothing on stdout). It then reads `options()` and +`input_descriptors()`, which `rcore.h` declares "before load" and which the +session already reads before `init`. It calls neither `init` nor `load`, and +it does not refuse an `OWNS_LOOP` core, because nothing is driven. + +`--package` gets the same refusals as for a run (a core without +`game_package` refuses one; it must be a readable file). A `game_package` +core is described without one too: a package reaches a core only through +`load()`, so it cannot change what is declared before it. + +Stdout is UTF-8, one record per line, fields separated by one TAB, and +nothing else: + +``` +describe 1 +core +option