Files
psxrecomp/runtime/src/main.cpp
T
Matthew Stanley 035a9fa968 release hygiene: explicit two-step file pickers, prod event-ring gate, decouple capture from compile
- First-run pickers now state exactly which user-supplied file is being
  requested: an info box + step-numbered dialog title for the BIOS
  (Step 1 of 2, SCPH1001.BIN) and the game disc (Step 2 of 2, named
  game + accepted formats), each noting what it is NOT. Game name comes
  from the loaded game config.
- event_ring recording is now compiled out under PSX_NO_DEBUG_TOOLS:
  it is only readable via the TCP debug server (stripped in production)
  and is not part of the freeze dump, so recording was pure hot-path
  cost. All other rings were already no-op gated.
- Overlay autocapture (which writes the player-shareable
  overlay_captures.json) is now enabled whenever overlay_cache is on,
  no longer coupled to the dev-only autocompile command — player builds
  produce the contribution file the README promises; the background
  compile still requires a configured command and no-ops without one.

Production surface after this: no console (-mwindows), no TCP server,
no ring recording; keeps overlay cache + captures, freeze watchdog JSON,
and crash report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:28:10 -07:00

1538 lines
60 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* main.cpp — Phase 3 runtime entry point.
*
* Loads BIOS ROM, initializes CPU state + SDL display, calls into
* the recompiled reset vector. BIOS drives execution; SDL presents
* VRAM at each vblank via callback from gpu_vblank_tick().
*/
#include "cpu_state.h"
#include "psx_interpreter.h"
#include "cdrom.h"
#include "fntrace.h"
#include "boot_state.h"
#include "overlay_capture.h"
#include "overlay_loader.h"
#include "autocompile.h"
#include "gpu.h"
#include "frame_pacing.h"
#include "sio.h"
#include "spu.h"
#include "memcard.h"
#include "debug_server.h"
#include "crash_trace.h"
#include "freeze_heartbeat.h"
#include "config_loader.h"
#include "crc32.h"
#include <SDL.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <exception>
#include <array>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <commdlg.h>
#endif
#ifndef PSX_DEFAULT_BIOS_PATH
#define PSX_DEFAULT_BIOS_PATH "bios/SCPH1001.BIN"
#endif
#ifndef PSX_DEFAULT_GAME_CONFIG_PATH
#define PSX_DEFAULT_GAME_CONFIG_PATH ""
#endif
#ifndef PSX_WINDOW_TITLE
#define PSX_WINDOW_TITLE "psxrecomp"
#endif
#ifndef DEFAULT_DEBUG_PORT
#error DEFAULT_DEBUG_PORT must be defined by the runtime target.
#endif
extern "C" uint64_t gte_get_exec_count(void);
/* memory.c */
extern "C" void memory_init(const char* bios_path);
extern "C" void memory_set_sr_ptr(const uint32_t *p);
extern "C" uint32_t memory_get_bios_checksum(void);
/* dma.c */
extern "C" void dma_init(void);
/* mdec.c */
extern "C" void mdec_init(void);
/* timers.c */
extern "C" void timers_init(void);
/* interrupts.c */
extern "C" void interrupts_init(void);
extern "C" uint32_t psx_read_word(uint32_t addr);
extern "C" void psx_write_word(uint32_t addr, uint32_t val);
extern "C" uint16_t psx_read_half(uint32_t addr);
extern "C" void psx_write_half(uint32_t addr, uint16_t val);
extern "C" uint8_t psx_read_byte(uint32_t addr);
extern "C" void psx_write_byte(uint32_t addr, uint8_t val);
/* ---- SDL state ---- */
static SDL_Window* sdl_window;
static SDL_Renderer* sdl_renderer;
static SDL_Texture* sdl_texture;
static SDL_GameController* sdl_controller;
static SDL_JoystickID sdl_controller_instance = -1;
static uint32_t sdl_pixel_buf[640 * 512]; /* ARGB8888 staging buffer */
static int s_fast_boot_active = 0; /* cleared when game entry PC fires */
/* Vsync self-heal state (see SDL_RenderPresent wrapper in
* sdl_vblank_present). C linkage: freeze_heartbeat.c includes both in
* the freeze dump so a slow-frames wedge can be attributed to driver
* present backpressure from the dump alone. */
extern "C" {
uint32_t g_present_slow_count = 0; /* presents that blocked >250ms */
int g_present_vsync_disabled = 0; /* 1 once self-heal tripped */
}
/* Turbo-through-loads (step 4). C linkage: debug_server.c reads/toggles the
* enable and reports the frame counter. Enabled by game.toml [runtime]
* turbo_loads (opt-in per game) or the turbo_loads TCP command. */
extern "C" {
int g_turbo_loads_enabled = 0;
uint64_t g_turbo_loads_frames = 0; /* vblanks run unpaced (observability) */
}
static SDL_AudioDeviceID sdl_audio_device;
static int16_t sdl_audio_buf[2048 * 2];
static std::filesystem::path find_upward(std::filesystem::path start,
const std::filesystem::path& marker) {
std::error_code ec;
start = std::filesystem::absolute(start, ec);
if (ec) start = std::filesystem::current_path();
if (!std::filesystem::is_directory(start, ec)) start = start.parent_path();
for (;;) {
if (std::filesystem::exists(start / marker, ec)) return start;
if (!start.has_parent_path() || start.parent_path() == start) break;
start = start.parent_path();
}
return {};
}
static std::filesystem::path exe_dir_from_argv(const char* argv0) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path exe_dir;
if (argv0 && argv0[0]) {
exe_dir = fs::absolute(argv0, ec).parent_path();
if (ec) exe_dir.clear();
}
if (exe_dir.empty()) exe_dir = fs::current_path();
return exe_dir;
}
static std::filesystem::path resolve_existing_runtime_path(const char* requested,
const char* argv0) {
namespace fs = std::filesystem;
if (!requested || !requested[0]) return {};
std::error_code ec;
fs::path p(requested);
if (fs::exists(p, ec)) return fs::absolute(p, ec);
if (p.is_absolute()) return {};
std::vector<fs::path> roots;
roots.push_back(fs::current_path());
roots.push_back(exe_dir_from_argv(argv0));
for (const fs::path& root : roots) {
fs::path direct = root / p;
if (fs::exists(direct, ec)) return fs::absolute(direct, ec);
fs::path found = find_upward(root, p);
if (!found.empty()) return fs::absolute(found / p, ec);
}
return {};
}
static std::filesystem::path sidecar_cfg_path(const char* argv0, const char* filename) {
return exe_dir_from_argv(argv0) / filename;
}
static std::filesystem::path read_cached_path(const char* argv0, const char* filename) {
std::ifstream f(sidecar_cfg_path(argv0, filename));
if (!f.is_open()) return {};
std::string line;
std::getline(f, line);
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) {
line.pop_back();
}
return line.empty() ? std::filesystem::path{} : std::filesystem::path(line);
}
static void write_cached_path(const char* argv0, const char* filename,
const std::filesystem::path& path) {
std::ofstream f(sidecar_cfg_path(argv0, filename), std::ios::trunc);
if (f.is_open()) f << path.string() << "\n";
}
static void launcher_warning(const char* title, const std::string& msg) {
std::fprintf(stderr, "%s: %s\n", title, msg.c_str());
#ifdef _WIN32
MessageBoxA(NULL, msg.c_str(), title, MB_OK | MB_ICONWARNING);
#endif
}
static void launcher_info(const char* title, const std::string& msg) {
std::fprintf(stderr, "%s: %s\n", title, msg.c_str());
#ifdef _WIN32
MessageBoxA(NULL, msg.c_str(), title, MB_OK | MB_ICONINFORMATION);
#endif
}
/* Game display name for picker dialogs ("Tomba!"); set after the game
* config loads, before any interactive file resolution. */
static std::string s_picker_game_name = "PSXRecomp";
static bool pick_runtime_file(const char* title, const char* filter,
std::filesystem::path& out) {
#ifdef _WIN32
char path_buf[4096];
std::memset(path_buf, 0, sizeof(path_buf));
OPENFILENAMEA ofn;
std::memset(&ofn, 0, sizeof(ofn));
ofn.lStructSize = sizeof(ofn);
ofn.hwndOwner = NULL;
ofn.lpstrFilter = filter;
ofn.lpstrFile = path_buf;
ofn.nMaxFile = (DWORD)sizeof(path_buf);
ofn.lpstrTitle = title;
ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY;
if (!GetOpenFileNameA(&ofn)) return false;
out = path_buf;
return true;
#else
(void)filter;
(void)out;
std::fprintf(stderr, "psxrecomp: %s requires a command-line path on this platform.\n", title);
return false;
#endif
}
static std::string uppercase_ascii(std::string s) {
for (char& c : s) {
c = (char)std::toupper((unsigned char)c);
}
return s;
}
static bool ends_with_ci(const std::string& s, const std::string& suffix) {
if (s.size() < suffix.size()) return false;
for (size_t i = 0; i < suffix.size(); i++) {
char a = (char)std::toupper((unsigned char)s[s.size() - suffix.size() + i]);
char b = (char)std::toupper((unsigned char)suffix[i]);
if (a != b) return false;
}
return true;
}
static std::filesystem::path cue_data_path(const std::filesystem::path& path) {
if (!ends_with_ci(path.string(), ".cue")) return path;
std::ifstream cue(path);
if (!cue.is_open()) return path;
std::string line;
while (std::getline(cue, line)) {
const std::string upper = uppercase_ascii(line);
const size_t file_pos = upper.find("FILE");
const size_t binary_pos = upper.find("BINARY");
if (file_pos == std::string::npos || binary_pos == std::string::npos) continue;
size_t q1 = line.find('"', file_pos);
size_t q2 = (q1 == std::string::npos) ? std::string::npos : line.find('"', q1 + 1);
std::string bin_name;
if (q1 != std::string::npos && q2 != std::string::npos && q2 > q1 + 1) {
bin_name = line.substr(q1 + 1, q2 - q1 - 1);
} else {
std::istringstream iss(line.substr(file_pos + 4));
iss >> bin_name;
}
if (bin_name.empty()) break;
std::filesystem::path bin_path(bin_name);
if (bin_path.is_relative()) return path.parent_path() / bin_path;
return bin_path;
}
return path;
}
static bool read_at(std::ifstream& f, uint64_t offset, uint8_t* out, size_t len) {
f.clear();
f.seekg((std::streamoff)offset, std::ios::beg);
if (!f.good()) return false;
f.read(reinterpret_cast<char*>(out), (std::streamsize)len);
return f.gcount() == (std::streamsize)len;
}
static bool is_iso_pvd(const uint8_t* sector) {
return sector[0] == 0x01 && std::memcmp(&sector[1], "CD001", 5) == 0 && sector[6] == 0x01;
}
static std::string psx_exe_id_from_game_id(const std::string& game_id) {
if (game_id.size() == 10 && game_id[4] == '-') {
return game_id.substr(0, 4) + "_" + game_id.substr(5, 3) + "." + game_id.substr(8, 2);
}
return game_id;
}
struct DiscValidation {
bool opened = false;
bool has_header = false;
bool id_matches = false;
std::string detail;
};
static DiscValidation validate_disc_image(const std::filesystem::path& selected_path,
const std::string& game_id) {
DiscValidation v;
const std::filesystem::path data_path = cue_data_path(selected_path);
std::ifstream f(data_path, std::ios::binary | std::ios::ate);
if (!f.is_open()) {
v.detail = "Could not open the selected disc image or its CUE-referenced BIN file.";
return v;
}
v.opened = true;
const uint64_t size = (uint64_t)f.tellg();
uint8_t sector[2048];
if ((size >= (16ull * 2352ull + 24ull + sizeof(sector))) &&
read_at(f, 16ull * 2352ull + 24ull, sector, sizeof(sector)) &&
is_iso_pvd(sector)) {
v.has_header = true;
} else if ((size >= (16ull * 2048ull + sizeof(sector))) &&
read_at(f, 16ull * 2048ull, sector, sizeof(sector)) &&
is_iso_pvd(sector)) {
v.has_header = true;
}
if (!v.has_header) {
v.detail = "No ISO9660 CD001 header was found at the standard PS1 disc locations.";
return v;
}
if (game_id.empty()) {
v.id_matches = true;
return v;
}
const std::string exe_id = uppercase_ascii(psx_exe_id_from_game_id(game_id));
const std::string serial_id = uppercase_ascii(game_id);
const uint64_t scan_len_u64 = std::min<uint64_t>(size, 16ull * 1024ull * 1024ull);
const size_t scan_len = (size_t)scan_len_u64;
std::vector<uint8_t> scan(scan_len);
if (!read_at(f, 0, scan.data(), scan.size())) {
v.detail = "The ISO header is present, but the image could not be scanned for the game ID.";
return v;
}
auto contains_ascii = [&](const std::string& needle) {
if (needle.empty() || needle.size() > scan.size()) return false;
return std::search(scan.begin(), scan.end(), needle.begin(), needle.end()) != scan.end();
};
if (contains_ascii(exe_id) || contains_ascii(serial_id)) {
v.id_matches = true;
} else {
v.detail = "The disc header is readable, but it does not contain the expected game ID " +
exe_id + " / " + serial_id + " in the early disc metadata.";
}
return v;
}
static bool validate_disc_for_launch(const std::filesystem::path& path,
const std::string& game_id) {
const DiscValidation v = validate_disc_image(path, game_id);
if (!v.opened) {
launcher_warning("Disc Image Not Found", v.detail + "\n\nSelected path:\n" + path.string());
return false;
}
if (!v.has_header || !v.id_matches) {
launcher_warning("Disc Image Warning",
v.detail + "\n\nThis may be the wrong game or a corrupt image. The runtime will try to run it anyway.");
}
return true;
}
static bool validate_bios_for_launch(const std::filesystem::path& path) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f.is_open()) return false;
const std::streamoff size = f.tellg();
if (size != 512 * 1024) {
launcher_warning("BIOS Warning",
"The selected BIOS is not 512 KiB. Please select SCPH1001.BIN.");
return false;
}
std::vector<uint8_t> data((size_t)size);
if (!read_at(f, 0, data.data(), data.size())) return false;
const uint32_t crc = crc32_compute(data.data(), data.size());
if (crc != 0x37157331u) {
char buf[256];
std::snprintf(buf, sizeof(buf),
"The selected BIOS CRC32 is %08X, but this build was validated with SCPH1001.BIN CRC32 37157331.\n\n"
"The runtime will try it anyway, but boot may fail.", crc);
launcher_warning("BIOS Warning", buf);
}
return true;
}
static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0);
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;
}
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. */
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"
"Step 1 of 2 — PlayStation BIOS\n\n"
"In the next window, select your PlayStation BIOS dump. The file is "
"usually named SCPH1001.BIN and is exactly 512 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.)");
std::string bios_title =
s_picker_game_name + " — Step 1 of 2: select PlayStation BIOS (SCPH1001.BIN)";
for (;;) {
std::filesystem::path picked;
if (!pick_runtime_file(
bios_title.c_str(),
"PlayStation BIOS (*.bin)\0*.bin\0All Files (*.*)\0*.*\0",
picked)) {
return {};
}
if (validate_bios_for_launch(picked)) {
write_cached_path(argv0, "bios.cfg", picked);
return picked;
}
}
}
static std::filesystem::path resolve_disc_for_runtime(const std::filesystem::path& config_disc,
const char* disc_override,
const std::string& game_id,
const char* argv0) {
if (disc_override && disc_override[0]) {
std::filesystem::path p = std::filesystem::absolute(disc_override);
return validate_disc_for_launch(p, game_id) ? p : std::filesystem::path{};
}
if (!config_disc.empty() && std::filesystem::exists(config_disc) &&
validate_disc_for_launch(config_disc, game_id)) {
return config_disc;
}
std::filesystem::path cached = read_cached_path(argv0, "disc.cfg");
if (!cached.empty() && std::filesystem::exists(cached) &&
validate_disc_for_launch(cached, game_id)) {
return cached;
}
launcher_info((s_picker_game_name + " — game disc image needed").c_str(),
"Step 2 of 2 — game disc image\n\n"
"In the next window, select your " + s_picker_game_name +
(game_id.empty() ? std::string() : " (" + game_id + ")") +
" disc image ripped from your own disc.\n\n"
"Accepted formats: .cue (preferred, with its .bin next to it), "
".bin, or .iso.\n\n"
"(This is NOT the BIOS — the BIOS was already chosen.)");
std::string disc_title =
s_picker_game_name + " — Step 2 of 2: select " + s_picker_game_name +
" disc image (.cue / .bin / .iso)";
for (;;) {
std::filesystem::path picked;
if (!pick_runtime_file(
disc_title.c_str(),
"PS1 Disc Images (*.cue;*.bin;*.iso)\0*.cue;*.bin;*.iso\0All Files (*.*)\0*.*\0",
picked)) {
return {};
}
if (validate_disc_for_launch(picked, game_id)) {
write_cached_path(argv0, "disc.cfg", picked);
return picked;
}
}
}
static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path p(requested);
if (fs::exists(p, ec)) {
fs::path abs = fs::absolute(p, ec);
return ec ? p : abs;
}
if (p.is_absolute()) return p;
std::vector<fs::path> roots;
roots.push_back(fs::current_path());
if (argv0 && argv0[0]) roots.push_back(fs::absolute(argv0, ec).parent_path());
for (const fs::path& root : roots) {
fs::path found = find_upward(root, p);
if (!found.empty()) return found / p;
}
return p;
}
// Fallback memcard directory used when no game config (or its [runtime]
// block) specifies one. Prefer the executable's directory so saves live
// next to the binary; fall back to the current working directory.
static std::filesystem::path default_memcard_dir(const char* argv0) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path exe_dir;
if (argv0 && argv0[0]) {
exe_dir = fs::absolute(argv0, ec).parent_path();
if (ec) exe_dir.clear();
}
if (exe_dir.empty()) exe_dir = fs::current_path();
return exe_dir;
}
static void close_controller(void);
static void shutdown_runtime(void) {
memcard_flush_all();
overlay_capture_write_json();
if (sdl_audio_device) {
SDL_ClearQueuedAudio(sdl_audio_device);
SDL_CloseAudioDevice(sdl_audio_device);
sdl_audio_device = 0;
}
close_controller();
debug_server_shutdown();
}
static void sdl_audio_pump(void) {
if (!sdl_audio_device) return;
const uint32_t bytes_per_frame = sizeof(int16_t) * 2u;
const uint32_t max_queue_bytes = 44100u * bytes_per_frame / 5u;
if (SDL_GetQueuedAudioSize(sdl_audio_device) > max_queue_bytes) return;
static double sample_accum = 0.0;
sample_accum += 44100.0 / 60.0;
int frames = (int)sample_accum;
sample_accum -= (double)frames;
if (frames <= 0) return;
if (frames > 2048) frames = 2048;
spu_render(sdl_audio_buf, frames);
SDL_QueueAudio(sdl_audio_device, sdl_audio_buf,
(uint32_t)frames * bytes_per_frame);
}
/* PS1 digital pad button bits (active-low: 0=pressed, 1=released).
* Bit 0 = SELECT, Bit 3 = START, Bit 4 = UP, Bit 5 = RIGHT,
* Bit 6 = DOWN, Bit 7 = LEFT, Bit 8 = L2, Bit 9 = R2,
* Bit 10 = L1, Bit 11 = R1, Bit 12 = TRIANGLE, Bit 13 = CIRCLE,
* Bit 14 = CROSS, Bit 15 = SQUARE */
#define PAD_SELECT (1 << 0)
#define PAD_START (1 << 3)
#define PAD_UP (1 << 4)
#define PAD_RIGHT (1 << 5)
#define PAD_DOWN (1 << 6)
#define PAD_LEFT (1 << 7)
#define PAD_L2 (1 << 8)
#define PAD_R2 (1 << 9)
#define PAD_L1 (1 << 10)
#define PAD_R1 (1 << 11)
#define PAD_TRIANGLE (1 << 12)
#define PAD_CIRCLE (1 << 13)
#define PAD_CROSS (1 << 14)
#define PAD_SQUARE (1 << 15)
struct ControllerSource {
enum class Kind {
None,
Button,
AxisPositive,
AxisNegative,
};
Kind kind = Kind::None;
int id = -1;
};
struct PsxButtonMap {
uint16_t bit;
const char* ini_name;
std::vector<ControllerSource> sources;
};
static int controller_device_index = 0;
static int controller_deadzone = 12000;
static std::array<PsxButtonMap, 14> controller_map = {{
{ PAD_UP, "up", {} },
{ PAD_DOWN, "down", {} },
{ PAD_LEFT, "left", {} },
{ PAD_RIGHT, "right", {} },
{ PAD_CROSS, "cross", {} },
{ PAD_CIRCLE, "circle", {} },
{ PAD_SQUARE, "square", {} },
{ PAD_TRIANGLE, "triangle", {} },
{ PAD_L1, "l1", {} },
{ PAD_R1, "r1", {} },
{ PAD_L2, "l2", {} },
{ PAD_R2, "r2", {} },
{ PAD_START, "start", {} },
{ PAD_SELECT, "select", {} },
}};
static std::string trim_copy(const std::string& s) {
size_t first = 0;
while (first < s.size() && std::isspace((unsigned char)s[first])) first++;
size_t last = s.size();
while (last > first && std::isspace((unsigned char)s[last - 1])) last--;
return s.substr(first, last - first);
}
static std::string lower_copy(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
return s;
}
static bool parse_bool_value(const std::string& value, bool fallback) {
std::string v = lower_copy(trim_copy(value));
if (v == "1" || v == "true" || v == "yes" || v == "on") return true;
if (v == "0" || v == "false" || v == "no" || v == "off") return false;
return fallback;
}
static ControllerSource parse_controller_source(const std::string& raw) {
std::string s = lower_copy(trim_copy(raw));
ControllerSource out;
if (s.empty() || s == "none" || s == "disabled") return out;
if (s == "a") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_A; return out; }
if (s == "b") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_B; return out; }
if (s == "x") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_X; return out; }
if (s == "y") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_Y; return out; }
if (s == "back" || s == "view" || s == "select") {
out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_BACK; return out;
}
if (s == "start" || s == "menu") {
out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_START; return out;
}
if (s == "guide") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_GUIDE; return out; }
if (s == "leftstick") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_LEFTSTICK; return out; }
if (s == "rightstick") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_RIGHTSTICK; return out; }
if (s == "leftshoulder" || s == "lb" || s == "l1") {
out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_LEFTSHOULDER; return out;
}
if (s == "rightshoulder" || s == "rb" || s == "r1") {
out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_RIGHTSHOULDER; return out;
}
if (s == "dpup" || s == "dpadup") {
out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_UP; return out;
}
if (s == "dpdown" || s == "dpaddown") {
out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_DOWN; return out;
}
if (s == "dpleft" || s == "dpadleft") {
out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_LEFT; return out;
}
if (s == "dpright" || s == "dpadright") {
out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_RIGHT; return out;
}
if (s == "lefttrigger" || s == "lt" || s == "l2") {
out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_TRIGGERLEFT; return out;
}
if (s == "righttrigger" || s == "rt" || s == "r2") {
out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_TRIGGERRIGHT; return out;
}
if (s == "leftx+" || s == "lsright") {
out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_LEFTX; return out;
}
if (s == "leftx-" || s == "lsleft") {
out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_LEFTX; return out;
}
if (s == "lefty+" || s == "lsdown") {
out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_LEFTY; return out;
}
if (s == "lefty-" || s == "lsup") {
out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_LEFTY; return out;
}
if (s == "rightx+" || s == "rsright") {
out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_RIGHTX; return out;
}
if (s == "rightx-" || s == "rsleft") {
out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_RIGHTX; return out;
}
if (s == "righty+" || s == "rsdown") {
out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_RIGHTY; return out;
}
if (s == "righty-" || s == "rsup") {
out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_RIGHTY; return out;
}
SDL_GameControllerButton button = SDL_GameControllerGetButtonFromString(s.c_str());
if (button != SDL_CONTROLLER_BUTTON_INVALID) {
out.kind = ControllerSource::Kind::Button;
out.id = button;
return out;
}
SDL_GameControllerAxis axis = SDL_GameControllerGetAxisFromString(s.c_str());
if (axis != SDL_CONTROLLER_AXIS_INVALID) {
out.kind = ControllerSource::Kind::AxisPositive;
out.id = axis;
return out;
}
return out;
}
static std::vector<ControllerSource> parse_source_list(const std::string& value) {
std::vector<ControllerSource> sources;
std::stringstream ss(value);
std::string item;
while (std::getline(ss, item, ',')) {
ControllerSource source = parse_controller_source(item);
if (source.kind != ControllerSource::Kind::None) {
sources.push_back(source);
}
}
return sources;
}
static void set_default_controller_mapping(void) {
for (auto& entry : controller_map) entry.sources.clear();
auto set_sources = [](const char* name, const char* sources) {
for (auto& entry : controller_map) {
if (std::strcmp(entry.ini_name, name) == 0) {
entry.sources = parse_source_list(sources);
return;
}
}
};
set_sources("up", "dpup,lefty-");
set_sources("down", "dpdown,lefty+");
set_sources("left", "dpleft,leftx-");
set_sources("right", "dpright,leftx+");
set_sources("cross", "a");
set_sources("circle", "b");
set_sources("square", "x");
set_sources("triangle", "y");
set_sources("l1", "leftshoulder");
set_sources("r1", "rightshoulder");
set_sources("l2", "lefttrigger");
set_sources("r2", "righttrigger");
set_sources("start", "start");
set_sources("select", "back");
}
static std::string default_input_ini_text(void) {
return
"; PSXRecomp input mapping. PSX buttons are active when any listed source is pressed.\n"
"; Sources use SDL/Xbox names: a,b,x,y,back,start,leftshoulder,rightshoulder,\n"
"; lefttrigger,righttrigger,dpup,dpdown,dpleft,dpright,leftx-/leftx+/lefty-/lefty+.\n"
"\n"
"[controller]\n"
"enabled = true\n"
"device = 0\n"
"deadzone = 12000\n"
"\n"
"[mapping]\n"
"up = dpup,lefty-\n"
"down = dpdown,lefty+\n"
"left = dpleft,leftx-\n"
"right = dpright,leftx+\n"
"cross = a\n"
"circle = b\n"
"square = x\n"
"triangle = y\n"
"l1 = leftshoulder\n"
"r1 = rightshoulder\n"
"l2 = lefttrigger\n"
"r2 = righttrigger\n"
"start = start\n"
"select = back\n";
}
static void load_input_config(const char* argv0) {
set_default_controller_mapping();
namespace fs = std::filesystem;
fs::path config_path = exe_dir_from_argv(argv0) / "input.ini";
std::error_code ec;
if (!fs::exists(config_path, ec)) {
std::ofstream out(config_path, std::ios::binary);
if (out) out << default_input_ini_text();
return;
}
std::ifstream in(config_path);
if (!in) return;
bool controller_enabled = true;
std::string section;
std::string line;
while (std::getline(in, line)) {
size_t comment = line.find_first_of(";#");
if (comment != std::string::npos) line.resize(comment);
line = trim_copy(line);
if (line.empty()) continue;
if (line.front() == '[' && line.back() == ']') {
section = lower_copy(trim_copy(line.substr(1, line.size() - 2)));
continue;
}
size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = lower_copy(trim_copy(line.substr(0, eq)));
std::string value = trim_copy(line.substr(eq + 1));
if (section == "controller") {
if (key == "enabled") {
controller_enabled = parse_bool_value(value, controller_enabled);
} else if (key == "device") {
controller_device_index = std::max(0, std::atoi(value.c_str()));
} else if (key == "deadzone") {
controller_deadzone = std::max(0, std::min(32767, std::atoi(value.c_str())));
}
} else if (section == "mapping") {
for (auto& entry : controller_map) {
if (key == entry.ini_name) {
entry.sources = parse_source_list(value);
break;
}
}
}
}
if (!controller_enabled) {
for (auto& entry : controller_map) entry.sources.clear();
}
}
static void close_controller(void) {
if (sdl_controller) {
SDL_GameControllerClose(sdl_controller);
sdl_controller = nullptr;
sdl_controller_instance = -1;
}
}
static void open_configured_controller(void) {
if (sdl_controller) return;
/* First pass: open the configured device index. Second pass: open the
* first available controller — covers Steam Remote Play where the virtual
* Xbox controller appears at an unpredictable index, and cases where the
* configured index is simply out of range. */
for (int pass = 0; pass < 2 && !sdl_controller; pass++) {
int seen = 0;
int joysticks = SDL_NumJoysticks();
for (int i = 0; i < joysticks; i++) {
if (!SDL_IsGameController(i)) continue;
int match = (pass == 0) ? (seen == controller_device_index) : 1;
seen++;
if (!match) continue;
sdl_controller = SDL_GameControllerOpen(i);
if (sdl_controller) {
SDL_Joystick* joy = SDL_GameControllerGetJoystick(sdl_controller);
sdl_controller_instance = joy ? SDL_JoystickInstanceID(joy) : -1;
const char* name = SDL_GameControllerName(sdl_controller);
std::fprintf(stdout, "psxrecomp runtime: controller %d (pass %d): %s\n",
seen - 1, pass, name ? name : "(unnamed)");
}
break;
}
}
}
static bool controller_source_pressed(const ControllerSource& source) {
if (!sdl_controller) return false;
switch (source.kind) {
case ControllerSource::Kind::Button:
return SDL_GameControllerGetButton(
sdl_controller, (SDL_GameControllerButton)source.id) != 0;
case ControllerSource::Kind::AxisPositive:
return SDL_GameControllerGetAxis(
sdl_controller, (SDL_GameControllerAxis)source.id) > controller_deadzone;
case ControllerSource::Kind::AxisNegative:
return SDL_GameControllerGetAxis(
sdl_controller, (SDL_GameControllerAxis)source.id) < -controller_deadzone;
case ControllerSource::Kind::None:
default:
return false;
}
}
static uint16_t pad_from_keyboard(void) {
const Uint8* keys = SDL_GetKeyboardState(NULL);
uint16_t buttons = 0xFFFF; /* all released */
if (keys[SDL_SCANCODE_UP]) buttons &= ~PAD_UP;
if (keys[SDL_SCANCODE_DOWN]) buttons &= ~PAD_DOWN;
if (keys[SDL_SCANCODE_LEFT]) buttons &= ~PAD_LEFT;
if (keys[SDL_SCANCODE_RIGHT]) buttons &= ~PAD_RIGHT;
if (keys[SDL_SCANCODE_RETURN]) buttons &= ~PAD_START;
if (keys[SDL_SCANCODE_RSHIFT]) buttons &= ~PAD_SELECT;
if (keys[SDL_SCANCODE_X]) buttons &= ~PAD_CROSS;
if (keys[SDL_SCANCODE_Z]) buttons &= ~PAD_SQUARE;
if (keys[SDL_SCANCODE_S]) buttons &= ~PAD_CIRCLE;
if (keys[SDL_SCANCODE_A]) buttons &= ~PAD_TRIANGLE;
if (keys[SDL_SCANCODE_Q]) buttons &= ~PAD_L1;
if (keys[SDL_SCANCODE_W]) buttons &= ~PAD_R1;
if (keys[SDL_SCANCODE_E]) buttons &= ~PAD_L2;
if (keys[SDL_SCANCODE_R]) buttons &= ~PAD_R2;
return buttons;
}
static uint16_t merge_controller_pad(uint16_t buttons) {
if (!sdl_controller) return buttons;
for (const auto& entry : controller_map) {
for (const auto& source : entry.sources) {
if (controller_source_pressed(source)) {
buttons &= (uint16_t)~entry.bit;
break;
}
}
}
return buttons;
}
/* PSX native vblank cadence: NTSC ≈ 59.94 Hz. Wall-clock target keeps
* audio sample generation (735 samples/vblank * 60 = 44100/sec) matched
* to the SDL audio device drain rate, eliminating queue overflow drops
* and underruns. Uncapped, the host runs the simulation at whatever
* speed it can — typically several × realtime — and audio glitches. */
static constexpr double PSX_FRAME_PERIOD_MS = 1000.0 / 59.94;
/* Called from gpu_vblank_tick() at each simulated vblank. */
static void sdl_vblank_present(void) {
#ifndef PSX_NO_DEBUG_TOOLS
/* Debug server: pause gate, poll commands, record frame, check watchpoints. */
debug_server_wait_if_paused();
debug_server_poll();
debug_server_record_frame();
debug_server_check_watchpoints();
/* Check debug server input override. */
int override = debug_server_get_input_override();
#else
/* Production: skip debug server. Still need to advance frame counter
* locally so anything else that reads it continues to work. */
extern uint64_t s_frame_count;
s_frame_count++;
int override = -1;
#endif
/* Step 2.8 automation ticks — BEFORE the turbo/fast-boot early returns
* below, so capture detection and compile pickup keep running while the
* frontend is skipping presents. Both are cheap and emu-thread-only. */
overlay_autocapture_tick();
autocompile_poll_main();
/* Pump SDL events to prevent window freeze. */
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
if (ev.type == SDL_QUIT) {
shutdown_runtime();
std::exit(0);
} else if (ev.type == SDL_CONTROLLERDEVICEADDED) {
open_configured_controller();
} else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) {
if (ev.cdevice.which == sdl_controller_instance) {
close_controller();
open_configured_controller();
}
} else if (ev.type == SDL_KEYDOWN) {
/* Fullscreen toggle: F11, Alt+Enter, or Cmd/Ctrl+F.
* FULLSCREEN_DESKTOP keeps the desktop resolution; the
* renderer's logical size letterboxes the 640x480 image. */
const Uint16 mod = ev.key.keysym.mod;
if (ev.key.keysym.sym == SDLK_F11 ||
(ev.key.keysym.sym == SDLK_RETURN && (mod & KMOD_ALT)) ||
(ev.key.keysym.sym == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) {
Uint32 is_fs = SDL_GetWindowFlags(sdl_window) &
SDL_WINDOW_FULLSCREEN_DESKTOP;
SDL_SetWindowFullscreen(sdl_window,
is_fs ? 0 : SDL_WINDOW_FULLSCREEN_DESKTOP);
}
}
}
/* Sample keyboard state and feed into SIO controller.
* Debug server input override takes priority if active. */
uint16_t pad_buttons_this_frame;
if (override >= 0) {
pad_buttons_this_frame = (uint16_t)override;
} else {
pad_buttons_this_frame = merge_controller_pad(pad_from_keyboard());
}
sio_set_pad_state(pad_buttons_this_frame);
#ifndef PSX_SDL_NO_AUDIO
sdl_audio_pump();
#endif
/* TCP turbo is for automated validation and trace capture. It keeps the
* simulation advancing and the debug server polling, but removes frontend
* presentation and wall-clock pacing. */
#ifndef PSX_NO_DEBUG_TOOLS
if (debug_server_turbo_enabled()) return;
#endif
/* Turbo mode: while TAB is held, skip both VRAM->ARGB conversion and
* SDL_RenderPresent. The recompiled BIOS still advances simulated
* cycles every vblank, so the BIOS proceeds at whatever rate the host
* CPU sustains without graphics-driver vsync overhead. Present once
* every TURBO_PRESENT_EVERY frames so the user sees visual progress. */
{
const Uint8* keys = SDL_GetKeyboardState(NULL);
static int turbo_skip = 0;
const int TURBO_PRESENT_EVERY = 30;
if (keys[SDL_SCANCODE_TAB]) {
turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY;
if (turbo_skip != 0) return; /* skip render this frame */
} else {
turbo_skip = 0;
}
}
/* Fast boot: run at full speed with no display until game entry PC fires.
* Kernel init and disc auth still execute; only logo rendering is skipped. */
if (s_fast_boot_active) {
extern int fntrace_is_game_started(void);
if (!fntrace_is_game_started()) return;
s_fast_boot_active = 0;
}
/* Turbo-through-loads (step 4, OPT-IN via game.toml [runtime]
* turbo_loads): while the game is loading — CD data stream active,
* XA/FMV excluded, post-BIOS-handoff only — skip wall-clock pacing and
* most presents so the guest runs at host speed. Step-3 measurement
* showed loads are paced by the game's own per-sector processing at
* real time (2.2-4.8 sectors/frame against a 32-256 IRQ budget), so
* host-speed execution is the lever that compresses load wall-time.
* Presents 1-in-30 so visual progress stays visible. */
if (g_turbo_loads_enabled) {
extern int fntrace_is_game_started(void);
if (fntrace_is_game_started() && cdrom_load_in_progress()) {
g_turbo_loads_frames++;
static int tl_skip = 0;
const int TL_PRESENT_EVERY = 30;
tl_skip = (tl_skip + 1) % TL_PRESENT_EVERY;
if (tl_skip != 0) return;
}
}
/* Wall-clock pacing: always runs once fast_boot has ended, even when the
* display is still disabled (e.g. game crt0 setup). Skipped only by the
* turbo and fast_boot early-returns above. frame_pacer_wait is the
* race-free replacement for the old open-coded loop whose double
* counter read could underflow into a ~24.7-day SDL_Delay (Bug B
* hard freeze). */
{
static FramePacer pacer = { 0 };
frame_pacer_wait(&pacer, PSX_FRAME_PERIOD_MS);
}
/* ---- Display from our VRAM ---- */
uint32_t w = 0, h = 0;
{
static bool disabled_frame_presented = false;
GpuDisplayInfo di;
gpu_get_display_info(&di);
if (di.disabled || di.width == 0 || di.height == 0) {
#ifndef PSX_SDL_NO_RENDER
if (!disabled_frame_presented) {
disabled_frame_presented = true;
SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255);
SDL_RenderClear(sdl_renderer);
SDL_RenderPresent(sdl_renderer);
}
#endif
return;
}
disabled_frame_presented = false;
w = di.width; h = di.height;
for (uint32_t y = 0; y < h; y++) {
for (uint32_t x = 0; x < w; x++) {
sdl_pixel_buf[y * w + x] = gpu_display_pixel_argb(&di, x, y);
}
}
}
/* Update only the active display rectangle. The backing texture is fixed
* at 640x512, while games can switch to smaller modes such as 320x224 for
* FMV; presenting the full texture would leave the active image stuck in
* the upper-left portion of the window. */
#ifndef PSX_SDL_NO_RENDER
SDL_Rect src = { 0, 0, (int)w, (int)h };
SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf, (int)(w * sizeof(uint32_t)));
SDL_Rect dst = { 0, 0, 640, 480 };
SDL_RenderClear(sdl_renderer);
SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst);
/* Vsync self-heal. The renderer is created with PRESENTVSYNC for
* tear-free output, but the wall-clock pacer above already holds
* 59.94 Hz, so driver vsync is redundant for timing. Under some
* driver states (observed: NVIDIA GL with the swap queue wedged)
* SwapBuffers blocks ~1.5 s per present, dragging the whole
* emulation to ~0.7 fps for minutes (freeze dump 1781045865:
* 8/8 main-thread samples inside wglSwapBuffers). If presents
* block pathologically several times in a row, drop driver vsync
* for the rest of the session; our own pacing keeps the rate. */
{
const Uint64 t0 = SDL_GetPerformanceCounter();
SDL_RenderPresent(sdl_renderer);
const Uint64 t1 = SDL_GetPerformanceCounter();
const Uint64 freq = SDL_GetPerformanceFrequency();
const Uint64 present_ms = (t1 >= t0 && freq) ? ((t1 - t0) * 1000u) / freq : 0;
if (!g_present_vsync_disabled && present_ms > 250) {
g_present_slow_count++;
if (g_present_slow_count >= 3 &&
SDL_RenderSetVSync(sdl_renderer, 0) == 0) {
g_present_vsync_disabled = 1;
}
}
}
#endif
}
int main(int argc, char** argv) {
/* Force line-buffered output so messages appear even if killed. */
std::setvbuf(stdout, nullptr, _IOLBF, 0);
std::setvbuf(stderr, nullptr, _IOLBF, 0);
std::fprintf(stderr, "psxrecomp: main() entered\n");
std::fflush(stderr);
/* Install crash handlers early so they catch issues during init too.
* Writes psx_last_run_report.json on signal/SEH/atexit/fail-fast. */
psx_crash_trace_install_handlers();
const char* bios_path = PSX_DEFAULT_BIOS_PATH;
const char* game_config_path = nullptr;
const char* disc_override_path = nullptr;
/* Parse args.
* --bios <path> override the compile-time BIOS path
* --game <toml> load a game config (single source of truth for
* disc / memcard / window title / debug port)
* --disc <path> override the game config disc path
* <positional> deprecated alias for --bios
* No --memcard-dir / --game-root flags: those are config-driven. */
for (int i = 1; i < argc; i++) {
if (std::strcmp(argv[i], "--bios") == 0 && i + 1 < argc) {
bios_path = argv[++i];
} 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) {
disc_override_path = argv[++i];
} else if (argv[i][0] != '-') {
bios_path = argv[i];
}
}
std::string default_game_config_storage;
if (!game_config_path) {
std::filesystem::path default_game_config =
resolve_existing_runtime_path(PSX_DEFAULT_GAME_CONFIG_PATH, argv[0]);
if (!default_game_config.empty()) {
default_game_config_storage = default_game_config.string();
game_config_path = default_game_config_storage.c_str();
}
}
std::filesystem::path memcard_dir;
std::filesystem::path resolved_disc;
std::string window_title = PSX_WINDOW_TITLE;
uint16_t debug_port = (uint16_t)DEFAULT_DEBUG_PORT;
std::string game_name;
std::string game_id;
std::string disc_speed; /* "1x" | "2x" | "4x" | "instant" */
int instant_rate = 0; /* 0 = cdrom.c built-in default */
uint32_t game_entry_pc = 0;
bool fast_boot = false;
if (game_config_path) {
try {
const auto gc = PSXRecompV4::load_game_config(game_config_path);
game_name = gc.name;
game_id = gc.id;
if (!gc.discs.empty()) resolved_disc = gc.discs.front();
if (gc.runtime.has_memcard_dir) memcard_dir = gc.runtime.memcard_dir;
if (gc.runtime.has_window_title) window_title = gc.runtime.window_title;
if (gc.runtime.has_debug_port) debug_port = gc.runtime.debug_port;
if (gc.runtime.has_disc_speed) disc_speed = gc.runtime.disc_speed;
if (gc.runtime.has_instant_max_per_frame)
instant_rate = gc.runtime.instant_max_per_frame;
if (gc.runtime.turbo_loads) {
g_turbo_loads_enabled = 1;
std::fprintf(stdout, "psxrecomp: turbo_loads enabled (opt-in)\n");
}
game_entry_pc = gc.entry_pc;
fast_boot = gc.runtime.fast_boot;
/* Overlay DLL cache (Layer A). Off unless enabled in [runtime];
* when on, capture overlay bytes and scan cache/<game_id>/ for
* precompiled overlay DLLs. */
if (gc.runtime.overlay_cache) {
std::filesystem::path exe_dir = exe_dir_from_argv(argv[0]);
std::string cache_dir = (exe_dir / "cache").string();
overlay_capture_set_out_dir(exe_dir.string().c_str());
overlay_capture_set_enabled(1);
overlay_loader_init(cache_dir.c_str(), game_id.c_str());
/* Step 2.8 variant-capture automation. Autocapture is ON
* whenever the cache is on: it writes the player-shareable
* overlay_captures.json (the contribution file the README
* promises) even on machines with no compile toolchain.
* The background compile additionally needs a configured
* command; autocompile_request() no-ops without one. */
overlay_autocapture_set_enabled(1);
if (gc.runtime.has_overlay_autocompile_cmd) {
autocompile_configure(
gc.runtime.overlay_autocompile_cmd.c_str(),
gc.project_root.string().c_str());
std::fprintf(stdout,
"psxrecomp: overlay autocompile enabled\n");
}
}
std::fprintf(stdout, "psxrecomp: loaded game config %s (%s, %s)\n",
game_config_path, game_name.c_str(), game_id.c_str());
} catch (const std::exception& ex) {
std::fprintf(stderr, "psxrecomp: failed to load --game %s: %s\n",
game_config_path, ex.what());
return 1;
}
}
if (!game_name.empty()) s_picker_game_name = game_name;
std::filesystem::path resolved_bios = resolve_bios_for_runtime(bios_path, argv[0]);
if (resolved_bios.empty()) {
std::fprintf(stderr, "psxrecomp: no BIOS selected; exiting.\n");
return 1;
}
if (game_config_path || disc_override_path || !resolved_disc.empty()) {
resolved_disc = resolve_disc_for_runtime(resolved_disc, disc_override_path, game_id, argv[0]);
if (game_config_path && resolved_disc.empty()) {
std::fprintf(stderr, "psxrecomp: no disc image selected; exiting.\n");
return 1;
}
}
if (memcard_dir.empty()) {
memcard_dir = default_memcard_dir(argv[0]);
}
std::string bios_path_str = resolved_bios.string();
std::string memcard_dir_str = memcard_dir.string();
std::string disc_path_str = resolved_disc.string();
std::fprintf(stdout, "psxrecomp runtime: loading BIOS from %s\n", bios_path_str.c_str());
memory_init(bios_path_str.c_str());
gpu_init();
dma_init();
mdec_init();
timers_init();
interrupts_init();
sio_init();
sio_connect_pad(0); /* Controller on port 1 */
spu_init();
cdrom_init(disc_path_str.empty() ? NULL : disc_path_str.c_str());
{
int divisor = 1; /* default: authentic 1x timing */
if (disc_speed == "instant") divisor = 0;
else if (disc_speed == "4x") divisor = 4;
else if (disc_speed == "2x") divisor = 2;
/* Store for post-BIOS application; boot always runs at 1x so the
* BIOS disc-init sequence sees correct timing. */
cdrom_set_game_speed(divisor);
if (instant_rate > 0) cdrom_set_instant_rate(instant_rate);
if (divisor != 1)
std::fprintf(stdout, "psxrecomp: disc_speed=%s (applied post-BIOS, "
"instant budget %d/frame)\n",
disc_speed.c_str(), cdrom_get_instant_rate());
}
memcard_init(memcard_dir_str.c_str());
std::atexit(memcard_flush_all);
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_init(debug_port);
#else
(void)debug_port;
#endif
/* Heartbeat always on — see freeze_heartbeat.c rationale. */
freeze_heartbeat_start("psx-runtime");
/* Register game entry_pc for post-BIOS disc speed switch. Fires once when
* the BIOS hands control to the game EXE — not on the BIOS shell. */
if (game_entry_pc != 0)
fntrace_set_game_range(game_entry_pc, 0);
/* ---- SDL init ---- */
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "0");
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
/* Prefer SDL's own HIDAPI driver over platform-native so Steam's virtual
* Xbox controller (injected by Steam Input / Remote Play) is enumerated
* as a game controller rather than a raw HID device. */
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, "0");
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) {
std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
return 1;
}
load_input_config(argv[0]);
open_configured_controller();
#ifndef PSX_SDL_NO_AUDIO
if (SDL_InitSubSystem(SDL_INIT_AUDIO) == 0) {
SDL_AudioSpec want;
SDL_AudioSpec have;
SDL_zero(want);
want.freq = 44100;
want.format = AUDIO_S16SYS;
want.channels = 2;
want.samples = 1024;
sdl_audio_device = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0);
if (sdl_audio_device) {
SDL_PauseAudioDevice(sdl_audio_device, 0);
}
}
#endif
sdl_window = SDL_CreateWindow(
window_title.c_str(),
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
640, 480,
SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE
);
if (!sdl_window) {
std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError());
return 1;
}
/* Force OpenGL renderer.
*
* History (TombaRecomp/ISSUES.md #6):
* 1. Originally SDL_RENDERER_ACCELERATED with fallback to software.
* Froze "Not Responding" after extended uptime — was thought to
* be GPU-driver-side hangs.
* 2. Switched to SDL_RENDERER_SOFTWARE only. Still froze. Software
* renderer goes through Windows GDI; the GDI path hangs the SDL
* main thread under heavy emulation load.
* 3. Bisection: NO_AUDIO+NO_RENDER ran indefinitely (~7+ min, 40k+
* frames) but the game never progressed past BIOS boot because
* it depends on the renderer being present. NO_AUDIO alone with
* software renderer froze at frame 3084 — same as full debug
* build. So audio is innocent; software renderer (GDI path) is
* the culprit.
* 4. SDL_HINT_RENDER_DRIVER=opengl + SDL_RENDERER_ACCELERATED.
* Ran indefinitely past every prior freeze point. OpenGL driver
* uses a different presentation path that doesn't hit the GDI
* hang. This is now the default.
*
* Note: the freeze became prevalent only after the FMV-speed fix
* (commit b486c13) raised cycle throughput. Before that, the slower
* MDEC/DMA workload was below whatever GDI threshold trips the bug. */
/* The OpenGL force above is a Windows-only workaround for the GDI
* presentation hang; macOS/Linux have no GDI path, so let SDL choose its
* native backend (Metal on Apple Silicon). PRESENTVSYNC removes tearing;
* fall back progressively if a driver can't provide vsync/accel. */
#ifdef _WIN32
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
#endif
sdl_renderer = SDL_CreateRenderer(sdl_window, -1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
if (!sdl_renderer)
sdl_renderer = SDL_CreateRenderer(sdl_window, -1, SDL_RENDERER_ACCELERATED);
if (!sdl_renderer) {
std::fprintf(stderr, "SDL_CreateRenderer failed: %s\n", SDL_GetError());
return 1;
}
/* Present in a fixed 640x480 logical space; SDL scales (and, in
* fullscreen, letterboxes) it to the real output. Identity in the
* default 640x480 window, so windowed rendering is unchanged. */
SDL_RenderSetLogicalSize(sdl_renderer, 640, 480);
sdl_texture = SDL_CreateTexture(
sdl_renderer,
SDL_PIXELFORMAT_ARGB8888,
SDL_TEXTUREACCESS_STREAMING,
640, 512
);
if (!sdl_texture) {
std::fprintf(stderr, "SDL_CreateTexture failed: %s\n", SDL_GetError());
return 1;
}
SDL_SetTextureScaleMode(sdl_texture, SDL_ScaleModeNearest);
/* Register vblank presentation callback. */
gpu_set_vblank_callback(sdl_vblank_present);
/* Initialize CPU state. */
CPUState cpu;
std::memset(&cpu, 0, sizeof(cpu));
/* Wire memory function pointers. */
cpu.read_word = psx_read_word;
cpu.write_word = psx_write_word;
cpu.read_half = psx_read_half;
cpu.write_half = psx_write_half;
cpu.read_byte = psx_read_byte;
cpu.write_byte = psx_write_byte;
/* Fast boot: try to restore a previously-captured BIOS handoff snapshot.
* First launch runs BIOS normally (logos visible) and captures state at
* game handoff. Every subsequent launch restores and skips BIOS entirely. */
bool fast_boot_restored = false;
if (fast_boot && game_entry_pc != 0) {
uint32_t bios_cksum = memory_get_bios_checksum();
char snap_name[64];
std::snprintf(snap_name, sizeof(snap_name),
"fast_boot_%08X_%08X.bin", bios_cksum, game_entry_pc);
std::filesystem::path snap_path =
resolved_bios.parent_path() / snap_name;
std::string snap_str = snap_path.string();
if (boot_state_load(snap_str.c_str(), bios_cksum, game_entry_pc, &cpu)) {
fast_boot_restored = true;
} else {
/* First run: show BIOS logos normally, capture state silently at
* game handoff. Subsequent launches use the snapshot. */
boot_state_set_capture(snap_str.c_str(), bios_cksum, game_entry_pc);
}
}
if (!fast_boot_restored) {
/* R3000A reset state. */
cpu.pc = 0xBFC00000u;
cpu.cop0[12] = 0x00400000u; /* SR: BEV=1 (boot exception vectors) */
}
/* Let memory subsystem see SR for cache-isolation checks. */
memory_set_sr_ptr(&cpu.cop0[12]);
/* Wire debug server to CPU state for register queries. */
debug_server_set_cpu(&cpu);
/* Execute. */
std::fprintf(stdout, "psxrecomp runtime: executing from PC=0x%08X\n", cpu.pc);
#if defined(PSX_ORACLE_BUILD)
std::fprintf(stdout, "psxrecomp ORACLE: interpreter mode (port %d)\n", DEFAULT_DEBUG_PORT);
interp_init(&cpu);
interp_trace_enable(1);
/* Breakpoints focused on VSync diagnostics.
* Only break on the VSync incrementer — count how many times it's hit. */
interp_break_add(0x8005A5BCu); /* VSync counter incrementer — KSEG0 */
std::fprintf(stderr, "ORACLE: running with BP at 0x8005A5BC (VSync incrementer)...\n");
std::fflush(stderr);
uint64_t total_executed = 0;
const uint64_t max_total = 100000000ULL; /* 100M instructions */
uint32_t vsync_hits = 0;
for (;;) {
uint32_t ran = interp_step(&cpu, 1000000);
total_executed += ran;
if (interp_hit_breakpoint()) {
vsync_hits++;
if (vsync_hits <= 3 || (vsync_hits % 100 == 0)) {
std::fprintf(stderr, "ORACLE: VSync hit #%u at %llu instructions, ra=0x%08X, gte_exec=%llu\n",
vsync_hits, (unsigned long long)total_executed, cpu.gpr[31],
(unsigned long long)gte_get_exec_count());
/* Dump last 20 trace entries for first 3 hits. */
if (vsync_hits <= 3) {
uint64_t tseq = interp_trace_count();
uint32_t tavail = (tseq < 1048576ULL) ? (uint32_t)tseq : 1048576u;
uint32_t tstart = (tavail > 20) ? tavail - 20 : 0;
for (uint32_t ti = tstart; ti < tavail; ti++) {
const InterpTraceEntry* e = interp_trace_get(ti);
if (e) std::fprintf(stderr, " [%llu] PC=0x%08X insn=0x%08X ra=0x%08X v0=0x%08X\n",
(unsigned long long)e->seq, e->pc, e->insn,
e->gpr[31], e->gpr[2]);
}
}
std::fflush(stderr);
}
/* Step past the breakpoint: temporarily remove, step 1, re-add. */
uint32_t bp_pc = cpu.pc;
interp_break_remove(bp_pc);
interp_step(&cpu, 1);
total_executed++;
interp_break_add(bp_pc);
}
if (ran == 0) {
std::fprintf(stderr, "ORACLE: halted at PC=0x%08X after %llu total instructions\n",
cpu.pc, (unsigned long long)total_executed);
break;
}
if (total_executed >= max_total) {
std::fprintf(stderr, "ORACLE: reached %llu instructions, stopping. PC=0x%08X\n",
(unsigned long long)total_executed, cpu.pc);
/* Dump last 30 trace entries (ring-relative). */
uint64_t tseq2 = interp_trace_count();
uint32_t tavail2 = (tseq2 < 1048576ULL) ? (uint32_t)tseq2 : 1048576u;
uint32_t tstart2 = (tavail2 > 30) ? tavail2 - 30 : 0;
for (uint32_t ti = tstart2; ti < tavail2; ti++) {
const InterpTraceEntry* e = interp_trace_get(ti);
if (e) std::fprintf(stderr, " [%llu] PC=0x%08X insn=0x%08X ra=0x%08X\n",
(unsigned long long)e->seq, e->pc, e->insn, e->gpr[31]);
}
std::fflush(stderr);
break;
}
/* Poll debug server. */
debug_server_poll();
}
/* Read VSync counter from RAM. */
uint32_t vsync_counter = cpu.read_word(0x80079D9Cu);
uint32_t init_flag_48 = cpu.read_word(0x80079D48u);
uint32_t init_flag_4C = cpu.read_word(0x80079D4Cu);
std::fprintf(stderr, "ORACLE: VSync counter = 0x%08X (%u), init_flag@48 = 0x%08X, init_flag@4C = 0x%08X\n",
vsync_counter, vsync_counter, init_flag_48, init_flag_4C);
std::fprintf(stderr, "ORACLE: VSync incrementer hit count = %u\n", vsync_hits);
std::fflush(stderr);
#else
psx_dispatch(&cpu, cpu.pc);
#endif
/* If we reach here, all execution completed without MMIO abort. */
std::fprintf(stdout, "psxrecomp runtime: execution completed, PC=0x%08X\n", cpu.pc);
shutdown_runtime();
SDL_DestroyTexture(sdl_texture);
SDL_DestroyRenderer(sdl_renderer);
SDL_DestroyWindow(sdl_window);
SDL_Quit();
return 0;
}