feat(bios): pick the BIOS at runtime — OpenBIOS unless the player chose one

Implements docs/BIOS_SELECTION.md on top of the backend ABI. One build
now carries both images and decides at startup, so the per-BIOS build
flavour (and its three uncross-checked restatements) is gone.

  - runtime.cmake takes PSXRECOMP_BIOS_STEMS (default "OpenBIOS;SCPH1001")
    and generates psx_bios_registry.c from it, so the stem list is the
    single source of truth for what a build carries.
  - [runtime] openbios (default true) records whether a title may use the
    bundled image. Not exposed to settings.toml: it is a developer's
    compatibility finding, not a player preference.
  - Selection: an explicit choice that matches a linked image wins;
    otherwise the bundled image; otherwise prompt. A mismatched explicit
    choice is explained and falls back to OpenBIOS when the title allows
    it, instead of dead-ending.
  - validate_bios_for_launch now ASKS WHICH backend an image is, rather
    than checking against one hardcoded identity, and activates it.

Crucially, "no BIOS" means "the player chose none" — not "no BIOS file
was discoverable". The compile-time DEFAULT_BIOS_PATH is no longer
treated as a choice: it made a stray file near the executable produce a
BIOS Mismatch dialog before the bundled image was considered.

Also fixed while wiring this: the settings.toml BIOS-adoption check read
psx_bios_image.image_bundled, which now runs before any backend is
active (all-zero), so it asks the registry instead.

Verified on Tomba! with ONE binary, both paths:
  no --bios          -> OPENBIOS, no dialog, boots to in-game save
  --bios SCPH1001.BIN-> SCPH-1001 (crc 37157331, bundled=0)
and the per-image state follows the selection, not just the label:
kernel-bless window 0x500-0x4BA0 vs 0x500-0x8500, deliver_event_ret
0x0 vs 0x80001720.

