Files
psxrecomp/runtime/src/main.cpp
T
Matthew Stanley be76e4d4b5 widescreen: [video] aspect_ratio — GTE X-squash + stretched present
Adds the DuckStation-style widescreen hack as a config option,
implemented in the runtime (no regen needed):

- gte.cpp: gte_set_display_aspect(num, den). RTPS/RTPT scale the
  IR1*h/sz screen-X term by (4*den)/(3*num) around the game's own
  OFX projection centre (identity at 4:3). Games cull against the
  SXY values they read back from us, so post-projection screen-
  bounds culls stay aligned with the visible frame.
- present: GL letterbox_rect generalised to the configured aspect
  (gl_renderer_set_display_aspect); SDL_Renderer path gets an
  aspect-derived logical size + dst rect. Window opens at the
  configured aspect (clamp_window_4x3 -> clamp_window_aspect).
- config: [video] aspect_ratio = W:H (4:3 default, 16:9, up
  to 32:9) in game.toml, settings.toml (load+save), launcher seed.

World geometry keeps correct proportions on the stretched present;
screen-space 2D (HUD, FMV, sprite quad widths) stretches — the
known limitation of the squash tier. EXPERIMENTAL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 12:52:14 -07:00

2035 lines
87 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 "gpu_sw_renderer.h"
#include "gpu_render.h"
#include "gpu_gl_renderer.h"
#include "frame_pacing.h"
#include "sio.h"
#include "spu.h"
#include "spu_shadow.h"
#include "memcard.h"
#include "debug_server.h"
#include "crash_trace.h"
#include "freeze_heartbeat.h"
#include "config_loader.h"
#include "crc32.h"
#include "disc_identity.h"
#if defined(PSX_LAUNCHER)
#include "launcher.h"
#endif
#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;
/* Per-player input device routing (PSX ports 1 & 2). Seeded from the
* [controller] settings the launcher writes; the runtime opens the matching
* SDL controller (or uses the keyboard) and feeds each PSX pad slot. */
struct PlayerInput {
int kind = 0; /* 0=none, 1=keyboard, 2=controller */
char guid[40] = {0}; /* SDL joystick GUID string when kind==controller */
bool analog = false; /* DualShock (analog) vs digital pad */
SDL_GameController* handle = nullptr;
SDL_JoystickID instance = -1;
};
static PlayerInput g_players[2];
/* ARGB8888 staging buffer. Sized for the active internal resolution:
* 640*scale x 512*scale. Allocated once the supersampling scale is known
* (sized for the native 640x512 when supersampling is off). */
static uint32_t* sdl_pixel_buf = nullptr;
static int s_fast_boot_active = 0; /* cleared when game entry PC fires */
/* [video] options, resolved from the game config (defaults: native + AA). */
static int g_video_scale = 1; /* internal-resolution SSAA factor */
static bool g_video_aa = true; /* linear present filtering */
static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */
static int g_video_renderer = 0; /* 0=software, 1=opengl (requested) */
static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */
static int g_video_win_w = 1280; /* window width (height follows aspect) */
static bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */
static int g_auto_skip_fmv = 0; /* fast-forward FMVs invisibly to EOF */
/* FMV auto-skip detection hooks (cdrom.c / mdec.c). */
extern "C" int cdrom_xa_stream_active(void);
extern "C" uint32_t mdec_get_decode_count(void);
/* Display aspect W:H (default 4:3 = native). Wider aspects enable the
* widescreen hack: GTE X-squash + stretched present (see [video] aspect_ratio
* in config_loader.h). */
static int g_video_aspect_num = 4;
static int g_video_aspect_den = 3;
/* Logical present width for the SDL_Renderer (software) path; 640*scale at
* 4:3, wider for wide aspects. Height is always 480*scale. Set at window
* creation alongside SDL_RenderSetLogicalSize. */
static int g_logical_w = 640;
/* Clamp a requested window width to the primary display's usable area so an
* oversized choice (e.g. 1920 on a 1080p panel) still fits on screen. Keeps
* the given aspect: height = width*den/num. */
static void clamp_window_aspect(int* w, int* h, int num, int den) {
int width = *w;
if (width < 640) width = 640;
SDL_Rect bounds;
if (SDL_GetDisplayUsableBounds(0, &bounds) == 0 && bounds.w > 0 && bounds.h > 0) {
if (width > bounds.w) width = bounds.w;
if (width * den / num > bounds.h) width = bounds.h * num / den;
}
*w = width;
*h = width * den / num;
}
static bool g_gl_active = false; /* GL context live -> GL present path */
/* Present straight from the FBO (fast, no readback). Set PSX_GL_FORCE_CPU_PRESENT=1
* to force the software readout path instead — a diagnostic/fallback that also
* keeps CPU VRAM current every frame (so screenshots reflect the screen). */
static int g_gl_fbo_present = 1;
/* 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 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;
}
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) {
// Delegate to the shared disc-identity module so the launch-time check and
// the launcher badge can never drift apart. No CRC here — the launch check
// only cares about openability / header / serial; the launcher does the
// (slower) CRC pass when an expected CRC is configured.
const PSXRecompV4::DiscIdentity id =
PSXRecompV4::identify_disc(selected_path, game_id, /*expected_crc*/0,
/*has_expected_crc*/false, /*compute_crc*/false);
DiscValidation v;
v.opened = id.opened;
v.has_header = id.has_header;
v.id_matches = game_id.empty() ? true : id.serial_matches;
v.detail = id.detail;
if (id.opened && id.has_header && !v.id_matches && v.detail.empty()) {
v.detail = "The disc header is readable, but it does not contain the expected game ID " +
uppercase_ascii(game_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();
}
/* Linear gain ramp g0 -> g1 across a block of interleaved stereo frames. */
static void sdl_audio_gain_ramp(int16_t* buf, int frames, float g0, float g1) {
if (frames <= 0) return;
const float step = (g1 - g0) / (float)frames;
float g = g0;
for (int f = 0; f < frames; f++, g += step) {
buf[f * 2 + 0] = (int16_t)((float)buf[f * 2 + 0] * g);
buf[f * 2 + 1] = (int16_t)((float)buf[f * 2 + 1] * g);
}
}
/* Fade-in state: samples of rising ramp still to apply after an unmute.
* Consumed by sdl_audio_pump across however many pump calls it spans.
* MUST fit sdl_audio_buf (2048 frames): the fade-out tail renders this many
* frames in one spu_render call. 1764 frames = 40 ms. */
static const int sdl_audio_fade_samples = 44100 * 40 / 1000; /* 40 ms */
static int sdl_audio_fadein_left = 0;
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);
if (sdl_audio_fadein_left > 0) {
const float g0 = 1.0f - (float)sdl_audio_fadein_left
/ (float)sdl_audio_fade_samples;
int ramp = sdl_audio_fadein_left < frames ? sdl_audio_fadein_left : frames;
const float g1 = 1.0f - (float)(sdl_audio_fadein_left - ramp)
/ (float)sdl_audio_fade_samples;
sdl_audio_gain_ramp(sdl_audio_buf, ramp, g0, g1);
sdl_audio_fadein_left -= ramp;
}
SDL_QueueAudio(sdl_audio_device, sdl_audio_buf,
(uint32_t)frames * bytes_per_frame);
}
/* Audio gating across turbo-loads transitions.
*
* The mute model stays: during turbo the guest runs at host speed, so
* rendered SPU audio is time-compressed garble — we stop pumping, the queue
* drains, voice positions freeze, and music resumes in place afterward.
* What changes is the EDGES:
* - entering turbo: render one short tail of the current voice state,
* ramp it to silence, and queue it — the drain ends in a fade instead
* of a hard cut;
* - leaving turbo: hold the mute for a short hangover first (loads often
* re-trigger within a few frames; without the debounce the mute would
* flicker audibly), then resume pumping with a rising ramp applied
* across the first ~50 ms of samples (sdl_audio_pump above). */
static void sdl_audio_update(int turbo_active) {
if (!sdl_audio_device) return;
const int HANGOVER_FRAMES = 8; /* ~133 ms at 60 fps */
static int muted = 0;
static int hangover = 0;
if (turbo_active) {
if (!muted) {
int tail = sdl_audio_fade_samples;
const int buf_cap = (int)(sizeof(sdl_audio_buf) / (2 * sizeof(int16_t)));
if (tail > buf_cap) tail = buf_cap;
/* An unmute ramp may still be in progress; start the down-ramp
* from its current gain so the edge stays continuous. */
const float g0 = 1.0f - (float)sdl_audio_fadein_left
/ (float)sdl_audio_fade_samples;
sdl_audio_fadein_left = 0;
spu_render(sdl_audio_buf, tail);
sdl_audio_gain_ramp(sdl_audio_buf, tail, g0, 0.0f);
SDL_QueueAudio(sdl_audio_device, sdl_audio_buf,
(uint32_t)tail * sizeof(int16_t) * 2u);
muted = 1;
}
hangover = HANGOVER_FRAMES;
return;
}
if (muted) {
if (hangover > 0) { hangover--; return; }
muted = 0;
sdl_audio_fadein_left = sdl_audio_fade_samples;
}
sdl_audio_pump();
}
/* 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_player(PlayerInput& p) {
if (p.handle) {
SDL_GameControllerClose(p.handle);
p.handle = nullptr;
p.instance = -1;
}
}
static void close_controller(void) {
close_player(g_players[0]);
close_player(g_players[1]);
}
/* Open the SDL controller whose GUID matches p.guid. If no exact GUID match
* exists (e.g. a different physical unit of the same model, or Steam's virtual
* pad at an unpredictable slot), fall back to the first controller not already
* claimed by the other player. */
static void open_player(PlayerInput& p, const PlayerInput& other) {
if (p.kind != 2 || p.handle) return;
int chosen = -1, fallback = -1;
const int joysticks = SDL_NumJoysticks();
for (int i = 0; i < joysticks; i++) {
if (!SDL_IsGameController(i)) continue;
SDL_JoystickGUID g = SDL_JoystickGetDeviceGUID(i);
char buf[40] = {0};
SDL_JoystickGetGUIDString(g, buf, sizeof(buf));
/* Skip a device already opened by the other player. */
SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i);
if (other.handle && other.instance == inst) continue;
if (p.guid[0] && std::strcmp(buf, p.guid) == 0) { chosen = i; break; }
if (fallback < 0) fallback = i;
}
if (chosen < 0) chosen = fallback;
if (chosen < 0) return;
p.handle = SDL_GameControllerOpen(chosen);
if (p.handle) {
SDL_Joystick* joy = SDL_GameControllerGetJoystick(p.handle);
p.instance = joy ? SDL_JoystickInstanceID(joy) : -1;
const char* name = SDL_GameControllerName(p.handle);
std::fprintf(stdout, "psxrecomp runtime: opened controller for slot: %s\n",
name ? name : "(unnamed)");
}
}
/* Open/close SDL handles so they match g_players, and (re)assert each slot's
* PSX connection + pad type. Safe to call repeatedly (hotplug, boot). */
static void refresh_player_devices(void) {
for (int s = 0; s < 2; s++) {
PlayerInput& p = g_players[s];
if (p.kind != 2) close_player(p); /* keyboard/none: no handle */
else open_player(p, g_players[s ^ 1]);
sio_set_pad_connected(s, p.kind != 0 ? 1 : 0);
sio_set_pad_analog(s, p.analog ? 1 : 0, 0x80, 0x80, 0x80, 0x80);
}
}
/* Parse a [controller] device string into a player slot:
* "none" -> no pad; "keyboard" -> keyboard map; otherwise an SDL GUID. */
static void set_player_device(PlayerInput& p, const std::string& dev, bool analog) {
p.analog = analog;
p.guid[0] = '\0';
std::string d = lower_copy(trim_copy(dev));
if (d.empty() || d == "none") { p.kind = 0; }
else if (d == "keyboard") { p.kind = 1; }
else {
p.kind = 2;
std::snprintf(p.guid, sizeof(p.guid), "%s", trim_copy(dev).c_str());
}
}
static bool controller_source_pressed_h(SDL_GameController* h, const ControllerSource& source) {
if (!h) return false;
switch (source.kind) {
case ControllerSource::Kind::Button:
return SDL_GameControllerGetButton(
h, (SDL_GameControllerButton)source.id) != 0;
case ControllerSource::Kind::AxisPositive:
return SDL_GameControllerGetAxis(
h, (SDL_GameControllerAxis)source.id) > controller_deadzone;
case ControllerSource::Kind::AxisNegative:
return SDL_GameControllerGetAxis(
h, (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 controller_pad_buttons(SDL_GameController* h) {
uint16_t buttons = 0xFFFF; /* all released */
if (!h) return buttons;
for (const auto& entry : controller_map) {
for (const auto& source : entry.sources) {
if (controller_source_pressed_h(h, source)) {
buttons &= (uint16_t)~entry.bit;
break;
}
}
}
return buttons;
}
/* Map an SDL axis (-32768..32767) to a PSX analog byte (0..255, 0x80 centred). */
static uint8_t axis_to_pad_byte(int16_t v) {
int b = ((int)v + 32768) >> 8; /* 0..255 */
if (b < 0) b = 0; else if (b > 255) b = 255;
return (uint8_t)b;
}
/* Buttons for a player's selected device (0xFFFF = none pressed). */
static uint16_t pad_buttons_for(const PlayerInput& p) {
if (p.kind == 1) return pad_from_keyboard();
if (p.kind == 2) return controller_pad_buttons(p.handle);
return 0xFFFF;
}
/* Analog stick bytes (lx,ly,rx,ry) for a player; centred if no live source. */
static void pad_sticks_for(const PlayerInput& p, uint8_t out[4]) {
out[0] = out[1] = out[2] = out[3] = 0x80;
if (p.kind == 2 && p.handle) {
out[0] = axis_to_pad_byte(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTX));
out[1] = axis_to_pad_byte(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY));
out[2] = axis_to_pad_byte(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTX));
out[3] = axis_to_pad_byte(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTY));
}
}
/* 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) {
psx_crash_trace_set_exit_origin("sdl_window_close");
shutdown_runtime();
std::exit(0);
} else if (ev.type == SDL_CONTROLLERDEVICEADDED) {
refresh_player_devices();
} else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) {
if (ev.cdevice.which == g_players[0].instance ||
ev.cdevice.which == g_players[1].instance) {
close_controller();
refresh_player_devices();
}
} 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 each player's device and feed the matching SIO pad slot.
* Debug server input override (when active) drives port 1 only. */
if (override >= 0) {
sio_set_pad_state_slot(0, (uint16_t)override);
} else {
for (int s = 0; s < 2; s++) {
const PlayerInput& p = g_players[s];
if (p.kind == 0) continue; /* no device in this port */
sio_set_pad_state_slot(s, pad_buttons_for(p));
if (p.analog) {
uint8_t st[4];
pad_sticks_for(p, st);
sio_set_pad_analog(s, 1, st[0], st[1], st[2], st[3]);
}
}
}
/* Turbo-active test, shared by the audio gate here and the pacing/
* present gate below. sdl_audio_update owns the mute + fade-in/out +
* debounce across turbo transitions (see its comment). */
int turbo_loads_active = 0;
if (g_turbo_loads_enabled) {
extern int fntrace_is_game_started(void);
if (fntrace_is_game_started() && cdrom_load_in_progress())
turbo_loads_active = 1;
}
/* FMV auto-skip ([video] auto_skip_fmv). A streaming FMV is XA audio + MDEC
* video running together. Rather than fast-forward the stream (flooding XA
* sectors desyncs and hangs the player; even safe uncapped playback is slow
* and choppy), we trigger the GAME'S OWN skip: hold START while the movie is
* detected, and the FMV player tears the movie down cleanly and jumps to the
* next screen — instant, desync-free, with all state correct (verified: a
* START press during Tomba's opening movie lands exactly on the next scene).
* Detect "MDEC produced a frame since the last vblank AND XA is streaming";
* a short hold rides out brief inter-frame gaps. Injection (and the present/
* pacing safety-net below) stop the instant XA ends, so START doesn't bleed
* into the next screen. */
int fmv_skip_active = 0;
if (g_auto_skip_fmv) {
static uint32_t s_last_mdec = 0;
static int s_fmv_hold = 0;
uint32_t mc = mdec_get_decode_count();
int mdec_decoding = (mc != s_last_mdec);
s_last_mdec = mc;
int xa = cdrom_xa_stream_active();
if (mdec_decoding && xa) s_fmv_hold = 4;
else if (s_fmv_hold > 0) s_fmv_hold--;
fmv_skip_active = (s_fmv_hold > 0) && xa;
if (fmv_skip_active) {
/* Inject START (PSX pad word is active-low; START is bit 3) into
* port 1 so the game's FMV player aborts the movie itself. */
sio_set_pad_state_slot(0, (uint16_t)~(1u << 3));
}
}
#ifndef PSX_SDL_NO_AUDIO
sdl_audio_update(turbo_loads_active || fmv_skip_active);
#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 (turbo_loads_active) {
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;
}
/* FMV auto-skip: run uncapped (no wall-clock pacing) and suppress nearly all
* presents so the fast-forwarded movie is invisible. Present 1-in-30 only so
* the window keeps pumping and never looks hung during the brief skip. */
if (fmv_skip_active) {
static int fs_skip = 0;
const int FMV_PRESENT_EVERY = 30;
fs_skip = (fs_skip + 1) % FMV_PRESENT_EVERY;
if (fs_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;
int active_scale = 1; /* hi-res mirror used only for 15-bit display */
{
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;
if (g_gl_active) {
gl_renderer_present_blank();
} else {
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;
/* OpenGL: 15-bit frames ALWAYS present straight from the authoritative
* VRAM FBO — one deterministic path (the old per-frame FBO-vs-CPU
* alternation caused the menu seam/jitter). 24-bit (FMV) frames and
* the PSX_GL_FORCE_CPU_PRESENT diagnostic sync the FBO down and use
* the CPU readout below. */
#ifndef PSX_SDL_NO_RENDER
if (g_gl_active && g_gl_fbo_present && !di.depth24) {
gl_renderer_present_vram((int)di.display_x, (int)di.display_y,
(int)w, (int)h, g_video_aa ? 1 : 0);
return;
}
if (g_gl_active) gl_renderer_sync_cpu();
#endif
/* The hi-res mirror is a 15-bit copy of VRAM; 24-bit display (FMV)
* reads packed bytes the mirror can't represent, so fall back to the
* native path for those frames (the present filter still upscales). */
active_scale = (g_video_scale > 1 && !di.depth24) ? g_video_scale : 1;
if (active_scale > 1) {
int sw = (int)w * active_scale;
gr_render_display_hires(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)),
(int)di.display_x, (int)di.display_y,
(int)w, (int)h);
} else {
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 sized
* 640x512 (times the supersampling factor), 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
int src_w = (int)w * active_scale;
int src_h = (int)h * active_scale;
if (g_gl_active) {
/* OpenGL present: upload the active display rect and draw a full-screen
* quad. SDL_GL_SwapWindow handles vsync; the wall-clock pacer above
* still owns timing. */
gl_renderer_present(sdl_pixel_buf, src_w, src_h, g_video_aa ? 1 : 0);
} else {
SDL_Rect src = { 0, 0, src_w, src_h };
SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf,
(int)(src_w * sizeof(uint32_t)));
SDL_Rect dst = { 0, 0, g_logical_w, 480 * g_video_scale };
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;
bool bios_from_cli = false; /* CLI --bios/positional wins over settings.toml */
/* 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. */
bool force_launcher = false;
bool force_no_launcher = false;
/* 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
* --launcher force the GUI launcher (overrides skip_launcher)
* --no-launcher skip the GUI launcher (boot straight in)
* <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];
bios_from_cli = 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) {
disc_override_path = argv[++i];
} else if (std::strcmp(argv[i], "--launcher") == 0) {
force_launcher = true;
} else if (std::strcmp(argv[i], "--no-launcher") == 0) {
force_no_launcher = true;
} else if (argv[i][0] != '-') {
bios_path = argv[i];
bios_from_cli = true;
}
}
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 memcard1_path; /* explicit slot-1 .mcd (empty => dir/card1.mcd) */
std::filesystem::path memcard2_path; /* explicit slot-2 .mcd (empty => dir/card2.mcd) */
bool memcard1_enabled = true;
bool memcard2_enabled = true;
/* [controller] device routing (defaults: P1 keyboard/digital, P2 none). */
std::string p1_device = "keyboard";
std::string p2_device = "none";
bool p1_analog = false;
bool p2_analog = false;
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;
bool game_has_disc_crc = false;
uint32_t game_disc_crc = 0;
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;
game_has_disc_crc = gc.has_disc_crc;
game_disc_crc = gc.disc_crc;
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");
}
g_video_scale = gc.runtime.video_supersampling;
g_video_aa = gc.runtime.video_antialiasing;
g_video_texfilter = gc.runtime.video_texture_filter;
g_video_renderer = gc.runtime.video_renderer;
g_video_screen = gc.runtime.video_screen_kind;
g_video_aspect_num = gc.runtime.video_aspect_num;
g_video_aspect_den = gc.runtime.video_aspect_den;
g_audio_spu_hq = gc.runtime.audio_spu_hq;
g_auto_skip_fmv = gc.runtime.video_auto_skip_fmv ? 1 : 0;
{ const char *e = std::getenv("PSX_GL_FORCE_CPU_PRESENT");
if (e && e[0] && e[0] != '0') g_gl_fbo_present = 0; }
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;
/* Layer the launcher-written settings.toml (next to the exe) over the
* bundled game.toml. Any field present there overrides the config value;
* the command line (--bios/--disc) still wins over the file. Absent =>
* fall through to game.toml. The file is the launcher's persistence. */
bool skip_launcher_setting = false; /* [launcher] skip_launcher from settings.toml */
std::string settings_bios_storage; /* must outlive resolve_bios_for_runtime */
{
std::filesystem::path settings_path =
exe_dir_from_argv(argv[0]) / "settings.toml";
const PSXRecompV4::UserSettings us =
PSXRecompV4::load_user_settings(settings_path);
if (us.has_skip_launcher) skip_launcher_setting = us.skip_launcher;
if (us.has_renderer) g_video_renderer = us.renderer;
if (us.has_supersampling) g_video_scale = us.supersampling;
if (us.has_window_width) g_video_win_w = us.window_width;
if (us.has_antialiasing) g_video_aa = us.antialiasing;
if (us.has_texture_filter) g_video_texfilter = us.texture_filter;
if (us.has_screen_kind) g_video_screen = us.screen_kind;
if (us.has_auto_skip_fmv) g_auto_skip_fmv = us.auto_skip_fmv ? 1 : 0;
if (us.has_aspect_ratio) {
g_video_aspect_num = us.aspect_num;
g_video_aspect_den = us.aspect_den;
}
if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq;
if (us.has_bios_path && !bios_from_cli) {
settings_bios_storage = us.bios_path.string();
bios_path = settings_bios_storage.c_str();
}
if (us.has_disc_path && !disc_override_path) resolved_disc = us.disc_path;
if (us.has_memcard_dir) memcard_dir = us.memcard_dir;
if (us.has_memcard1_path) memcard1_path = us.memcard1_path;
if (us.has_memcard2_path) memcard2_path = us.memcard2_path;
if (us.has_memcard1_enabled) memcard1_enabled = us.memcard1_enabled;
if (us.has_memcard2_enabled) memcard2_enabled = us.memcard2_enabled;
if (us.has_p1_device) p1_device = us.p1_device;
if (us.has_p2_device) p2_device = us.p2_device;
if (us.has_p1_analog) p1_analog = us.p1_analog;
if (us.has_p2_analog) p2_analog = us.p2_analog;
}
/* Resolve the effective memory-card directory now (before the launcher) so
* the launcher can introspect the real card files. The same default is used
* by the runtime below. */
if (memcard_dir.empty()) memcard_dir = default_memcard_dir(argv[0]);
#if defined(PSX_LAUNCHER)
/* Integrated launcher: shown in its own GL window before the emulator
* boots. Seeded with the effective settings (game.toml ∪ settings.toml);
* on LAUNCH the user's choices are persisted to settings.toml and applied.
* The launcher window/context is fully torn down before the emulator's own
* window is created, so the emulator boot path below is untouched.
*
* Skip the GUI (boot straight in) when ANY of: PSX_NO_LAUNCHER=1 env,
* --no-launcher, or the persisted [launcher] skip_launcher setting — unless
* --launcher forces it back on (mirrors snesrecomp's SkipLauncher / --launcher).
* This removes the dismiss-the-launcher round-trip for scripted/debug runs. */
const bool want_launcher =
force_launcher ||
(!std::getenv("PSX_NO_LAUNCHER") && !force_no_launcher && !skip_launcher_setting);
if (want_launcher) {
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) {
PSXRecompV4::UserSettings seed;
seed.renderer = g_video_renderer; seed.has_renderer = true;
seed.supersampling = g_video_scale; seed.has_supersampling = true;
seed.antialiasing = g_video_aa; seed.has_antialiasing = true;
seed.texture_filter = g_video_texfilter; seed.has_texture_filter = true;
seed.screen_kind = g_video_screen; seed.has_screen_kind = true;
seed.auto_skip_fmv = (g_auto_skip_fmv != 0); seed.has_auto_skip_fmv = true;
seed.aspect_num = g_video_aspect_num;
seed.aspect_den = g_video_aspect_den; seed.has_aspect_ratio = true;
seed.spu_hq = g_audio_spu_hq; seed.has_spu_hq = true;
seed.skip_launcher = skip_launcher_setting; seed.has_skip_launcher = true;
if (bios_path && bios_path[0]) { seed.bios_path = bios_path; seed.has_bios_path = true; }
if (!resolved_disc.empty()) { seed.disc_path = resolved_disc; seed.has_disc_path = true; }
seed.memcard_dir = memcard_dir; seed.has_memcard_dir = true;
seed.memcard1_enabled = memcard1_enabled; seed.has_memcard1_enabled = true;
seed.memcard2_enabled = memcard2_enabled; seed.has_memcard2_enabled = true;
if (!memcard1_path.empty()) { seed.memcard1_path = memcard1_path; seed.has_memcard1_path = true; }
if (!memcard2_path.empty()) { seed.memcard2_path = memcard2_path; seed.has_memcard2_path = true; }
seed.p1_device = p1_device; seed.has_p1_device = true;
seed.p2_device = p2_device; seed.has_p2_device = true;
seed.p1_analog = p1_analog; seed.has_p1_analog = true;
seed.p2_analog = p2_analog; seed.has_p2_analog = true;
seed.window_width = g_video_win_w; seed.has_window_width = true;
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
/* Launcher opens at the same 4:3 size the game will, so there's no
* jarring resize on LAUNCH. The dense dashboard needs a usable
* minimum, so the launcher floor is 1280 wide even if the game
* window is set smaller. */
int lwin_w = g_video_win_w < 1280 ? 1280 : g_video_win_w;
int lwin_h = 0;
clamp_window_aspect(&lwin_w, &lwin_h, 4, 3);
std::string lwin_title = (game_name.empty() ? std::string("PSX") : game_name)
+ " \xE2\x80\x94 Launcher";
SDL_Window* lwin = SDL_CreateWindow(
lwin_title.c_str(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
lwin_w, lwin_h, SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE);
psx_launcher::Result lr = psx_launcher::Result::Unavailable;
if (lwin) {
SDL_GLContext lctx = SDL_GL_CreateContext(lwin);
if (lctx) {
SDL_GL_MakeCurrent(lwin, lctx);
SDL_GL_SetSwapInterval(1);
std::string assets = exe_dir_from_argv(argv[0]).string();
psx_launcher::GameInfo ginfo;
ginfo.name = game_name.empty() ? nullptr : game_name.c_str();
ginfo.expected_serial = game_id.empty() ? nullptr : game_id.c_str();
ginfo.expected_crc = game_disc_crc;
ginfo.has_expected_crc = game_has_disc_crc;
lr = psx_launcher::run(lwin, lctx, seed, ginfo, assets.c_str());
SDL_GL_DeleteContext(lctx);
}
SDL_DestroyWindow(lwin);
}
/* Reset GL attributes so the emulator window starts from defaults. */
SDL_GL_ResetAttributes();
if (lr == psx_launcher::Result::Quit) {
std::fprintf(stdout, "psxrecomp: launcher closed; exiting.\n");
return 0;
}
if (lr == psx_launcher::Result::Launch) {
g_video_renderer = seed.renderer;
g_video_scale = seed.supersampling;
g_video_aa = seed.antialiasing;
g_video_texfilter = seed.texture_filter;
g_video_screen = seed.screen_kind;
g_auto_skip_fmv = seed.auto_skip_fmv ? 1 : 0;
g_video_aspect_num = seed.aspect_num;
g_video_aspect_den = seed.aspect_den;
g_audio_spu_hq = seed.spu_hq;
skip_launcher_setting = seed.skip_launcher;
if (seed.has_bios_path) {
settings_bios_storage = seed.bios_path.string();
bios_path = settings_bios_storage.c_str();
}
if (seed.has_disc_path) resolved_disc = seed.disc_path;
memcard1_enabled = seed.memcard1_enabled;
memcard2_enabled = seed.memcard2_enabled;
if (seed.has_memcard1_path) memcard1_path = seed.memcard1_path;
if (seed.has_memcard2_path) memcard2_path = seed.memcard2_path;
p1_device = seed.p1_device; p2_device = seed.p2_device;
p1_analog = seed.p1_analog; p2_analog = seed.p2_analog;
g_video_win_w = seed.window_width;
/* Persist the user's choices next to the exe. */
PSXRecompV4::save_user_settings(
exe_dir_from_argv(argv[0]) / "settings.toml", seed);
}
}
}
#endif
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;
}
}
/* memcard_dir was resolved to its default before the launcher (above). */
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());
/* Select the renderer backend BEFORE gpu_init() (which runs gr_init ->
* the backend's init on the VRAM buffer). Software is the default and the
* fallback; an unavailable OpenGL backend reverts to software. */
gr_set_backend(g_video_renderer == 1 ? GR_BACKEND_OPENGL : GR_BACKEND_SOFTWARE);
std::fprintf(stdout, "psxrecomp: renderer backend requested: %s\n",
g_video_renderer == 1 ? "opengl" : "software");
gpu_init();
/* Internal-resolution supersampling (SSAA). Must follow gpu_init (which
* runs sw_renderer_init). scale==1 is a no-op; >1 allocates the hi-res
* VRAM mirror. */
if (g_video_scale < 1) g_video_scale = 1;
if (g_video_scale > SW_MAX_INTERNAL_SCALE) g_video_scale = SW_MAX_INTERNAL_SCALE;
gr_set_scale(g_video_scale);
g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */
gr_set_texture_filter(g_video_texfilter);
/* Display aspect (widescreen hack). Identity at the default 4:3. The GTE
* squashes screen-X around the game's projection centre; both present
* paths stretch the 4:3 frame to the configured aspect. */
gte_set_display_aspect(g_video_aspect_num, g_video_aspect_den);
gl_renderer_set_display_aspect(g_video_aspect_num, g_video_aspect_den);
if (g_video_aspect_num * 3 != g_video_aspect_den * 4)
std::fprintf(stdout, "psxrecomp: widescreen %d:%d (GTE X-squash + stretched present)\n",
g_video_aspect_num, g_video_aspect_den);
/* Present-time screen-colour model (verified-enhancement LUT). Default raw
* is byte-identical; PSX_SCREEN env overrides this at scanout. */
gpu_set_screen_kind(g_video_screen);
if (g_video_scale > 1 || g_video_texfilter)
std::fprintf(stdout,
"psxrecomp: supersampling %dx (antialiasing %s, texture filter %s)\n",
g_video_scale, g_video_aa ? "on" : "off",
g_video_texfilter ? "bilinear" : "nearest");
if (g_video_screen != 0)
std::fprintf(stdout, "psxrecomp: screen-colour model %s\n",
g_video_screen == 1 ? "crt" : g_video_screen == 2 ? "composite"
: "trinitron");
dma_init();
mdec_init();
timers_init();
interrupts_init();
sio_init();
/* Seed per-player device routing from the resolved [controller] config.
* SDL controller handles are opened later (after SDL_Init); here we only
* set the PSX-visible connection + pad type so the BIOS sees the right
* ports during early boot. */
set_player_device(g_players[0], p1_device, p1_analog);
set_player_device(g_players[1], p2_device, p2_analog);
for (int s = 0; s < 2; s++) {
sio_set_pad_connected(s, g_players[s].kind != 0 ? 1 : 0);
sio_set_pad_analog(s, g_players[s].analog ? 1 : 0, 0x80, 0x80, 0x80, 0x80);
}
/* SPU float-shadow gate must be set before spu_init() (which runs
* spu_shadow_reset()). Default OFF; PSX_AUDIO_SHADOW env overrides. */
spu_shadow_set_enabled(g_audio_spu_hq ? 1 : 0);
if (g_audio_spu_hq)
std::fprintf(stdout, "psxrecomp: SPU float-shadow enabled (verified-enhancement)\n");
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());
}
{
std::string mc1 = memcard1_path.string();
std::string mc2 = memcard2_path.string();
const MemcardSlotConfig slots[2] = {
{ mc1.empty() ? nullptr : mc1.c_str(), memcard1_enabled ? 1 : 0 },
{ mc2.empty() ? nullptr : mc2.c_str(), memcard2_enabled ? 1 : 0 },
};
memcard_init_slots(memcard_dir_str.c_str(), slots);
}
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 ---- */
/* Scale quality governs SDL's logical-size -> window scaling. Linear when
* antialiasing is on so the (super)sampled frame stays smooth when the
* window is resized; nearest preserves crisp pixels otherwise. */
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, g_video_aa ? "1" : "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]);
refresh_player_devices(); /* open SDL handles to match the player config */
#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
Uint32 win_flags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE;
if (g_video_renderer == 1) win_flags |= SDL_WINDOW_OPENGL;
/* Open at the user-chosen window size (default 1280 wide) instead of the
* old hardcoded 640x480, so the game doesn't boot into a tiny window. The
* height follows the configured display aspect (4:3 native, wider for the
* widescreen hack); the present path letterboxes to the same aspect, so
* the image scales to fill the larger window with no further distortion. */
int game_w = g_video_win_w, game_h = 0;
clamp_window_aspect(&game_w, &game_h, g_video_aspect_num, g_video_aspect_den);
sdl_window = SDL_CreateWindow(
window_title.c_str(),
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
game_w, game_h,
win_flags
);
if (!sdl_window) {
std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError());
return 1;
}
/* OpenGL backend: create the GL context now. On failure, relabel the
* facade back to software (rasterization already runs through software in
* this phase) and fall through to the SDL_Renderer present path below. */
if (g_video_renderer == 1) {
g_gl_active = (gl_renderer_init_context(sdl_window) != 0);
if (!g_gl_active) gr_set_backend(GR_BACKEND_SOFTWARE);
}
/* Title bar shows the clean game title (set at window creation); the active
* renderer is reported via the debug server / config, not appended here. */
/* 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. */
if (!g_gl_active) {
#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 logical space of the configured aspect (640x480 at native
* 4:3, wider at widescreen aspects; scaled by the supersampling factor so
* the full internal resolution reaches a large/fullscreen window; SDL
* still scales and letterboxes to the real output). Identity in the
* default window when supersampling is off, so native rendering is
* unchanged. */
g_logical_w = 480 * g_video_aspect_num * g_video_scale / g_video_aspect_den;
SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale);
}
/* Staging buffer + backing texture are sized for the internal resolution
* (640x512 native, times the supersampling factor). */
sdl_pixel_buf = (uint32_t*)std::malloc(
(size_t)640 * g_video_scale * 512 * g_video_scale * sizeof(uint32_t));
if (!sdl_pixel_buf) {
std::fprintf(stderr, "failed to allocate %dx staging buffer\n", g_video_scale);
return 1;
}
if (!g_gl_active) {
sdl_texture = SDL_CreateTexture(
sdl_renderer,
SDL_PIXELFORMAT_ARGB8888,
SDL_TEXTUREACCESS_STREAMING,
640 * g_video_scale, 512 * g_video_scale
);
if (!sdl_texture) {
std::fprintf(stderr, "SDL_CreateTexture failed: %s\n", SDL_GetError());
return 1;
}
SDL_SetTextureScaleMode(sdl_texture,
g_video_aa ? SDL_ScaleModeLinear : 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();
if (g_gl_active) gl_renderer_shutdown();
SDL_DestroyTexture(sdl_texture); /* NULL-safe in GL mode */
SDL_DestroyRenderer(sdl_renderer); /* NULL-safe in GL mode */
SDL_DestroyWindow(sdl_window);
SDL_Quit();
return 0;
}