Still outstanding: savestates must record the active BIOS and refuse to
load under another (kernel RAM layout differs); a player-facing way to
clear a remembered choice; retiring PSXRECOMP_BIOS_STEM/_PROFILE, which
now only feed the staleness stamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-07-25 12:12:33 -07:00
parent 7e4adff41f
commit b0f514c57e
4 changed files with 209 additions and 84 deletions
+3
View File
@@ -222,6 +222,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
if (runtime.contains("fast_boot")) {
rt.fast_boot = toml::find<bool>(runtime, "fast_boot");
}
if (runtime.contains("openbios")) {
rt.openbios = toml::find<bool>(runtime, "openbios");
}
if (runtime.contains("bios_hle")) {
rt.bios_hle = toml::find<bool>(runtime, "bios_hle");
}
+10
View File
@@ -132,6 +132,16 @@ struct RuntimeConfig {
// to LLE. Implies the HLE boot shell-skip unless bios_hle_keep_intro.
// PSX_BIOS_HLE / PSX_BIOS_HLE_KEEP_INTRO env override at launch.
// Runtime: runtime/src/bios_hle.c.
// openbios: may this title run on the bundled, redistributable OpenBIOS?
// Default true — a player who chooses no BIOS gets OpenBIOS and never has
// to find one (docs/BIOS_SELECTION.md). Set false ONLY for a title with a
// verified OpenBIOS incompatibility; a retail image is then required and
// the player is prompted for it.
//
// Deliberately NOT overridable by a player's settings.toml: this records a
// developer's compatibility finding, not a preference.
bool openbios = true;
bool bios_hle = true;
bool bios_hle_keep_intro = false;
+58 -14
View File
@@ -168,6 +168,7 @@ set(PSXRECOMP_RUNTIME_SOURCES
${PSXRECOMP_ROOT}/runtime/src/event_ring.c
${PSXRECOMP_ROOT}/runtime/src/game_options.c
${PSXRECOMP_ROOT}/runtime/src/psx_keybinds.c
${PSXRECOMP_ROOT}/runtime/src/psx_bios_backend.c
${PSXRECOMP_ROOT}/runtime/src/psx_netplay.c
${PSXRECOMP_ROOT}/runtime/src/psx_lobby_client.c
${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp
@@ -246,22 +247,62 @@ if(PSXRECOMP_LOBBY_INCLUDE_DIR)
list(APPEND PSXRECOMP_RUNTIME_INCLUDE_DIRS ${PSXRECOMP_LOBBY_INCLUDE_DIR})
endif()
# Which recompiled BIOS the runtime links, selected by profile. One BIOS per
# binary: generated symbols are address-derived (func_1FC00000, psx_dispatch,
# psx_bios_kernel_bodies), so two images collide on essentially every symbol —
# a game repo picks its BIOS at configure time. The stem must match the
# profile's [recompiler] out_stem; it is a separate cache variable (rather
# than parsed out of the profile) because CMake cannot read TOML at configure
# time — a mismatch surfaces immediately as missing generated/<stem>_*.c.
set(PSXRECOMP_BIOS_STEM "SCPH1001" CACHE STRING
"Output stem of the recompiled BIOS to link (matches the profile's out_stem)")
set(PSXRECOMP_BIOS_PROFILE "${PSXRECOMP_ROOT}/bios/SCPH1001.toml" CACHE FILEPATH
# Which recompiled BIOSes the runtime links. A build carries every image it
# ships — bundled OpenBIOS plus a retail one — and chooses between them at
# startup (docs/BIOS_SELECTION.md). Each image exports a single
# <STEM>_psx_bios_backend descriptor and namespaces everything else, so they
# co-link; the runtime routes psx_dispatch()/psx_bios_image through whichever
# backend it selects.
#
# One build configuration on purpose: the previous per-BIOS flavour needed the
# same choice restated in game.toml and two CMake variables with nothing
# cross-checking them, and it linked cleanly when they disagreed.
set(PSXRECOMP_BUNDLED_BIOS_PATH "bios/openbios.bin" CACHE STRING
"Bundled redistributable BIOS image, relative to the executable")
set(PSXRECOMP_BIOS_STEMS "OpenBIOS;SCPH1001" CACHE STRING
"Recompiled BIOS stems to link (first bundled/redistributable one is the default at runtime)")
# The profile is still needed as the staleness-stamp input for the primary stem.
list(GET PSXRECOMP_BIOS_STEMS 0 PSXRECOMP_BIOS_STEM_PRIMARY)
set(PSXRECOMP_BIOS_STEM "${PSXRECOMP_BIOS_STEM_PRIMARY}" CACHE STRING
"Primary recompiled BIOS stem (staleness stamp; see PSXRECOMP_BIOS_STEMS)")
set(PSXRECOMP_BIOS_PROFILE "${PSXRECOMP_ROOT}/bios/${PSXRECOMP_BIOS_STEM}.toml" CACHE FILEPATH
"BIOS profile TOML this build regenerates from (staleness stamp input)")
set(PSXRECOMP_BIOS_GENERATED
${PSXRECOMP_ROOT}/generated/${PSXRECOMP_BIOS_STEM}_full.c
${PSXRECOMP_ROOT}/generated/${PSXRECOMP_BIOS_STEM}_dispatch.c
)
set(PSXRECOMP_BIOS_GENERATED "")
set(_psxrt_registry_externs "")
set(_psxrt_registry_entries "")
foreach(_stem IN LISTS PSXRECOMP_BIOS_STEMS)
list(APPEND PSXRECOMP_BIOS_GENERATED
${PSXRECOMP_ROOT}/generated/${_stem}_full.c
${PSXRECOMP_ROOT}/generated/${_stem}_dispatch.c)
string(APPEND _psxrt_registry_externs
"extern const PsxBiosBackend ${_stem}_psx_bios_backend;
")
string(APPEND _psxrt_registry_entries
" &${_stem}_psx_bios_backend,
")
endforeach()
list(LENGTH PSXRECOMP_BIOS_STEMS _psxrt_bios_count)
# Registry of the compiled-in backends, in preference order. Generated so the
# stem list stays the single source of truth.
set(_psxrt_registry_c "${CMAKE_BINARY_DIR}/psx_bios_registry.c")
file(WRITE "${_psxrt_registry_c}"
"/* Generated by runtime.cmake from PSXRECOMP_BIOS_STEMS — do not edit. */
"
"#include \"psx_bios_backend.h\"
"
"${_psxrt_registry_externs}"
"
const PsxBiosBackend *const psx_bios_registry[] = {
"
"${_psxrt_registry_entries}"
"};
"
"const uint32_t psx_bios_registry_count = ${_psxrt_bios_count}u;
")
list(APPEND PSXRECOMP_BIOS_GENERATED "${_psxrt_registry_c}")
# --- BIOS generated/ staleness check (hygiene) -----------------------------------
# generated/<stem>_*.c is gitignored build output produced by a SEPARATE build
@@ -510,6 +551,9 @@ function(psxrecomp_add_runtime_target target)
target_compile_definitions(${target} PRIVATE
DEFAULT_DEBUG_PORT=${PSXRT_DEBUG_PORT}
PSX_DEFAULT_BIOS_PATH="${PSXRT_DEFAULT_BIOS_PATH}"
# Where the shipped redistributable image lives, relative to the exe.
# This is what a player gets when they choose no BIOS.
PSX_BUNDLED_BIOS_PATH="${PSXRECOMP_BUNDLED_BIOS_PATH}"
PSX_DEFAULT_GAME_CONFIG_PATH="${PSXRT_DEFAULT_GAME_CONFIG_PATH}"
PSX_WINDOW_TITLE="${PSXRT_WINDOW_TITLE}"
PSX_BUILD_REV="${PSX_GIT_REV}"
+138 -70
View File
@@ -6,7 +6,8 @@
*/
#include "cpu_state.h"
#include "psx_bios_image.h" /* the linked recompiled BIOS's self-description */
#include "psx_bios_image.h" /* the active BIOS's self-description */
#include "psx_bios_backend.h" /* routing to whichever BIOS was selected */
#include "psx_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */
#include "parity_trace.h" /* general two-process control-flow parity ring */
#include "device_trace.h" /* general two-process device-event cycle ring */
@@ -860,6 +861,16 @@ static void launcher_info(const char* title, const std::string& msg) {
* config loads, before any interactive file resolution. */
static std::string s_picker_game_name = "PSXRecomp";
/* BIOS selection state (docs/BIOS_SELECTION.md). s_openbios_allowed is the
* game's [runtime] openbios; s_bundled_bios_rel is where the shipped
* redistributable image lives relative to the executable. */
static bool s_openbios_allowed = true;
#ifdef PSX_BUNDLED_BIOS_PATH
static std::string s_bundled_bios_rel = PSX_BUNDLED_BIOS_PATH;
#else
static std::string s_bundled_bios_rel = "bios/openbios.bin";
#endif
static bool pick_runtime_file(const char* title, const char* filter,
std::filesystem::path& out) {
// Headless: never open an interactive file dialog (it blocks). Fail the
@@ -990,94 +1001,135 @@ static std::filesystem::path normalize_disc_path_for_launch(const std::filesyste
return p;
}
static bool validate_bios_for_launch(const std::filesystem::path& path) {
/* HARD identity gate against the linked recompiled BIOS
* (psx_bios_image, emitted from the BIOS profile). The selected file is
* only ever DATA: the code that executes is the statically recompiled C
* of one specific image. Running that code against a different image's
* bytes is a guaranteed wild-jump crash (mismatched jump tables /
* kernel-copy source), so a mismatch is a refusal — the old warn-only
* CRC check let exactly that crash happen. */
/* Which compiled-in backend, if any, was recompiled from THIS file?
*
* The chosen file is only ever DATA: the code that executes is the statically
* recompiled C of one specific image, so running it against another image's
* bytes is a guaranteed wild jump. Identity therefore decides WHICH backend
* may run, not merely whether to warn. Null = no linked image matches.
*/
static const PsxBiosBackend* bios_backend_for_file(const std::filesystem::path& path,
uint32_t* out_crc,
uint64_t* out_size) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f.is_open()) return false;
if (!f.is_open()) return nullptr;
const std::streamoff size = f.tellg();
if ((uint64_t)size != (uint64_t)psx_bios_image.image_size) {
char buf[320];
std::snprintf(buf, sizeof(buf),
"The selected BIOS is %llu bytes; this build's recompiled BIOS "
"(%s) is %u bytes.\n\nSelect the exact image this build was "
"compiled from.",
(unsigned long long)size,
psx_bios_image.image_id, psx_bios_image.image_size);
launcher_warning("BIOS Mismatch", buf);
return false;
}
if (out_size) *out_size = (uint64_t)size;
if (size <= 0) return nullptr;
std::vector<uint8_t> data((size_t)size);
if (!read_at(f, 0, data.data(), data.size())) return false;
if (!read_at(f, 0, data.data(), data.size())) return nullptr;
const uint32_t crc = crc32_compute(data.data(), data.size());
if (crc != psx_bios_image.image_crc32) {
char buf[448];
std::snprintf(buf, sizeof(buf),
"The selected BIOS (CRC32 %08X) is not the image this build's "
"recompiled BIOS was compiled from:\n\n %s\n CRC32 %08X\n"
" SHA256 %.16s...\n\nThe compiled-in code would execute against "
"mismatched data and crash. Select the exact matching image.",
crc, psx_bios_image.image_id, psx_bios_image.image_crc32,
psx_bios_image.image_sha256);
launcher_warning("BIOS Mismatch", buf);
return false;
if (out_crc) *out_crc = crc;
for (uint32_t i = 0; i < psx_bios_registry_count; i++) {
const PsxBiosBackend* b = psx_bios_registry[i];
if (!b || !b->image) continue;
if ((uint64_t)size == (uint64_t)b->image->image_size &&
crc == b->image->image_crc32)
return b;
}
return true;
return nullptr;
}
/* What a player may supply, for mismatch and picker copy. The bundled image is
* excluded: it is never something to go and find. */
static std::string bios_accepted_images() {
std::string s;
for (uint32_t i = 0; i < psx_bios_registry_count; i++) {
const PsxBiosBackend* b = psx_bios_registry[i];
if (!b || !b->image || b->image->image_bundled) continue;
if (!s.empty()) s += ", ";
s += b->image->image_id;
s += " (" + std::to_string(b->image->image_size / 1024u) + " KB)";
}
return s.empty() ? std::string("(this build ships its own BIOS)") : s;
}
/* Identity-gate a player-chosen BIOS and activate its backend on success. */
static bool validate_bios_for_launch(const std::filesystem::path& path) {
uint32_t crc = 0; uint64_t size = 0;
const PsxBiosBackend* b = bios_backend_for_file(path, &crc, &size);
if (b) return psx_bios_activate(b) != 0;
char buf[512];
std::snprintf(buf, sizeof(buf),
"That BIOS (%llu bytes, CRC32 %08X) is not an image this build was "
"compiled from.\n\nThis build accepts: %s\n\nThe compiled-in code "
"would execute against mismatched data and crash.",
(unsigned long long)size, crc, bios_accepted_images().c_str());
launcher_warning("BIOS Mismatch", buf);
return false;
}
static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0);
/* Resolve which BIOS image file to load, and activate its backend.
*
* Policy (docs/BIOS_SELECTION.md):
* no explicit player choice -> bundled OpenBIOS, silently
* explicit choice, matching -> that image
* explicit choice, mismatched-> explained; falls back to OpenBIOS if allowed
* openbios disabled for this title -> a retail image is required
*
* "Explicit" means --bios or a remembered launcher/settings pick. Finding a
* file on disk deliberately does NOT count: discovery used to adopt whatever
* happened to sit near the executable, so two players with the same build
* could end up on different BIOSes.
*/
static std::filesystem::path resolve_bios_for_runtime(const char* requested,
const char* argv0) {
std::filesystem::path resolved = resolve_bios_path(requested, argv0);
if (!resolved.empty() && std::filesystem::exists(resolved) &&
validate_bios_for_launch(resolved)) {
return resolved;
const char* argv0,
bool requested_is_explicit) {
const bool openbios_allowed = s_openbios_allowed;
const PsxBiosBackend* bundled = psx_bios_bundled();
/* 1. An explicit choice: --bios, else a remembered pick. */
std::filesystem::path chosen;
if (requested_is_explicit && requested && requested[0]) {
/* --bios, or a BIOS named in settings.toml. NOT the compile-time
* default: that is a build constant, not something the player asked
* for, and treating it as a choice made a stray file near the exe
* produce a "BIOS Mismatch" dialog before the bundled image was even
* considered. */
chosen = resolve_bios_path(requested, argv0);
} else {
std::filesystem::path cached = read_cached_path(argv0, "bios.cfg");
if (!cached.empty() && std::filesystem::exists(cached)) chosen = cached;
}
if (!chosen.empty() && std::filesystem::exists(chosen)) {
if (validate_bios_for_launch(chosen)) return chosen; /* activates it */
/* Mismatched. With OpenBIOS available this is recoverable, so say so
* and carry on rather than dead-ending on a file they chose once. */
if (!(openbios_allowed && bundled)) return {};
}
/* Bundled BIOS (psx_bios_image.image_bundled — a redistributable image
* like OpenBIOS that ships WITH the game): the user supplies only their
* disc. There is nothing to pick and no cached path to consult — if the
* bundled image is missing or tampered (the identity gate above already
* refused it), that is an installation defect, not a selection problem. */
if (psx_bios_image.image_bundled) {
/* 2. No usable explicit choice -> bundled OpenBIOS, if this title allows it. */
if (openbios_allowed && bundled) {
std::filesystem::path img = resolve_bios_path(
s_bundled_bios_rel.empty() ? nullptr : s_bundled_bios_rel.c_str(), argv0);
if (!img.empty() && std::filesystem::exists(img) &&
bios_backend_for_file(img, nullptr, nullptr) == bundled &&
psx_bios_activate(bundled)) {
return img;
}
launcher_warning("Bundled BIOS Missing",
std::string("This build ships its own BIOS (") +
psx_bios_image.image_id + "), but the bundled image was not "
"found or does not match.\n\nExpected next to the executable:\n" +
(requested && requested[0] ? requested : "(default path)") +
"\n\nReinstall or rebuild — there is nothing to select.");
bundled->image->image_id + "), but the bundled image is missing "
"or does not match.\n\nExpected next to the executable:\n" +
(s_bundled_bios_rel.empty() ? "(default path)" : s_bundled_bios_rel) +
"\n\nReinstall or rebuild.");
return {};
}
std::filesystem::path cached = read_cached_path(argv0, "bios.cfg");
if (!cached.empty() && std::filesystem::exists(cached) &&
validate_bios_for_launch(cached)) {
return cached;
}
/* Interactive pick. Be explicit about WHICH of the two user-supplied
* files is being requested — first-time users see two pickers in a
* row and the difference between "BIOS" and "disc image" is not
* obvious to non-technical players. */
const std::string bios_id = psx_bios_image.image_id[0]
? std::string(psx_bios_image.image_id) : std::string("PlayStation BIOS");
/* 3. This title requires a retail BIOS: ask for one. */
const std::string accepted = bios_accepted_images();
launcher_info((s_picker_game_name + " — PlayStation BIOS needed").c_str(),
s_picker_game_name + " does not include any Sony or game files.\n\n"
s_picker_game_name + " requires a PlayStation BIOS.\n\n"
"Step 1 of 2 — PlayStation BIOS\n\n"
"In the next window, select your PlayStation BIOS dump. This build "
"requires the exact image it was compiled from: " + bios_id +
" (" + std::to_string(psx_bios_image.image_size / 1024u) + " KB). "
"You must dump it from your own console or otherwise "
"legally obtain it.\n\n"
"(This is NOT the game disc — that is asked for next.)");
"requires the exact image it was compiled from: " + accepted + ". "
"You must dump it from your own console or otherwise legally obtain "
"it.\n\n(This is NOT the game disc — that is asked for next.)");
std::string bios_title =
s_picker_game_name + " — Step 1 of 2: select PlayStation BIOS (" + bios_id + ")";
s_picker_game_name + " — Step 1 of 2: select PlayStation BIOS (" + accepted + ")";
for (;;) {
std::filesystem::path picked;
if (!pick_runtime_file(
@@ -4459,6 +4511,10 @@ int main(int argc, char** argv) {
const char* game_config_path = nullptr;
const char* disc_override_path = nullptr;
bool bios_from_cli = false; /* CLI --bios/positional wins over settings.toml */
/* Did the PLAYER choose this BIOS (CLI or settings), as opposed to it
* being the compile-time default? Only a real choice overrides the
* bundled OpenBIOS — see docs/BIOS_SELECTION.md. */
bool bios_explicit = false;
/* Launcher overrides (mirrors snesrecomp): --launcher forces the GUI back on
* even when [launcher] skip_launcher = true is set; --no-launcher (and the
* PSX_NO_LAUNCHER env) forces it off. --launcher wins if both are given. */
@@ -4499,6 +4555,7 @@ int main(int argc, char** argv) {
if (std::strcmp(argv[i], "--bios") == 0 && i + 1 < argc) {
bios_path = argv[++i];
bios_from_cli = true;
bios_explicit = true;
} else if (std::strcmp(argv[i], "--game") == 0 && i + 1 < argc) {
game_config_path = argv[++i];
} else if (std::strcmp(argv[i], "--disc") == 0 && i + 1 < argc) {
@@ -4549,6 +4606,8 @@ int main(int argc, char** argv) {
if (!bios_from_cli) {
bios_path = argv[i];
bios_from_cli = true;
bios_explicit = true;
bios_explicit = true;
} else {
std::fprintf(stderr,
"psxrecomp: ignoring unexpected positional argument after BIOS selection: %s\n",
@@ -4827,6 +4886,9 @@ int main(int argc, char** argv) {
fast_boot = gc.runtime.fast_boot;
bios_hle = gc.runtime.bios_hle;
bios_hle_keep_intro = gc.runtime.bios_hle_keep_intro;
/* Developer compatibility finding, applied before BIOS selection.
* Not exposed to settings.toml on purpose — see BIOS_SELECTION.md. */
s_openbios_allowed = gc.runtime.openbios;
/* Let the dispatch layer distinguish "dirty because text was
* loaded" from "diverged because runtime wrote different code over
* the original EXE image". Packed/self-modifying games can rewrite
@@ -5111,9 +5173,14 @@ int main(int argc, char** argv) {
* as-hide treatment as lock_mode / ws_offered below) so a stale
* settings.toml or launcher-less build can never point a bundled
* build at a foreign image. The identity gate is the backstop. */
if (us.has_bios_path && !bios_from_cli && !psx_bios_image.image_bundled) {
/* A BIOS named in settings.toml is a player choice. Note this runs
* BEFORE any backend is activated, so it must not consult
* psx_bios_image (all-zero until selection) — ask the registry. */
if (us.has_bios_path && !bios_from_cli &&
!(s_openbios_allowed && psx_bios_bundled() != nullptr)) {
settings_bios_storage = us.bios_path.string();
bios_path = settings_bios_storage.c_str();
bios_explicit = true;
}
if (us.has_disc_path && !disc_override_path)
resolved_disc = normalize_disc_path_for_launch(us.disc_path);
@@ -5673,7 +5740,8 @@ int main(int argc, char** argv) {
memcard2_path.clear();
}
std::filesystem::path resolved_bios = resolve_bios_for_runtime(bios_path, argv[0]);
std::filesystem::path resolved_bios =
resolve_bios_for_runtime(bios_path, argv[0], bios_explicit);
if (resolved_bios.empty()) {
std::fprintf(stderr, "psxrecomp: no BIOS selected; exiting.\n");
return 1;