Files
psxrecomp/runtime/src/main.cpp
T
2026-07-23 19:05:30 -04:00

6454 lines
306 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_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */
#include "parity_trace.h" /* general two-process control-flow parity ring */
#include "device_trace.h" /* general two-process device-event cycle ring */
#include "psx_interpreter.h"
#include "cdrom.h"
#include "fntrace.h"
#include "text_xlate.h"
#include "boot_state.h"
#include "bios_hle.h"
#include "psx_cycles.h"
#include "starvation_ring.h"
#include "load_accel.h"
#include "savestate.h"
#include "overlay_capture.h"
#include "overlay_loader.h"
#include "autocompile.h"
#include "code_provider.h"
extern "C" void psx_event_step_conservative_env_init(void);
#include "overlay_backend.h"
#include "gpu.h"
#include "present_ring.h"
#include "load_transition_ring.h"
#include "gpu_sw_renderer.h"
#include "gpu_render.h"
#include "gpu_gl_renderer.h"
#include "gpu_vk_renderer.h"
#include "frame_pacing.h"
#include "latency_ring.h"
#include "sio.h"
#include "psx_netplay.h"
#include "psx_lobby_client.h"
#include "spu.h"
#include "audio_trace.h"
#include "spu_shadow.h"
/* Shared clock-domain bridge: band-limited polyphase resampler + P-only DRC.
* The SPU renders at a fixed 44100/60 per wall-clock frame while the host
* consumes on its own crystal; with no resampling/DRC the queue slowly drifts
* to underrun (silence gaps). The bridge resamples ~1:1 with a <=+/-0.5% ratio
* trim to hold the ring near target -- no gaps. See recomp_audio_drc.h. */
#define RECOMP_AUDIO_DRC_IMPL
#include "recomp_audio_drc.h"
#include "memcard.h"
#include "debug_server.h"
#include "crash_trace.h"
#include "freeze_heartbeat.h"
#include "config_loader.h"
#include "game_options.h"
#include "crc32.h"
#include "disc_identity.h"
#include "iso_reader.h" /* text-image guard: extract the boot EXE from the disc */
#include "launcher_device.h" /* recomp-ui controller-source round-trip */
#include "psx_keybinds.h" /* configurable keyboard->DualShock keybinds (keybinds.ini) */
#if defined(RECOMP_LAUNCHER)
#include "recomp_launcher.h" /* shared recomp-ui Dear ImGui launcher */
#include "launcher_profile.h" /* per-system variant profile (theme/caps bundle) */
#endif
#include <SDL.h>
#if defined(PSX_WEB)
#include <emscripten/emscripten.h>
#endif
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cmath>
#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 <winsock2.h>
#include <ws2tcpip.h>
#include <iphlpapi.h>
#include <windows.h>
#include <commdlg.h>
#else
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.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);
/* Cross-language globals defined in C translation units. Declared extern "C" at
* file scope so MSVC gives them C linkage (matching the C definitions); without
* this MSVC name-mangles the C++ references and they fail to link. GCC/Clang do
* not mangle namespace-scope variables, so this is a no-op there. The existing
* block-scope `extern` redeclarations inside functions inherit this C linkage. */
extern "C" {
extern uint64_t psx_cycle_count;
extern uint64_t s_frame_count;
extern uint32_t g_overlay_region_floor;
extern int g_psx_cps_mode;
extern uint64_t g_slice_fired, g_slice_irq_taken, g_dirty_ram_insns_run;
extern uint64_t g_dirty_window_dispatches;
extern uint32_t g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter;
extern uint32_t g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want;
}
/* 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);
extern "C" void dirty_ram_register_text_image(uint32_t phys_lo,
const uint8_t *bytes,
uint32_t len);
/* Arm the dirty-RAM text-image guard with the boot EXE bytes. The guard is
* load-bearing: dispatch native-safety (dirty_ram_text_native_ok) and the
* fntrace alternate game-start latch both key off the registered image, so
* every install must arm it — not just repo checkouts that happen to carry a
* loose EXE copy next to game.toml. Source order:
* 1. The local EXE file from game.toml (dev checkouts; recompiler input).
* 2. The boot EXE extracted from the mounted disc image (end-user installs
* — the disc is the same bytes the BIOS loads, i.e. the true reference).
* Registration passes ownership of the malloc'd buffer to memory.c. */
static void arm_text_image_guard(const std::string &exe_path,
uint32_t load_address,
const std::string &disc_path) {
const uint32_t phys_lo = load_address & 0x1FFFFFFFu;
/* 1. Local EXE file (skip the 2048-byte PS-X EXE header). */
if (!exe_path.empty()) {
std::ifstream ef(exe_path, std::ios::binary | std::ios::ate);
if (ef) {
std::streamsize sz = ef.tellg();
if (sz > 2048) {
uint32_t img_len = (uint32_t)(sz - 2048);
uint8_t *img = (uint8_t *)std::malloc(img_len);
if (img) {
ef.seekg(2048, std::ios::beg);
if (ef.read((char *)img, img_len)) {
dirty_ram_register_text_image(phys_lo, img, img_len);
std::fprintf(stdout,
"psxrecomp: text image guard armed (0x%08X..0x%08X, local EXE)\n",
load_address, load_address + img_len);
return;
}
std::free(img);
}
}
}
}
/* 2. Extract the boot EXE from the disc image. */
if (!disc_path.empty()) {
PS1::ISOReader iso;
if (iso.Open(disc_path)) {
/* The boot filename: basename of the game.toml exe field (it names
* the same file the recompiler consumed, which came off this disc);
* fall back to the SYSTEM.CNF BOOT token for configs whose local
* name differs from the disc name. */
std::string boot_name;
if (!exe_path.empty()) {
const size_t slash = exe_path.find_last_of("/\\");
boot_name = (slash == std::string::npos)
? exe_path : exe_path.substr(slash + 1);
}
PS1::ISOFileEntry ent;
if (boot_name.empty() || !iso.FindFile(boot_name, ent)) {
/* SYSTEM.CNF: `BOOT = cdrom:\SCUS_944.23;1` */
uint8_t cnf[2048] = {0};
size_t n = iso.ReadFile("SYSTEM.CNF", cnf, sizeof(cnf) - 1);
if (n > 0) {
std::string text((const char *)cnf, n);
std::string lower = text;
for (char &c : lower) c = (char)std::tolower((unsigned char)c);
const size_t key = lower.find("cdrom:");
if (key != std::string::npos) {
size_t j = key + 6;
while (j < text.size() && (text[j] == '\\' || text[j] == '/')) j++;
std::string tok;
while (j < text.size()) {
char c = text[j];
if (c == ';' || c == '\r' || c == '\n' || c == ' ' ||
c == '\t' || c == '\0') break;
tok += c; j++;
if (tok.size() > 64) break;
}
const size_t s2 = tok.find_last_of("\\/");
if (s2 != std::string::npos) tok = tok.substr(s2 + 1);
if (!tok.empty() && iso.FindFile(tok, ent)) boot_name = tok;
}
}
}
if (!boot_name.empty() && iso.FindFile(boot_name, ent) &&
ent.size > 2048) {
uint8_t *file = (uint8_t *)std::malloc(ent.size);
if (file) {
size_t got = iso.ReadFile(boot_name, file, ent.size);
if (got > 2048) {
uint32_t img_len = (uint32_t)(got - 2048);
uint8_t *img = (uint8_t *)std::malloc(img_len);
if (img) {
memcpy(img, file + 2048, img_len);
std::free(file);
dirty_ram_register_text_image(phys_lo, img, img_len);
std::fprintf(stdout,
"psxrecomp: text image guard armed (0x%08X..0x%08X, disc %s)\n",
load_address, load_address + img_len,
boot_name.c_str());
return;
}
}
std::free(file);
}
}
}
}
std::fprintf(stdout,
"psxrecomp: WARNING: text image guard NOT armed (no local EXE, no disc "
"boot EXE) — native text dispatch will be conservative\n");
}
/* dma.c */
extern "C" void dma_init(void);
/* mdec.c */
extern "C" void mdec_init(void);
extern "C" int mdec_recently_active(uint32_t within_frames);
/* timers.c */
extern "C" void timers_init(void);
/* interrupts.c */
extern "C" void interrupts_init(void);
#ifdef PSX_COSIM
extern "C" void cosim_init(void); /* first-divergence oracle server (cosim.c) */
#endif
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);
/* Guest-side data-read wrappers: same as psx_read_* but charge PS1 main-RAM
* read wait states (R3000A has no D-cache). Wired to cpu->read_* below so the
* timing applies to recompiled + interpreted guest loads, not debug/device reads. */
extern "C" uint32_t psx_guest_read_word(uint32_t addr);
extern "C" uint16_t psx_guest_read_half(uint32_t addr);
extern "C" uint8_t psx_guest_read_byte(uint32_t addr);
/* ---- SDL state ---- */
extern "C" {
SDL_Window* sdl_window = nullptr;
}
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 */
/* Pad input mode (PSXRecompV4::PadMode): 0=hybrid (default), 1=analog,
* 2=digital. hybrid_analog is the per-frame auto-switch latch used only in
* hybrid mode: true => currently presenting DualShock (stick was the last
* input), false => currently presenting a digital pad (D-pad was last). */
int mode = PSXRecompV4::PAD_MODE_HYBRID;
bool hybrid_analog = false;
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;
typedef void (*ModFrameHook)(void);
static std::vector<ModFrameHook>& mod_frame_hooks() {
static std::vector<ModFrameHook> hooks;
return hooks;
}
/* Statically linked mod code can register per-frame callbacks here. Hooks run
* after normal input sampling and before presentation/widescreen frame work. */
extern "C" void mod_register_frame_hook(ModFrameHook hook) {
if (hook) mod_frame_hooks().push_back(hook);
}
static void mod_call_frame_hooks() {
auto& hooks = mod_frame_hooks();
const size_t count = hooks.size();
for (size_t i = 0; i < count; i++) hooks[i]();
}
/* Presentation-only interpolation for software-rendered content that repeats
* each guest image for two vblanks. This never changes guest or audio timing. */
static std::atomic<int> g_smooth_60fps{0};
struct Smooth60State {
std::vector<uint32_t> previous_source;
uint64_t source_hash = 0;
uint32_t width = 0;
uint32_t height = 0;
bool have_source = false;
bool previous_was_duplicate = false;
bool announced = false;
};
static Smooth60State g_smooth_60_state;
/* Present-path session state — must reset on rematch (function-local statics
* survive soft-return and poison FMV/FPS after session_reboot). */
static bool s_disabled_frame_presented = false;
static bool s_force_present_after_load = false;
static Uint64 s_fps_last_time = 0;
static uint64_t s_fps_last_frame = 0;
static std::string s_fps_base_title;
static FramePacer s_frame_pacer = { 0 };
static int s_turbo_present_skip = 0;
static int s_fmv_skip_present_skip = 0;
/* Netplay + depth24: present every other vblank (admit still every tick). */
static int s_netplay_depth24_present_skip = 0;
static uint32_t s_fmv_skip_last_mdec = 0;
static int s_fmv_skip_hold = 0;
static void smooth_60_reset(void) {
g_smooth_60_state.previous_source.clear();
g_smooth_60_state.source_hash = 0;
g_smooth_60_state.width = 0;
g_smooth_60_state.height = 0;
g_smooth_60_state.have_source = false;
g_smooth_60_state.previous_was_duplicate = false;
}
static void present_session_reset(void) {
s_disabled_frame_presented = false;
s_force_present_after_load = false;
s_fps_last_time = 0;
s_fps_last_frame = 0;
s_fps_base_title.clear();
s_frame_pacer = FramePacer{ 0 };
s_turbo_present_skip = 0;
s_fmv_skip_present_skip = 0;
s_netplay_depth24_present_skip = 0;
s_fmv_skip_last_mdec = 0;
s_fmv_skip_hold = 0;
smooth_60_reset();
}
/* Called from savestate_poll after a successful restore (before scheduler
* longjmp). Clears present latches and forces the next vblank to show the
* restored VRAM — including a blank if display was disabled in the snapshot. */
extern "C" void psx_frontend_on_savestate_loaded(void) {
s_disabled_frame_presented = false;
s_force_present_after_load = true;
smooth_60_reset();
/* Re-anchor wall pacing + FPS baseline: admit may have blocked for seconds
* in the load barrier with next_deadline in the past. */
s_frame_pacer = FramePacer{ 0 };
s_fps_last_time = 0;
s_fps_last_frame = 0;
/* GL present-dirty early-out can skip SwapWindow when the restored frame
* matches the last swap (typical on 2nd+ load of the same slot). Invalidate
* tiles + force several swaps so the window actually updates. Safe no-op
* when the GL pipeline was never brought up. */
gl_renderer_invalidate_present();
}
static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) {
uint64_t hash = 1469598103934665603ull;
const size_t step = std::max<size_t>(1, count / 4096u);
for (size_t i = 0; i < count; i += step) {
hash ^= pixels[i];
hash *= 1099511628211ull;
}
hash ^= count;
return hash * 1099511628211ull;
}
static bool smooth_60_scene_cut(const uint32_t* current,
const uint32_t* previous,
size_t count) {
const size_t step = std::max<size_t>(1, count / 4096u);
uint64_t difference = 0;
size_t samples = 0;
size_t large_changes = 0;
for (size_t i = 0; i < count; i += step) {
const uint32_t a = current[i];
const uint32_t b = previous[i];
const unsigned dr = (unsigned)std::abs((int)((a >> 16) & 0xFFu) -
(int)((b >> 16) & 0xFFu));
const unsigned dg = (unsigned)std::abs((int)((a >> 8) & 0xFFu) -
(int)((b >> 8) & 0xFFu));
const unsigned db = (unsigned)std::abs((int)(a & 0xFFu) -
(int)(b & 0xFFu));
const unsigned pixel_difference = dr + dg + db;
difference += pixel_difference;
if (pixel_difference > 320u) large_changes++;
samples++;
}
return samples && large_changes * 4u > samples * 3u &&
difference > (uint64_t)samples * 360u;
}
static void smooth_60_present(uint32_t* pixels, uint32_t width, uint32_t height,
bool eligible) {
if (!eligible || !pixels || !g_smooth_60fps.load(std::memory_order_acquire)) {
if (g_smooth_60_state.have_source) smooth_60_reset();
return;
}
Smooth60State& state = g_smooth_60_state;
const size_t count = (size_t)width * height;
if (!count) { smooth_60_reset(); return; }
const uint64_t hash = smooth_60_frame_hash(pixels, count);
if (!state.have_source || state.width != width || state.height != height) {
state.previous_source.assign(pixels, pixels + count);
state.source_hash = hash;
state.width = width;
state.height = height;
state.have_source = true;
state.previous_was_duplicate = false;
return;
}
const bool duplicate = hash == state.source_hash &&
std::memcmp(pixels, state.previous_source.data(), count * sizeof(uint32_t)) == 0;
if (duplicate) { state.previous_was_duplicate = true; return; }
const bool interpolate = state.previous_was_duplicate &&
!smooth_60_scene_cut(pixels, state.previous_source.data(), count);
state.previous_was_duplicate = false;
state.source_hash = hash;
if (!interpolate) {
std::copy(pixels, pixels + count, state.previous_source.begin());
return;
}
if (!state.announced) {
std::fprintf(stdout,
"psxrecomp: 60 FPS smoothing active (guest timing preserved)\n");
state.announced = true;
}
for (size_t i = 0; i < count; i++) {
const uint32_t current = pixels[i];
const uint32_t previous = state.previous_source[i];
state.previous_source[i] = current;
pixels[i] = 0xFF000000u |
(((current & 0x00FEFEFEu) >> 1) + ((previous & 0x00FEFEFEu) >> 1));
}
}
extern "C" void psx_smooth_60fps_set(int enabled) {
g_smooth_60fps.store(enabled ? 1 : 0, std::memory_order_release);
}
#if defined(PSX_WEB)
extern "C" EMSCRIPTEN_KEEPALIVE void psx_web_set_smooth_60fps(int enabled) {
psx_smooth_60fps_set(enabled);
}
#endif
/* [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 = PSXRecompV4::DEFAULT_VIDEO_RENDERER;
static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop)
* fullscreen, 2 exclusive fullscreen */
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; /* skip FMVs the instant they're detected */
static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */
/* FMV instant-skip via the game's OWN end-of-movie path. Tomba's MDEC player
* (FUN_8001efe8) tears a movie down when the streamed frame number reaches that
* movie's per-movie total minus 3; writing the current movie's total down to
* g_fmv_skip_end_total makes the player end it on the next frame — a natural end
* that reaches EVERY movie. g_fmv_skip_total_table = base of the per-movie u16
* total table; g_fmv_skip_movie_id = guest addr of the current-movie-id byte.
* 0 table => fall back to START injection. See [video] fmv_skip_* in game.toml. */
static uint32_t g_fmv_skip_total_table = 0;
static uint32_t g_fmv_skip_movie_id = 0;
static int g_fmv_skip_end_total = 3;
/* fmv_skip_no_xa: detect FMVs on MDEC activity alone (silent RAM-preloaded
* movies never stream XA, so the default MDEC+XA detector misses them —
* Tomba2's Whoopee Camp logo). Presentation-side fast-forward only. */
static int g_fmv_skip_no_xa = 0;
static int g_fmv_skip_no_xa_hold = 4;
/* Low-latency present options (see [video] in config_loader). Measured on a
* 60Hz box the dominant input->photon cost is NOT vsync at the swap (that
* blocks ~tens of us) but that input is sampled ~one pacer-wait (~13.6ms)
* before the frame the CPU then renders from it. g_low_latency_input re-samples
* the pad AFTER the wall-clock pacer (just before present) so the next CPU frame
* reads near-fresh input. g_video_vsync controls the GL swap interval
* (1=vsync/tear-free, 0=immediate/lowest display latency+tearing, -1=adaptive);
* it trims the display-side scanout latency the CPU-side ring can't see. */
static int g_low_latency_input = 1;
static int g_video_vsync = 1;
static int g_frame_interpolation = 0;
static int g_frame_interpolation_fps = 0;
static double g_host_refresh_hz = 0.0;
/* Map the configured tri-state fullscreen mode (g_fullscreen) to the SDL
* window-fullscreen flag: used both to open the window in that mode and to
* pick the hotkey's fullscreen target. */
static Uint32 psx_fullscreen_flag_for_mode(int mode) {
if (mode == 2) return SDL_WINDOW_FULLSCREEN; /* exclusive */
if (mode == 1) return SDL_WINDOW_FULLSCREEN_DESKTOP; /* borderless */
return 0; /* windowed */
}
/* 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);
/* Debug observability: expose the RESOLVED FMV-skip + config state so the TCP
* debug server can report the runtime truth (which game.toml path actually loaded,
* and what auto_skip_fmv / the frame-count-table params resolved to) instead of us
* inferring it from the .toml on disk. Set once in main() after the config path is
* resolved. */
const char *g_active_config_path = nullptr;
extern "C" void debug_get_fmv_config(int *auto_skip, uint32_t *total_table,
uint32_t *movie_id, int *no_xa_hold,
const char **cfg_path) {
if (auto_skip) *auto_skip = g_auto_skip_fmv;
if (total_table) *total_table = g_fmv_skip_total_table;
if (movie_id) *movie_id = g_fmv_skip_movie_id;
if (no_xa_hold) *no_xa_hold = g_fmv_skip_no_xa_hold;
if (cfg_path) *cfg_path = g_active_config_path ? g_active_config_path : "(null)";
}
/* 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;
/* Resize-driven widescreen. The user's fixed aspect is still used to shape the
* initial window; after the game window exists these values follow its live
* aspect, clamped to 4:3..the widest mode offered by the title. */
static bool g_ws_adaptive_view = false;
static int g_ws_adaptive_max_num = 16;
static int g_ws_adaptive_max_den = 9;
/* [widescreen] per-game hooks (see config_loader.h): anchor scratch addr for
* tagged sprite prims + HUD SPRT center-squash. Inert at 0/false. */
static uint32_t g_ws_anchor_addr = 0;
static bool g_ws_hud_sprt = false;
/* Runtime-only transition cleanup; kept out of gpu.h because generated game
* units include that ABI header and do not need this frontend-only setter. */
extern "C" void gpu_ws_set_clear_reveal(int on);
extern "C" void gpu_ws_set_nw_textured_edges(int on, int scale_pct);
extern "C" void gpu_ws_set_signed_x_bound_sites(const uint32_t*, const uint32_t*, int);
/* Widescreen engages at game entry (fntrace_is_game_started): the BIOS boot
* — Sony logo, PS logo, shell — presents authentic 4:3 with no GTE squash.
* Starts true when the configured aspect is already 4:3 (nothing to engage). */
static bool g_ws_engaged = true;
/* Wide-aspect strategy: native-wide (render the wider FOV into a wider frame,
* present 1:1 — the GTE is NOT squashed) vs. the legacy squash hack. Default
* native-wide; toggle live via the ws_nw TCP command for A/B comparison. */
static int g_ws_native_wide = 1;
/* 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 int aspect_gcd(int a, int b) {
while (b) { int t = a % b; a = b; b = t; }
return a > 0 ? a : 1;
}
/* Follow the host window without feeding its absolute pixel size into guest
* rendering. Only the ratio matters: gpu_ws_configure derives the PSX-native
* sidecar width from it, just as the fixed 16:9/21:9 modes do. */
static void update_adaptive_widescreen() {
if (!g_ws_adaptive_view || !sdl_window) return;
int width = 0, height = 0;
SDL_GetWindowSize(sdl_window, &width, &height);
if (width <= 0 || height <= 0) return;
int num = width, den = height;
if ((int64_t)width * 3 <= (int64_t)height * 4) {
num = 4; den = 3;
} else if ((int64_t)width * g_ws_adaptive_max_den >=
(int64_t)height * g_ws_adaptive_max_num) {
num = g_ws_adaptive_max_num;
den = g_ws_adaptive_max_den;
} else {
int divisor = aspect_gcd(num, den);
num /= divisor;
den /= divisor;
}
if (num == g_video_aspect_num && den == g_video_aspect_den) return;
g_video_aspect_num = num;
g_video_aspect_den = den;
gl_renderer_set_display_aspect(num, den);
if (sdl_renderer) {
g_logical_w = 480 * num * g_video_scale / den;
SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale);
}
if (g_ws_engaged) {
const bool wide = num * 3 != den * 4;
const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0;
gte_set_display_aspect(mode == 1 ? num : 4,
mode == 1 ? den : 3);
gpu_ws_configure(num, den, g_ws_anchor_addr,
g_ws_hud_sprt ? 1 : 0, mode);
}
}
/* SDL GL attributes are global inputs to the next context creation. Set the
* runtime requirements immediately before every GL window, because launcher
* teardown resets them and macOS otherwise supplies a legacy 2.1 context. */
static void configure_core_gl_context_attributes() {
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);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
}
/* Live native-wide vs squash toggle (ws_nw TCP command) for A/B comparison.
* Re-engages with the chosen mode in place if widescreen is already running. */
extern "C" void psx_ws_set_native_wide(int on) {
g_ws_native_wide = on ? 1 : 0;
if (g_ws_engaged && g_video_aspect_num * 3 != g_video_aspect_den * 4) {
int mode = g_ws_native_wide ? 2 : 1;
gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4,
mode == 1 ? g_video_aspect_den : 3);
gpu_ws_configure(g_video_aspect_num, g_video_aspect_den,
g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode);
}
}
extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; }
static bool g_gl_active = false; /* GL context live -> GL present path */
static bool g_vk_active = false; /* Vulkan context live -> VK 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) */
int g_turbo_audio_sink_enabled = 0;
int g_turbo_audio_sink_active = 0;
uint64_t g_turbo_audio_sink_frames = 0; /* guest SPU frames advanced, not queued */
}
/* The CD predicate already excludes XA and holds across ordinary inter-file
* gaps. A short engage debounce rejects a one-frame controller blip without
* leaving a visible authentic-paced prefix on every real load. Once engaged,
* a symmetric release debounce rides through tiny late-sector/IRQ gaps. This
* changes host presentation/pacing only; guest cycles and input sampling keep
* advancing normally. */
#define TURBO_LOADS_ENGAGE_FRAMES 4
#define TURBO_LOADS_RELEASE_FRAMES 6
static SDL_AudioDeviceID sdl_audio_device;
static int16_t sdl_audio_buf[2048 * 2];
/* DRC bridge. Producer (sdl_audio_pump) runs on the main loop thread under
* SDL_LockAudioDevice; consumer (sdl_drc_callback) runs on the SDL audio
* thread. */
static rab_bridge s_drc;
static bool s_drc_ready = false;
/* Observability + A/B. PSXRECOMP_AUDIO_LEGACY=1 keeps the historical push model
* (SDL_QueueAudio, no bridge) so the underrun baseline can be measured against
* the bridge from a single build; default (unset) = bridge/pull model. Output
* health is surfaced through the audio_stats TCP command (no stderr probe). */
static bool audio_legacy_mode(void) {
static int v = -1;
if (v < 0) { const char* e = getenv("PSXRECOMP_AUDIO_LEGACY"); v = (e && *e && *e != '0') ? 1 : 0; }
return v != 0;
}
/* Legacy-mode underrun counter: incremented when the SDL queue is found empty
* at pump time (the device was silence-filling = an audible gap). */
static uint64_t g_legacy_underruns = 0;
/* Set at unmute so the pump resyncs its bridge-underrun baseline past the
* intentional mute-drain instead of reporting it as gaps. */
int g_audio_unmute_resync = 0;
/* Actual device rate the host opened at (bridge mode may differ from 44100;
* the T3 tap ring runs at this rate and its WAV dump must say so). */
extern "C" {
int g_audio_host_rate = 44100;
}
static void sdl_drc_callback(void* /*user*/, Uint8* stream, int len) {
if (!s_drc_ready) { std::memset(stream, 0, (size_t)len); return; }
int frames = len / (int)(2 * sizeof(int16_t)); /* stereo S16 */
rab_pull(&s_drc, reinterpret_cast<int16_t*>(stream), frames);
/* T3 tap in bridge mode: the exact device-rate bytes the host consumes.
* This callback is the tap's single writer while the bridge is active. */
audio_trace_pcm(AUDIO_TAP_HOST, reinterpret_cast<const int16_t*>(stream),
frames);
}
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 {};
}
// The directory the running executable lives in — the SINGLE anchor for every
// runtime file (game.toml, disc, BIOS, settings.toml, caches, memcards, sidecar
// .cfg). Deliberately NEVER the current working directory: a game must launch
// identically whether started from its own folder, a shortcut, a shell in some
// other directory, or a debugger. On Windows GetModuleFileNameW is the
// authoritative source (independent of argv[0], which can be relative/bare and
// would otherwise get resolved against cwd by fs::absolute). $APPIMAGE and
// argv[0] are non-Windows / fallback sources only.
static std::filesystem::path exe_dir_from_argv(const char* argv0) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path exe_dir;
#ifdef _WIN32
// Authoritative: the real image path, regardless of how we were invoked.
{
wchar_t buf[MAX_PATH * 4];
DWORD n = GetModuleFileNameW(NULL, buf, (DWORD)(sizeof(buf) / sizeof(buf[0])));
if (n > 0 && n < (DWORD)(sizeof(buf) / sizeof(buf[0]))) {
exe_dir = fs::path(std::wstring(buf, buf + n)).parent_path();
}
}
#endif
// Inside an AppImage the binary lives in a read-only mount and argv[0] can
// be a bare name or symlink (launched via PATH / a .desktop file). $APPIMAGE
// is the .AppImage's own path, so settings.toml anchors next to it.
if (exe_dir.empty()) {
if (const char* appimg = std::getenv("APPIMAGE"); appimg && appimg[0]) {
exe_dir = fs::absolute(appimg, ec).parent_path();
if (ec) exe_dir.clear();
}
}
if (exe_dir.empty() && argv0 && argv0[0]) {
exe_dir = fs::absolute(argv0, ec).parent_path();
if (ec) exe_dir.clear();
}
// Last-ditch only (should be unreachable on a normal launch); a bare "." so
// we never silently resolve against an unrelated cwd deeper in the tree.
if (exe_dir.empty()) exe_dir = fs::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 {};
// Anchor exclusively on the exe directory — never cwd (see exe_dir_from_argv).
const fs::path root = exe_dir_from_argv(argv0);
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
// Headless (--headless / PSX_HEADLESS): NEVER pop a blocking modal — it would
// hang an unattended/CI/scripted run forever waiting for a click.
if (!g_headless) 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
if (!g_headless) 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) {
// Headless: never open an interactive file dialog (it blocks). Fail the
// resolve so boot aborts cleanly with the stderr message the caller printed.
if (g_headless) {
std::fprintf(stderr, "psxrecomp: headless — cannot prompt for '%s'; "
"supply it via game.toml / --disc / --bios.\n", title);
return false;
}
#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;
}
// Region display label for the launcher, derived from the game-id serial
// prefix (e.g. "SCUS-94423" -> "(USA)"). Used only when game.toml [game]
// leaves `region` unset. Unknown/empty serials yield "" (no badge) rather
// than guessing.
static std::string region_label_from_serial(const std::string& serial) {
if (serial.size() < 3) return std::string();
const std::string up = uppercase_ascii(serial);
const std::string p4 = up.substr(0, std::min<size_t>(4, up.size()));
const std::string p3 = up.substr(0, 3);
if (p4 == "SCUS" || p4 == "SLUS" || p3 == "LSP") return "(USA)";
if (p4 == "SCES" || p4 == "SLES") return "(Europe)";
if (p4 == "SCPS" || p4 == "SLPS" || p4 == "SLPM") return "(Japan)";
return std::string();
}
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 std::filesystem::path normalize_disc_path_for_launch(const std::filesystem::path& path) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path p = fs::absolute(path, ec);
if (ec) p = path;
if (uppercase_ascii(p.extension().string()) == ".CUE") {
fs::path bin = p;
bin.replace_extension(".bin");
if (fs::exists(bin, ec)) {
fs::path abs = fs::absolute(bin, ec);
return ec ? bin : abs;
}
ec.clear();
bin.replace_extension(".BIN");
if (fs::exists(bin, ec)) {
fs::path abs = fs::absolute(bin, ec);
return ec ? bin : abs;
}
}
return p;
}
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 = normalize_disc_path_for_launch(disc_override);
return validate_disc_for_launch(p, game_id) ? p : std::filesystem::path{};
}
// A relative disc path in game.toml resolves against the exe dir (never cwd);
// resolve_existing_runtime_path also walks up from there (build/ -> game dir),
// so an out-of-tree build layout still finds the bundled disc.
if (!config_disc.empty()) {
std::filesystem::path cd = config_disc;
if (cd.is_relative()) {
std::filesystem::path r = resolve_existing_runtime_path(config_disc.string().c_str(), argv0);
if (!r.empty()) cd = r;
}
cd = normalize_disc_path_for_launch(cd);
if (std::filesystem::exists(cd) && validate_disc_for_launch(cd, game_id)) {
return cd;
}
}
std::filesystem::path cached = read_cached_path(argv0, "disc.cfg");
if (!cached.empty()) {
cached = normalize_disc_path_for_launch(cached);
}
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 {};
}
picked = normalize_disc_path_for_launch(picked);
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;
// Anchor on the exe directory — never cwd (see exe_dir_from_argv).
fs::path found = find_upward(exe_dir_from_argv(argv0), p);
if (!found.empty()) return found / p;
// Dev-checkout rung: game projects keep the framework at
// <game root>/psxrecomp-v4 (junction/worktree), so a relative default like
// "bios/SCPH1001.BIN" lives under that prefix rather than at the game root.
// A user install has no psxrecomp-v4 directory, so this rung cannot
// resolve a build-machine BIOS there — it falls through to bios.cfg or the
// interactive picker.
const fs::path dev_marker = fs::path("psxrecomp-v4") / p;
found = find_upward(exe_dir_from_argv(argv0), dev_marker);
if (!found.empty()) return found / dev_marker;
return p;
}
// Fallback memcard directory used when no game config (or its [runtime]
// block) specifies one: the executable's directory (authoritative, never cwd —
// see exe_dir_from_argv), so saves always live next to the binary.
static std::filesystem::path default_memcard_dir(const char* argv0) {
return exe_dir_from_argv(argv0);
}
static void close_controller(void);
static void shutdown_runtime(void);
/* Set when this process started netplay from the lobby room — soft-exit returns
* there instead of killing the process. */
static int g_netplay_from_lobby = 0;
static int g_netplay_vsync_forced_off = 0;
/* Host-only: lockstep already couples peers. Driver vsync on top of the
* wall-clock pacer double-blocks the vblank callback (present is before the
* guest resumes), which shows up as MotK FMV ~30–40 FPS in netplay vs ~50+
* offline. Force immediate swaps for the session; restore on soft-exit. */
static void netplay_host_present_uncap(void) {
#ifndef PSX_SDL_NO_RENDER
if (g_gl_active) gl_renderer_set_swap_interval(0);
if (g_vk_active) vk_renderer_set_present_mode(0);
#endif
g_netplay_vsync_forced_off = 1;
}
static void netplay_host_present_restore(void) {
if (!g_netplay_vsync_forced_off) return;
#ifndef PSX_SDL_NO_RENDER
if (g_gl_active) gl_renderer_set_swap_interval(g_video_vsync);
if (g_vk_active) vk_renderer_set_present_mode(g_video_vsync);
#endif
g_netplay_vsync_forced_off = 0;
}
static void netplay_soft_exit(const char *origin) {
psx_crash_trace_set_exit_origin(origin);
netplay_host_present_restore();
psx_netplay_shutdown(); /* sends BYE so the peer soft-exits too */
if (g_netplay_from_lobby) {
std::printf("psxrecomp: netplay ended (%s) — returning to lobby\n",
origin ? origin : "?");
std::fflush(stdout);
psx_request_return_to_lobby();
return;
}
shutdown_runtime();
std::exit(0);
}
static void shutdown_runtime(void) {
/* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the
* off-thread JIT worker here; the worker no longer exists.) */
psx_netplay_shutdown();
memcard_flush_all();
overlay_autocapture_shutdown();
overlay_capture_wait_pending();
overlay_capture_write_json();
if (sdl_audio_device) {
SDL_ClearQueuedAudio(sdl_audio_device);
SDL_CloseAudioDevice(sdl_audio_device); /* stops the pull callback */
sdl_audio_device = 0;
}
if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; }
close_controller();
debug_server_shutdown();
}
/* Tear down the game window/GL/audio after a lobby soft-return. Leaves SDL
* subsystems and the lobby WebSocket intact for the next launcher session. */
static void teardown_game_session_keep_lobby(void) {
netplay_host_present_restore();
psx_netplay_shutdown();
memcard_flush_all();
if (sdl_audio_device) {
SDL_ClearQueuedAudio(sdl_audio_device);
SDL_CloseAudioDevice(sdl_audio_device);
sdl_audio_device = 0;
}
if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; }
close_controller();
if (g_vk_active) {
vk_renderer_shutdown();
g_vk_active = false;
}
if (g_gl_active) {
gl_renderer_shutdown();
g_gl_active = false;
}
if (sdl_texture) { SDL_DestroyTexture(sdl_texture); sdl_texture = nullptr; }
if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; }
if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; }
if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; }
psx_lobby_set_ready(0);
psx_lobby_clear_launch_pending();
psx_clear_return_to_lobby();
}
/* 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(bool discard_output = false) {
if (!sdl_audio_device) return;
const uint32_t bytes_per_frame = sizeof(int16_t) * 2u;
const bool legacy = audio_legacy_mode();
static int had_audio = 0;
uint32_t queued = 0; /* RENDER event b: bytes (legacy) / fill ms (bridge) */
if (discard_output) {
/* Host-only sink: skip all queue/bridge interaction, but continue to
* the guest-cycle sample budget and spu_render below. */
} else if (legacy) {
/* Historical push model + baseline measurement: a drained (==0) queue
* means the device was silence-filling since the last pump = a gap.
* Only meaningful after the first audio has been queued. */
const uint32_t max_queue_bytes = 44100u * bytes_per_frame / 5u;
queued = SDL_GetQueuedAudioSize(sdl_audio_device);
if (queued == 0 && had_audio) {
g_legacy_underruns++;
audio_trace_event(AUDIO_EV_UNDERRUN, 0, 0);
}
if (queued > max_queue_bytes) {
audio_trace_event(AUDIO_EV_PUMP_SKIP, queued, 0);
return;
}
} else {
/* No host-queue backpressure check: the bridge's ring + DRC hold the
* fill near target, so we always render this frame and push it. */
if (!s_drc_ready) return;
/* Surface bridge underruns (counted on the SDL audio thread) into the
* event ring from this thread — the event ring is single-writer.
* Across a turbo mute the ring intentionally runs dry (the pump stops
* while the callback keeps pulling); those dry pulls are the mute,
* not gaps — resync past them instead of reporting them. */
rab_stats st;
rab_get_stats(&s_drc, &st);
static uint64_t prev_underruns = 0;
extern int g_audio_unmute_resync;
if (g_audio_unmute_resync) {
prev_underruns = st.underrun_events;
g_audio_unmute_resync = 0;
} else if (st.underrun_events > prev_underruns) {
audio_trace_event(AUDIO_EV_UNDERRUN,
(uint32_t)(st.underrun_events - prev_underruns), 1);
prev_underruns = st.underrun_events;
}
queued = (uint32_t)st.last_fill_ms;
}
/* Faithful sample budget: the SPU is clocked by the GUEST, not by host
* presents. 33.8688 MHz / 44100 Hz = exactly 768 guest cycles per output
* frame, so production tracks guest time precisely — including the real
* NTSC 59.94 Hz vblank — instead of assuming 60.00 Hz per present, which
* built in a systematic -0.1% production deficit (measured: 43950/s
* produced vs 44100/s consumed = recurring ring underruns no +/-0.5%
* DRC trim could absorb during jitter spikes). */
extern uint64_t psx_cycle_count;
static uint64_t last_cycles = 0;
static uint64_t cycle_carry = 0;
const uint64_t now_cycles = psx_cycle_count;
if (last_cycles == 0) last_cycles = now_cycles;
uint64_t delta = (now_cycles - last_cycles) + cycle_carry;
last_cycles = now_cycles;
int frames = (int)(delta / 768u);
cycle_carry = delta % 768u;
if (frames <= 0) return;
if (frames > 2048) {
/* A burst beyond one buffer (e.g. right after an unmute or a long
* stall): render one full buffer and DROP the remainder of the debt —
* same semantic as the mute model (voice positions freeze across the
* gap) rather than time-compressing a backlog into garble. */
frames = 2048;
cycle_carry = 0;
}
audio_trace_event(AUDIO_EV_RENDER, (uint32_t)frames, queued);
spu_render(sdl_audio_buf, frames);
if (discard_output) {
g_turbo_audio_sink_frames += (uint64_t)frames;
audio_trace_event(AUDIO_EV_SINK_DROP, (uint32_t)frames, 0);
return;
}
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;
}
if (legacy) {
/* T3 tap: the exact post-fade bytes handed to the host audio queue. */
audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames);
SDL_QueueAudio(sdl_audio_device, sdl_audio_buf,
(uint32_t)frames * bytes_per_frame);
had_audio = 1;
} else {
/* Hand to the bridge (band-limited resample + DRC) instead of
* SDL_QueueAudio. Lock guards the SPSC ring against the pull callback.
* The T3 tap moves to sdl_drc_callback: what the device actually
* receives is the bridge's device-rate output, not this buffer. */
SDL_LockAudioDevice(sdl_audio_device);
rab_push(&s_drc, sdl_audio_buf, frames);
SDL_UnlockAudioDevice(sdl_audio_device);
}
}
/* 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). */
/* Bridge/legacy output health, surfaced through the audio_stats TCP command
* (debug_server.c) — no stderr probe; rule 3. */
extern "C" int psx_audio_out_stats(double *fill_ms, uint64_t *underruns,
uint64_t *overflow_drops, double *correction,
int *legacy, int *host_rate)
{
*legacy = audio_legacy_mode() ? 1 : 0;
*host_rate = g_audio_host_rate;
if (*legacy || !s_drc_ready) {
*fill_ms = sdl_audio_device
? (double)SDL_GetQueuedAudioSize(sdl_audio_device)
/ (44100.0 * 4.0) * 1000.0
: 0.0;
*underruns = g_legacy_underruns;
*overflow_drops = 0;
*correction = 0.0;
return sdl_audio_device != 0;
}
rab_stats st;
rab_get_stats(&s_drc, &st);
*fill_ms = st.last_fill_ms;
*underruns = st.underrun_events;
*overflow_drops = st.overflow_drops;
*correction = st.last_correction;
return 1;
}
/* Lightweight production-safe cadence probe. Unlike the TCP debug build this
* adds no per-block or per-instruction recording. All counter/timer work below
* is opt-in via PSX_RUNTIME_PERF_DIAG; the normal Release path pays only the
* existing once-per-vblank disabled checks. */
struct RuntimePerfSnapshot {
uint64_t counter = 0;
uint64_t frame = 0;
uint64_t guest_work_ticks = 0;
uint64_t pacer_ticks = 0;
uint64_t autocapture_ticks = 0;
uint64_t provider_poll_ticks = 0;
uint64_t dirty_insns = 0;
uint64_t dirty_dispatches = 0;
uint32_t overlay_loads = 0;
uint32_t overlay_invalidations = 0;
uint32_t overlay_unregistered = 0;
uint64_t overlay_native = 0;
uint64_t overlay_interp = 0;
uint64_t overlay_stale = 0;
uint32_t overlay_revalidations = 0;
uint32_t overlay_hot_native_pc = 0;
uint64_t overlay_hot_native_calls = 0;
uint64_t overlay_shadow_calls = 0;
uint64_t overlay_shadow_divergences = 0;
uint32_t overlay_first_divergence_pc = 0;
uint32_t capture_triggers = 0;
uint64_t capture_last_dispatch_delta = 0;
int capture_overlays = 0;
};
struct RuntimePerfState {
bool initialized = false;
bool enabled = false;
uint32_t interval_ms = 5000;
uint64_t frequency = 0;
uint64_t last_frame_exit_counter = 0;
uint64_t guest_work_ticks = 0;
uint64_t pacer_ticks = 0;
uint64_t autocapture_ticks = 0;
uint64_t provider_poll_ticks = 0;
bool bench_configured = false;
bool bench_started = false;
bool bench_reported = false;
uint64_t bench_start_frame = 0;
uint64_t bench_end_frame = 0;
RuntimePerfSnapshot bench_start;
};
static RuntimePerfState g_runtime_perf;
static bool runtime_perf_parse_window(const char *text, uint64_t *start,
uint64_t *end) {
if (!text || !text[0]) return false;
char *middle = nullptr;
unsigned long long first = std::strtoull(text, &middle, 10);
if (middle == text || *middle != ':') return false;
char *tail = nullptr;
unsigned long long last = std::strtoull(middle + 1, &tail, 10);
if (tail == middle + 1 || *tail != '\0' || first == 0 || last <= first)
return false;
*start = (uint64_t)first;
*end = (uint64_t)last;
return true;
}
static void runtime_perf_init() {
if (g_runtime_perf.initialized) return;
g_runtime_perf.initialized = true;
const char *enabled = std::getenv("PSX_RUNTIME_PERF_DIAG");
g_runtime_perf.enabled = enabled && enabled[0] && enabled[0] != '0';
if (!g_runtime_perf.enabled) return;
g_runtime_perf.frequency = SDL_GetPerformanceFrequency();
if (!g_runtime_perf.frequency) g_runtime_perf.frequency = 1;
if (const char *interval = std::getenv("PSX_RUNTIME_PERF_DIAG_MS")) {
char *tail = nullptr;
long requested = std::strtol(interval, &tail, 10);
if (tail != interval && *tail == '\0') {
if (requested < 250) requested = 250;
if (requested > 600000) requested = 600000;
g_runtime_perf.interval_ms = (uint32_t)requested;
} else {
std::fprintf(stderr,
"psxrecomp: ignoring invalid PSX_RUNTIME_PERF_DIAG_MS='%s'\n",
interval);
}
}
if (const char *window = std::getenv("PSX_BENCH_WINDOW")) {
if (runtime_perf_parse_window(window,
&g_runtime_perf.bench_start_frame,
&g_runtime_perf.bench_end_frame)) {
g_runtime_perf.bench_configured = true;
} else {
std::fprintf(stderr,
"psxrecomp: ignoring invalid PSX_BENCH_WINDOW='%s' "
"(expected start:end, start >= 1, end > start)\n", window);
}
}
std::fprintf(stdout, "psxrecomp: runtime perf diagnostics enabled: interval=%u ms",
g_runtime_perf.interval_ms);
if (g_runtime_perf.bench_configured)
std::fprintf(stdout, ", bench=%llu:%llu",
(unsigned long long)g_runtime_perf.bench_start_frame,
(unsigned long long)g_runtime_perf.bench_end_frame);
std::fprintf(stdout, "\n");
std::fflush(stdout);
}
static RuntimePerfSnapshot runtime_perf_snapshot(uint64_t now) {
RuntimePerfSnapshot s;
s.counter = now;
s.frame = s_frame_count;
s.guest_work_ticks = g_runtime_perf.guest_work_ticks;
s.pacer_ticks = g_runtime_perf.pacer_ticks;
s.autocapture_ticks = g_runtime_perf.autocapture_ticks;
s.provider_poll_ticks = g_runtime_perf.provider_poll_ticks;
s.dirty_insns = g_dirty_ram_insns_run;
s.dirty_dispatches = g_dirty_window_dispatches;
overlay_loader_get_counters(&s.overlay_loads, &s.overlay_invalidations,
&s.overlay_unregistered, &s.overlay_native,
&s.overlay_interp, &s.overlay_stale,
nullptr, nullptr, nullptr, nullptr,
&s.overlay_revalidations);
overlay_loader_take_hot_native(&s.overlay_hot_native_pc,
&s.overlay_hot_native_calls);
overlay_loader_get_shadow_summary(&s.overlay_shadow_calls,
&s.overlay_shadow_divergences,
&s.overlay_first_divergence_pc);
int capture_enabled = 0;
overlay_autocapture_get_status(&capture_enabled, &s.capture_triggers,
&s.capture_last_dispatch_delta);
s.capture_overlays = overlay_capture_count();
return s;
}
static double runtime_perf_ticks_ms(uint64_t ticks) {
return (double)ticks * 1000.0 / (double)g_runtime_perf.frequency;
}
static void runtime_perf_bench_tick(uint64_t now) {
if (!g_runtime_perf.bench_configured || g_runtime_perf.bench_reported) return;
if (!g_runtime_perf.bench_started) {
if (s_frame_count == g_runtime_perf.bench_start_frame) {
g_runtime_perf.bench_start = runtime_perf_snapshot(now);
g_runtime_perf.bench_started = true;
}
return;
}
if (s_frame_count != g_runtime_perf.bench_end_frame) return;
RuntimePerfSnapshot end = runtime_perf_snapshot(now);
const RuntimePerfSnapshot &start = g_runtime_perf.bench_start;
std::fprintf(stdout,
"[BENCH] window=%llu:%llu frames=%llu wall_ms=%.3f "
"guest_work_ms=%.3f pacer_ms=%.3f autocapture_main_ms=%.3f "
"provider_poll_ms=%.3f "
"dirty_insns=+%llu dirty_dispatches=+%llu "
"overlay_native=+%llu overlay_interp=+%llu overlay_loads=+%u "
"overlay_invalidations=+%u overlay_unregistered=+%u "
"overlay_stale=+%llu overlay_revalidations=+%u "
"capture_triggers=+%u capture_overlays=+%d "
"capture_last_dispatch_delta=%llu\n",
(unsigned long long)start.frame, (unsigned long long)end.frame,
(unsigned long long)(end.frame - start.frame),
runtime_perf_ticks_ms(end.counter - start.counter),
runtime_perf_ticks_ms(end.guest_work_ticks - start.guest_work_ticks),
runtime_perf_ticks_ms(end.pacer_ticks - start.pacer_ticks),
runtime_perf_ticks_ms(end.autocapture_ticks - start.autocapture_ticks),
runtime_perf_ticks_ms(end.provider_poll_ticks - start.provider_poll_ticks),
(unsigned long long)(end.dirty_insns - start.dirty_insns),
(unsigned long long)(end.dirty_dispatches - start.dirty_dispatches),
(unsigned long long)(end.overlay_native - start.overlay_native),
(unsigned long long)(end.overlay_interp - start.overlay_interp),
end.overlay_loads - start.overlay_loads,
end.overlay_invalidations - start.overlay_invalidations,
end.overlay_unregistered - start.overlay_unregistered,
(unsigned long long)(end.overlay_stale - start.overlay_stale),
end.overlay_revalidations - start.overlay_revalidations,
end.capture_triggers - start.capture_triggers,
end.capture_overlays - start.capture_overlays,
(unsigned long long)end.capture_last_dispatch_delta);
std::fflush(stdout);
g_runtime_perf.bench_reported = true;
}
static void runtime_perf_frame_begin() {
runtime_perf_init();
if (!g_runtime_perf.enabled) return;
uint64_t now = SDL_GetPerformanceCounter();
if (g_runtime_perf.last_frame_exit_counter &&
now >= g_runtime_perf.last_frame_exit_counter) {
g_runtime_perf.guest_work_ticks +=
now - g_runtime_perf.last_frame_exit_counter;
}
runtime_perf_bench_tick(now);
}
static void runtime_perf_frame_end() {
if (!g_runtime_perf.enabled) return;
g_runtime_perf.last_frame_exit_counter = SDL_GetPerformanceCounter();
}
class RuntimePerfFrameScope {
public:
~RuntimePerfFrameScope() { runtime_perf_frame_end(); }
};
static uint64_t runtime_perf_section_begin() {
return g_runtime_perf.enabled ? SDL_GetPerformanceCounter() : 0;
}
static void runtime_perf_section_end(uint64_t start, uint64_t *total) {
if (!start) return;
uint64_t end = SDL_GetPerformanceCounter();
if (end >= start) *total += end - start;
}
static void runtime_perf_diag_tick() {
static bool have_last = false;
static RuntimePerfSnapshot last;
static uint64_t last_spu = 0, last_underruns = 0, last_overflows = 0;
static uint64_t last_up[6] = {0};
static uint64_t last_overlay_load_us = 0;
if (!g_runtime_perf.enabled) return;
uint64_t now = SDL_GetPerformanceCounter();
const uint64_t interval_ticks =
g_runtime_perf.frequency * (uint64_t)g_runtime_perf.interval_ms / 1000u;
if (have_last && now - last.counter < interval_ticks) return;
RuntimePerfSnapshot current = runtime_perf_snapshot(now);
AudioTraceStats audio;
audio_trace_get_stats(&audio);
double fill_ms = 0.0, correction = 0.0;
uint64_t underruns = 0, overflows = 0;
int legacy = 0, host_rate = 0;
psx_audio_out_stats(&fill_ms, &underruns, &overflows, &correction,
&legacy, &host_rate);
uint64_t up[6] = {0};
gl_renderer_runtime_diag(up);
uint64_t overlay_load_us = 0, overlay_load_max_us = 0, overlay_load_last_us = 0;
overlay_loader_get_load_timing(&overlay_load_us, &overlay_load_max_us,
&overlay_load_last_us);
if (!have_last) {
last = current;
last_spu = audio.tap_frames[AUDIO_TAP_SPU_OUT];
last_underruns = underruns;
last_overflows = overflows;
last_overlay_load_us = overlay_load_us;
for (int i = 0; i < 6; i++) last_up[i] = up[i];
have_last = true;
return;
}
const double dt = (double)(current.counter - last.counter) /
(double)g_runtime_perf.frequency;
std::fprintf(stdout,
"psxrecomp: runtime cadence: guest=%.2f Hz, spu=%.1f Hz, "
"audio_fill=%.1f ms, underruns=+%llu, overflows=+%llu, corr=%+.5f; "
"GL upload=%.1f calls/s %.1f rect/s %.2f Mpix/s, "
"cpu=%.1f tex=%.1f draw=%.1f ms/s; "
"work guest=%.1f pacer=%.1f autocapture=%.1f provider_poll=%.1f ms/s, "
"dirty=%.0f insn/s %.0f dispatch/s; "
"overlay native=+%llu interp=+%llu hot_native=0x%08X/+%llu "
"shadow=+%llu div=+%llu first_div=0x%08X "
"loads=+%u revalidations=+%u "
"load_wall=%.1f ms max=%.1f last=%.1f ms; "
"capture triggers=+%u overlays=+%d last_dispatch_delta=%llu\n",
(double)(current.frame - last.frame) / dt,
(double)(audio.tap_frames[AUDIO_TAP_SPU_OUT] - last_spu) / dt,
fill_ms, (unsigned long long)(underruns - last_underruns),
(unsigned long long)(overflows - last_overflows), correction,
(double)(up[0] - last_up[0]) / dt,
(double)(up[1] - last_up[1]) / dt,
(double)(up[2] - last_up[2]) / dt / 1.0e6,
(double)(up[3] - last_up[3]) * 1000.0 /
(double)g_runtime_perf.frequency / dt,
(double)(up[4] - last_up[4]) * 1000.0 /
(double)g_runtime_perf.frequency / dt,
(double)(up[5] - last_up[5]) * 1000.0 /
(double)g_runtime_perf.frequency / dt,
runtime_perf_ticks_ms(current.guest_work_ticks - last.guest_work_ticks) / dt,
runtime_perf_ticks_ms(current.pacer_ticks - last.pacer_ticks) / dt,
runtime_perf_ticks_ms(current.autocapture_ticks - last.autocapture_ticks) / dt,
runtime_perf_ticks_ms(current.provider_poll_ticks - last.provider_poll_ticks) / dt,
(double)(current.dirty_insns - last.dirty_insns) / dt,
(double)(current.dirty_dispatches - last.dirty_dispatches) / dt,
(unsigned long long)(current.overlay_native - last.overlay_native),
(unsigned long long)(current.overlay_interp - last.overlay_interp),
current.overlay_hot_native_pc,
(unsigned long long)current.overlay_hot_native_calls,
(unsigned long long)(current.overlay_shadow_calls - last.overlay_shadow_calls),
(unsigned long long)(current.overlay_shadow_divergences -
last.overlay_shadow_divergences),
current.overlay_first_divergence_pc,
current.overlay_loads - last.overlay_loads,
current.overlay_revalidations - last.overlay_revalidations,
(double)(overlay_load_us - last_overlay_load_us) / 1000.0,
(double)overlay_load_max_us / 1000.0,
(double)overlay_load_last_us / 1000.0,
current.capture_triggers - last.capture_triggers,
current.capture_overlays - last.capture_overlays,
(unsigned long long)current.capture_last_dispatch_delta);
std::fflush(stdout);
last = current;
last_spu = audio.tap_frames[AUDIO_TAP_SPU_OUT];
last_underruns = underruns;
last_overflows = overflows;
last_overlay_load_us = overlay_load_us;
for (int i = 0; i < 6; i++) last_up[i] = up[i];
}
static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) {
if (!sdl_audio_device) return;
{ /* Tag audio events with the vblank frame counter. */
extern uint64_t s_frame_count;
audio_trace_note_frame((uint32_t)s_frame_count);
}
const int HANGOVER_FRAMES = 8; /* ~133 ms at 60 fps */
static int muted = 0;
static int hangover = 0;
static int sink_was_active = 0;
if (hard_mute_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);
audio_trace_event(AUDIO_EV_MUTE, (uint32_t)tail, 0);
if (audio_legacy_mode()) {
audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, tail);
SDL_QueueAudio(sdl_audio_device, sdl_audio_buf,
(uint32_t)tail * sizeof(int16_t) * 2u);
} else if (s_drc_ready) {
SDL_LockAudioDevice(sdl_audio_device);
rab_push(&s_drc, sdl_audio_buf, tail);
SDL_UnlockAudioDevice(sdl_audio_device);
}
muted = 1;
}
hangover = HANGOVER_FRAMES;
return;
}
if (muted) {
if (hangover > 0) { hangover--; return; }
muted = 0;
sdl_audio_fadein_left = sdl_audio_fade_samples;
audio_trace_event(AUDIO_EV_UNMUTE, (uint32_t)sdl_audio_fadein_left, 0);
extern int g_audio_unmute_resync;
g_audio_unmute_resync = 1;
}
if (turbo_sink_active) {
if (!sink_was_active) {
sink_was_active = 1;
audio_trace_event(AUDIO_EV_MUTE, 0, 2); /* b=2: discard-only sink */
}
g_turbo_audio_sink_active = 1;
sdl_audio_pump(true);
return;
}
g_turbo_audio_sink_active = 0;
if (sink_was_active) {
sink_was_active = 0;
sdl_audio_fadein_left = sdl_audio_fade_samples;
audio_trace_event(AUDIO_EV_UNMUTE,
(uint32_t)sdl_audio_fadein_left, 2);
g_audio_unmute_resync = 1;
}
sdl_audio_pump(false);
}
/* PS1 digital pad button bits (active-low: 0=pressed, 1=released).
* Bit 0 = SELECT, Bit 1 = L3, Bit 2 = R3, 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.
* L3/R3 (stick clicks) exist on a DualShock only; the wire reports them like
* Beetle's dualshock.cpp does — straight from the button word, no mode mask. */
#define PAD_SELECT (1 << 0)
#define PAD_L3 (1 << 1)
#define PAD_R3 (1 << 2)
#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, 16> 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_L3, "l3", {} },
{ PAD_R3, "r3", {} },
{ 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);
}
/* Resolve the durable additive overlay-capture store. Explicit process env is
* authoritative. For local development, parse only the two known capture keys
* from a gitignored root .psxrecomp.env (or .env); do not import unrelated
* variables/secrets into the game process. Relative paths are root-relative. */
static std::filesystem::path resolve_overlay_capture_path(
const std::filesystem::path& project_root,
const std::filesystem::path& exe_dir,
const std::string& game_id) {
std::string direct;
std::string store_root;
std::filesystem::path value_base = project_root;
if (const char* e = std::getenv("PSX_OVERLAY_CAPTURES")) direct = e;
if (const char* e = std::getenv("PSX_OVERLAY_CAPTURE_ROOT")) store_root = e;
if (direct.empty() && store_root.empty()) {
std::vector<std::filesystem::path> configs = {
project_root / ".psxrecomp.env", project_root / ".env"
};
/* Framework developers commonly build a game whose config root is a
* sibling repository. Walk upward from the executable as well so the
* framework worktree's private config remains the durable authority. */
std::filesystem::path cur = exe_dir;
for (int i = 0; i < 8; ++i) {
configs.push_back(cur / ".psxrecomp.env");
const auto parent = cur.parent_path();
if (parent == cur) break;
cur = parent;
}
for (const auto& config : configs) {
std::ifstream in(config);
if (!in) continue;
std::string line;
while (std::getline(in, line)) {
line = trim_copy(line);
if (line.empty() || line[0] == '#') continue;
const size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = trim_copy(line.substr(0, eq));
std::string value = trim_copy(line.substr(eq + 1));
if (value.size() >= 2 &&
((value.front() == '"' && value.back() == '"') ||
(value.front() == '\'' && value.back() == '\'')))
value = value.substr(1, value.size() - 2);
if (key == "PSX_OVERLAY_CAPTURES") {
direct = value;
value_base = config.parent_path();
} else if (key == "PSX_OVERLAY_CAPTURE_ROOT") {
store_root = value;
value_base = config.parent_path();
}
}
if (!direct.empty() || !store_root.empty()) break;
}
}
auto root_relative = [&](const std::string& raw) {
std::filesystem::path p(raw);
return p.is_absolute() ? p : value_base / p;
};
std::filesystem::path result;
if (!direct.empty()) result = root_relative(direct);
else if (!store_root.empty())
result = root_relative(store_root) / game_id / "overlay_captures.json";
else result = exe_dir / "overlay_captures.json";
std::error_code ec;
std::filesystem::create_directories(result.parent_path(), ec);
if (ec) {
std::fprintf(stderr, "psxrecomp: cannot create overlay capture store %s: %s\n",
result.parent_path().string().c_str(), ec.message().c_str());
return exe_dir / "overlay_captures.json";
}
return result.lexically_normal();
}
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("l3", "leftstick");
set_sources("r3", "rightstick");
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,leftstick,rightstick (stick clicks -> L3/R3),\n"
"; 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"
"l3 = leftstick\n"
"r3 = rightstick\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();
}
/* Configurable KEYBOARD keybinds (keybinds.ini, next to the exe) — separate
* from input.ini's gamepad map. Loads the user's map (or writes defaults on
* first run). The launcher may have just edited+saved this file; we re-read
* it here so the runtime always reflects the current bindings. */
psx_keybinds_init(argv0);
}
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)");
}
}
/* The pad type a mode reports before any input has been sampled (boot /
* hotplug). analog pins DualShock; digital and hybrid both start as a digital
* pad (hybrid only flips to DualShock once the player nudges the stick). */
static int pad_mode_boot_analog(int mode) {
/* Hybrid boots ANALOG-on (matches a DualShock powered up with the analog LED
* lit): the seamless auto-switch then drops to digital only if the player
* reaches for the d-pad. Pinned-analog also boots analog; digital boots off. */
return (mode == PSXRecompV4::PAD_MODE_ANALOG ||
mode == PSXRecompV4::PAD_MODE_HYBRID) ? 1 : 0;
}
/* 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).
* While delay-sync netplay is active, SIO connection/type are owned by
* psx_netplay (session slots stay plugged); only refresh host SDL handles. */
static void refresh_player_devices(void) {
const int netplay = psx_netplay_active();
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]);
if (netplay) continue;
sio_set_pad_connected(s, p.kind != 0 ? 1 : 0);
sio_set_pad_analog(s, pad_mode_boot_analog(p.mode), 0x80, 0x80, 0x80, 0x80);
/* DIGITAL mode == a plain digital controller that ignores the DualShock
* config-mode commands (real SCPH-1080 behaviour); ANALOG/HYBRID == a
* config-capable DualShock. A digital pad that wrongly answered 0x43
* sent Tomba 2's pad driver down the config path -> phantom 0x00 reads. */
sio_set_pad_config_capable(s, p.mode != PSXRecompV4::PAD_MODE_DIGITAL);
}
}
/* 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, int mode) {
p.mode = mode;
/* Hybrid starts in ANALOG (analog LED on); the auto-switch drops to digital
* only when the player uses the d-pad. */
p.hybrid_analog = true;
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 if (d == "auto" || d == "gamepad" || d == "controller") {
/* First available SDL game controller (guid empty -> open_player falls
* back to the first connected pad). Lets a user default to "my
* controller" without pinning a specific GUID. */
p.kind = 2; /* p.guid already cleared above */
}
else {
p.kind = 2;
std::snprintf(p.guid, sizeof(p.guid), "%s", trim_copy(dev).c_str());
}
}
/* Stick→button (Digital D-Pad fold): per-axis deadzone. A strong left/right
* push with a few units of Y drift must NOT fire Up/Down (jump/crouch). Pure
* radial+sign did that — once mag cleared the circle, every non-zero axis
* component became a D-Pad bit. Analog stick bytes still use radial rescale
* in axes_to_pad_pair; this path is digital buttons only. Triggers share the
* same per-axis threshold. */
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:
case ControllerSource::Kind::AxisNegative: {
const int16_t v = SDL_GameControllerGetAxis(h, (SDL_GameControllerAxis)source.id);
if (source.kind == ControllerSource::Kind::AxisPositive)
return v > controller_deadzone;
return v < -controller_deadzone;
}
case ControllerSource::Kind::None:
default:
return false;
}
}
/* Keyboard -> PSX button word for `player` (1 or 2), via the configurable
* keybinds.ini map (psx_keybinds). Defaults reproduce the old hardcoded layout
* (arrows=d-pad, X/S/Z/A=Cross/Circle/Square/Triangle, Q/W/E/R=L1/R1/L2/R2,
* Return=Start, RShift=Select) plus T/Y=L3/R3 stick clicks. */
static uint16_t pad_from_keyboard(int player) {
const Uint8* keys = SDL_GetKeyboardState(NULL);
return psx_keybinds_pad_word(keys, player);
}
/* A left/right ANALOG-STICK axis source (LEFTX/LEFTY/RIGHTX/RIGHTY), as opposed
* to a trigger axis (L2/R2) or a button. The default map folds the left stick
* onto the D-pad bits (up=dpup,lefty- … right=dpright,leftx+) so the stick works
* as a D-pad for DIGITAL games. In ANALOG mode that fold is WRONG: the stick
* already drives the analog axes, and on a real DualShock the stick and D-pad
* are independent — so a dual-analog game that uses the D-pad as its own control
* (e.g. Ape Escape's camera rotate) would see phantom D-pad presses from every
* stick movement, and constant rotation from centre drift. controller_pad_buttons
* suppresses these sources when the pad presents as analog. Trigger axes and
* button sources are never suppressed. */
static bool source_is_stick_axis(const ControllerSource& s) {
if (s.kind != ControllerSource::Kind::AxisPositive &&
s.kind != ControllerSource::Kind::AxisNegative) return false;
return s.id == SDL_CONTROLLER_AXIS_LEFTX || s.id == SDL_CONTROLLER_AXIS_LEFTY ||
s.id == SDL_CONTROLLER_AXIS_RIGHTX || s.id == SDL_CONTROLLER_AXIS_RIGHTY;
}
/* Build the active-low PSX button word from a controller's input.ini map. When
* suppress_stick_axes is set (the pad is presenting as analog this frame), the
* left/right analog-stick axes do NOT contribute button bits — see
* source_is_stick_axis above. Digital mode passes false, so the stick still
* folds onto the D-pad (its only outlet there). */
static uint16_t controller_pad_buttons(SDL_GameController* h, bool suppress_stick_axes) {
uint16_t buttons = 0xFFFF; /* all released */
if (!h) return buttons;
for (const auto& entry : controller_map) {
for (const auto& source : entry.sources) {
if (suppress_stick_axes && source_is_stick_axis(source)) continue;
if (controller_source_pressed_h(h, source)) {
buttons &= (uint16_t)~entry.bit;
break;
}
}
}
return buttons;
}
/* Radial deadzone: process a stick's X and Y together so the dead region is a
* small CIRCLE, not a per-axis square. Travel within controller_deadzone reads
* centred (0x80/0x80); beyond it the vector MAGNITUDE is rescaled to the full
* range along the stick's true direction, so diagonals are preserved (a
* per-axis deadzone snaps diagonals toward the cardinals and feels notchy —
* bad for directional analog like Ape Escape's net swing). The magnitude is
* capped at 32767 before rescale so a full-diagonal push (raw mag ~46341) maps
* to ~0x9E/0x9E per axis — the circular gate a real DualShock stick reports,
* not 0xFF/0xFF. At dz==0 it reduces to a plain magnitude-preserving map. */
static void axes_to_pad_pair(int16_t vx, int16_t vy, uint8_t* obx, uint8_t* oby) {
const double dz = controller_deadzone;
double x = vx, y = vy;
double mag = std::sqrt(x * x + y * y); /* 0 .. ~46341 */
if (mag <= dz || mag <= 0.0) { *obx = 0x80; *oby = 0x80; return; }
double capped = mag > 32767.0 ? 32767.0 : mag;
double range = 32767.0 - dz;
if (range < 1.0) range = 1.0;
double newmag = (capped - dz) * 32767.0 / range; /* 0 .. 32767 */
double scale = newmag / mag; /* along true direction */
int sx = (int)std::lround(x * scale);
int sy = (int)std::lround(y * scale);
if (sx > 32767) sx = 32767; else if (sx < -32768) sx = -32768;
if (sy > 32767) sy = 32767; else if (sy < -32768) sy = -32768;
int bx = (sx + 32768) >> 8;
int by = (sy + 32768) >> 8;
*obx = (uint8_t)(bx < 0 ? 0 : (bx > 255 ? 255 : bx));
*oby = (uint8_t)(by < 0 ? 0 : (by > 255 ? 255 : by));
}
/* Buttons for a player's selected device (0xFFFF = none pressed). `player` is
* 1 or 2 — selects which keybinds.ini section drives a keyboard port. */
static uint16_t pad_buttons_for(const PlayerInput& p, int player, bool suppress_stick_axes) {
if (p.kind == 1) return pad_from_keyboard(player);
if (p.kind == 2) return controller_pad_buttons(p.handle, suppress_stick_axes);
return 0xFFFF;
}
/* Analog stick bytes (lx,ly,rx,ry) for a player; centred if no live source.
*
* The host left stick maps to the LEFT analog axes (variable). When fold_dpad
* is set, the physical D-pad (and, for a keyboard player, the arrow keys) is
* ALSO folded onto the left axes at full deflection, so an analog-mode game
* whose movement reads only the stick magnitude (e.g. Tomba) still responds to
* the D-pad/keyboard — the faithful "seamless" behaviour: the stick gives
* variable speed, the D-pad gives full speed, both at once. Only the PHYSICAL
* D-pad is folded in (not the stick->d-pad button mapping), so the stick keeps
* its true variable magnitude. fold_dpad is false in HYBRID mode (there the
* D-pad instead flips the pad to digital, so the game runs its own d-pad path)
* and true in pinned-ANALOG mode. The keyboard branch always folds the arrows
* — they are that player's only stick source. */
static void pad_sticks_for(const PlayerInput& p, int player, uint8_t out[4], bool fold_dpad) {
out[0] = out[1] = out[2] = out[3] = 0x80;
if (p.kind == 1) {
/* Keyboard analog: the configurable left/right stick-direction binds
* (default = arrow keys on the LEFT stick; RIGHT stick unbound), so the
* old keyboard analog behaviour is preserved unless the user rebinds. */
const Uint8* keys = SDL_GetKeyboardState(NULL);
psx_keybinds_sticks(keys, player, out);
return;
}
if (p.kind == 2 && p.handle) {
axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTX),
SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY),
&out[0], &out[1]);
axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTX),
SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTY),
&out[2], &out[3]);
if (fold_dpad) {
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT)) out[0] = 0x00;
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) out[0] = 0xFF;
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_UP)) out[1] = 0x00;
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_DOWN)) out[1] = 0xFF;
}
}
}
/* HYBRID-mode auto-switch detectors (mirror the DualShock analog LED toggling).
* hybrid_stick_active: the LEFT stick is outside the radial deadzone — the
* player has reached for analog. hybrid_dpad_active: any D-pad direction (or,
* for the keyboard, an arrow key) is held — the player wants classic digital.
* The keyboard has no analog stick, so a keyboard player stays digital. */
static bool hybrid_stick_active(const PlayerInput& p) {
if (p.kind != 2 || !p.handle) return false;
const double lx = SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTX);
const double ly = SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY);
return std::sqrt(lx * lx + ly * ly) > (double)controller_deadzone;
}
static bool hybrid_dpad_active(const PlayerInput& p, int player, bool kb_always) {
if (p.kind == 2 && p.handle) {
if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT) ||
SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) ||
SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_UP) ||
SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_DOWN))
return true;
}
if (p.kind == 1 || kb_always) {
const Uint8* keys = SDL_GetKeyboardState(NULL);
if (psx_keybinds_dpad_active(keys, player)) return true;
}
return false;
}
/* Sample each player's live device state into the matching SIO pad slot.
* override >= 0 forces port-1 buttons (debug-server input injection). Called
* once at cycle start (covers the turbo/FMV-skip paths that present nothing)
* and, when g_low_latency_input is set, AGAIN after the pacer wait so the next
* CPU frame reads near-fresh input instead of input ~one frame stale.
*
* HYBRID mode auto-switches DualShock<->digital from the most-recent input
* (stick -> analog, D-pad -> digital) so the game runs its own analog or
* digital input path exactly as on hardware (mirrors the inline block this
* helper was extracted from for the low-latency re-sample). */
/* Dev input mode: while enabled, Player 1 is driven by the keyboard AND EVERY
* connected game controller, all merged together (active-low AND) onto the P1 pad
* word — so a tester can navigate P1 from whatever is plugged in (keyboard, the
* launcher-assigned pad, or any other controller) with no reconfiguration. The P1
* pad TYPE is left unchanged: a launcher-assigned analog DualShock still presents
* as analog (so the game's analog input path / SIO handshake cadence is preserved
* exactly), and merged sources only contribute button/stick STATE, never a type
* downgrade. Controlled by PSX_DEV_INPUT (default ON for the dev workflow); set
* PSX_DEV_INPUT=0 to restore strict single-device-per-port routing. */
static bool dev_any_input_enabled() {
static int cached = -1;
if (cached < 0) {
const char* e = std::getenv("PSX_DEV_INPUT");
cached = (e && (e[0] == '0' || e[0] == 'n' || e[0] == 'N' || e[0] == 'f' || e[0] == 'F')) ? 0 : 1;
}
return cached != 0;
}
/* Merge (active-low AND) the button words of every connected game controller.
* Handles are resolved from SDL's already-open set when possible and opened once
* on first sight otherwise (kept open for the process lifetime; if a slot later
* closes a shared device this self-heals by reopening next frame). This does NOT
* disturb per-slot routing — SDL returns the same handle for an already-open
* device, so reads are shared and harmless. */
static uint16_t dev_all_controllers_buttons(bool suppress_stick_axes) {
uint16_t btn = 0xFFFF;
const int n = SDL_NumJoysticks();
for (int i = 0; i < n; i++) {
if (!SDL_IsGameController(i)) continue;
SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i);
SDL_GameController* h = SDL_GameControllerFromInstanceID(inst);
if (!h) h = SDL_GameControllerOpen(i); /* open once; SDL keeps it */
if (h) btn &= controller_pad_buttons(h, suppress_stick_axes);
}
return btn;
}
/* Fold the first live (non-neutral) left/right stick from ANY connected
* controller into st[] (lx,ly,rx,ry). Lets an analog-mode P1 steer from
* whatever pad is plugged in, not only the launcher-assigned one. Only axes
* that read past the deadzone override; a neutral pad leaves st[] untouched. */
static void dev_any_controller_sticks(uint8_t st[4]) {
const int n = SDL_NumJoysticks();
for (int i = 0; i < n; i++) {
if (!SDL_IsGameController(i)) continue;
SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i);
SDL_GameController* h = SDL_GameControllerFromInstanceID(inst);
if (!h) h = SDL_GameControllerOpen(i);
if (!h) continue;
uint8_t lx, ly, rx, ry;
axes_to_pad_pair(SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTX),
SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTY), &lx, &ly);
axes_to_pad_pair(SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTX),
SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTY), &rx, &ry);
if (lx != 0x80 || ly != 0x80) { st[0] = lx; st[1] = ly; }
if (rx != 0x80 || ry != 0x80) { st[2] = rx; st[3] = ry; }
}
}
/* Debug-server input injection: drive the SAME pad model as a physical
* device. The old path set only the button word and returned, which left the
* pad type/sticks at whatever the real device last was — a hybrid P1 stayed
* analog, so injected d-pad bits never moved games that read the stick in
* analog mode (menus reacted, walking didn't). Injected d-pad reads as d-pad
* activity (hybrid drops to digital), an injected stick override reads as
* stick activity (hybrid rises to analog), and the resolved type goes through
* the same coherent request channel as real sampling — never slammed mid-
* handshake (the v0.5.0 phantom-input lesson). */
static void apply_input_override_to_sio(int override_word) {
PlayerInput& p = g_players[0];
const uint16_t w = (uint16_t)override_word;
sio_set_pad_state_slot(0, w);
uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 };
int axes = 0;
#ifndef PSX_NO_DEBUG_TOOLS
axes = debug_server_get_axis_override(st);
#endif
const bool stick_live = axes != 0 && (st[0] != 0x80 || st[1] != 0x80 ||
st[2] != 0x80 || st[3] != 0x80);
const bool dpad_live = ((uint16_t)~w & 0x00F0u) != 0; /* up/right/down/left */
int mode;
if (p.kind != 0) mode = p.mode;
else if (dev_any_input_enabled()) mode = (int)PSXRecompV4::PAD_MODE_HYBRID;
else mode = (int)PSXRecompV4::PAD_MODE_DIGITAL;
int eff_analog;
if (mode == (int)PSXRecompV4::PAD_MODE_DIGITAL) {
eff_analog = 0;
} else if (mode == (int)PSXRecompV4::PAD_MODE_ANALOG) {
eff_analog = 1;
} else { /* HYBRID: most-recent input source wins, like the real sampler */
if (stick_live) p.hybrid_analog = true;
else if (dpad_live) p.hybrid_analog = false;
eff_analog = p.hybrid_analog ? 1 : 0;
}
if (!eff_analog) { st[0] = st[1] = st[2] = st[3] = 0x80; }
sio_set_pad_sticks(0, st[0], st[1], st[2], st[3]);
sio_request_pad_type(0, eff_analog);
}
/* Capture one SIO slot's host pad into a netplay/local blob. Returns 1 if a
* device (or dev-any P1) is present; 0 leaves *out as released/disconnected. */
static int capture_pad_slot(int s, PsxNetPad* out) {
if (!out) return 0;
out->buttons = 0xFFFFu;
out->lx = out->ly = out->rx = out->ry = 0x80u;
out->analog = 0;
out->connected = 0;
PlayerInput& p = g_players[s];
const int player = s + 1; /* keybinds.ini section (1|2) */
/* Dev input: P1 is driven by the keyboard AND every connected controller,
* so a tester can navigate from whatever is plugged in (P2 keeps strict
* per-port routing). */
const bool dev_here = (dev_any_input_enabled() && s == 0);
if (p.kind == 0 && !dev_here) return 0; /* no device in this port */
/* Resolve the pad type this frame FIRST — the effective analog/digital
* state gates how the left stick is read for BOTH the button word and the
* analog axes below. An assigned device keeps its configured mode (a
* launcher-selected analog DualShock stays analog, so its input path / SIO
* handshake cadence is preserved exactly). A P1 with no assigned device but
* dev-any-input on presents as HYBRID — boots analog like a DualShock and
* auto-drops to digital on the d-pad — so any plugged controller and the
* keyboard both navigate. The hybrid latch reads raw device state, so it is
* safe to resolve here before the button word is built. */
int mode;
if (p.kind != 0) mode = p.mode;
else if (dev_here) mode = (int)PSXRecompV4::PAD_MODE_HYBRID;
else mode = (int)PSXRecompV4::PAD_MODE_DIGITAL;
int eff_analog;
if (mode == PSXRecompV4::PAD_MODE_DIGITAL) {
eff_analog = 0;
} else if (mode == PSXRecompV4::PAD_MODE_ANALOG) {
eff_analog = 1;
} else { /* HYBRID */
if (hybrid_stick_active(p)) p.hybrid_analog = true;
else if (hybrid_dpad_active(p, player, dev_here)) p.hybrid_analog = false;
eff_analog = p.hybrid_analog ? 1 : 0;
}
/* Buttons: merge the assigned device with the keyboard (PSX pad word is
* active-low, so AND combines "pressed on either source"). In dev mode P1
* also folds in the keyboard binds and EVERY connected controller. When the
* pad presents as ANALOG this frame (eff_analog), the left/right analog-
* stick axes are suppressed as button sources so the stick drives ONLY the
* analog axes — the D-pad bits then come solely from the physical D-pad,
* exactly as on a real DualShock. This is what stops a dual-analog game's
* D-pad control (Ape Escape's camera rotate) from being spun by stick
* movement or centre drift. Digital mode keeps the stick->D-pad fold. */
const bool suppress_stick = (eff_analog != 0);
uint16_t btn = (p.kind != 0) ? pad_buttons_for(p, player, suppress_stick)
: (uint16_t)0xFFFF;
if (dev_here) {
btn &= pad_from_keyboard(1); /* keyboard P1 binds */
btn &= dev_all_controllers_buttons(suppress_stick); /* any plugged-in pad */
}
/* Analog axes. Pinned-ANALOG folds the physical D-pad onto the left axes
* (fold_dpad) so the D-pad still moves stick-only games; HYBRID feeds the
* raw stick when currently analog (no fold — the D-pad drives its own
* digital path there); DIGITAL leaves the axes centred. */
uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 };
if (mode == PSXRecompV4::PAD_MODE_ANALOG) {
pad_sticks_for(p, player, st, /*fold_dpad=*/true);
} else if (eff_analog) { /* HYBRID, currently presenting analog */
pad_sticks_for(p, player, st, /*fold_dpad=*/false);
}
/* Dev mode: fold the keyboard's stick binds AND any connected controller's
* sticks onto the analog stick, so an analog-mode P1 steers from whatever
* is plugged in (P1 binds). */
if (dev_here && eff_analog) {
const Uint8* keys = SDL_GetKeyboardState(NULL);
psx_keybinds_sticks(keys, 1, st);
dev_any_controller_sticks(st);
}
out->buttons = btn;
out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3];
out->analog = eff_analog ? 1u : 0u;
out->connected = 1;
return 1;
}
/* Netplay-only capture: assigned PlayerInput for this slot only. Never merges
* keyboard-all / all-controllers (dev_any_input). Same pad-mode / stick rules
* as capture_pad_slot otherwise. */
static int capture_pad_slot_exclusive(int s, PsxNetPad* out) {
if (!out) return 0;
out->buttons = 0xFFFFu;
out->lx = out->ly = out->rx = out->ry = 0x80u;
out->analog = 0;
out->connected = 0;
PlayerInput& p = g_players[s];
const int player = s + 1; /* keybinds.ini section (1|2) */
const bool dev_here = false;
if (p.kind == 0) return 0; /* no device in this port */
int mode = p.mode;
int eff_analog;
if (mode == PSXRecompV4::PAD_MODE_DIGITAL) {
eff_analog = 0;
} else if (mode == PSXRecompV4::PAD_MODE_ANALOG) {
eff_analog = 1;
} else { /* HYBRID */
if (hybrid_stick_active(p)) p.hybrid_analog = true;
else if (hybrid_dpad_active(p, player, dev_here)) p.hybrid_analog = false;
eff_analog = p.hybrid_analog ? 1 : 0;
}
const bool suppress_stick = (eff_analog != 0);
uint16_t btn = pad_buttons_for(p, player, suppress_stick);
uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 };
if (mode == PSXRecompV4::PAD_MODE_ANALOG) {
pad_sticks_for(p, player, st, /*fold_dpad=*/true);
} else if (eff_analog) { /* HYBRID, currently presenting analog */
pad_sticks_for(p, player, st, /*fold_dpad=*/false);
}
out->buttons = btn;
out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3];
out->analog = eff_analog ? 1u : 0u;
out->connected = 1;
return 1;
}
static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) {
sio_set_pad_state_slot(s, pad.buttons);
sio_set_pad_sticks(s, pad.lx, pad.ly, pad.rx, pad.ry);
sio_request_pad_type(s, pad.analog ? 1 : 0);
}
/* Local human pad for delay-sync: sample the host PlayerInput selected for
* this peer (see --net-input-player / auto), then recomp-net maps that blob
* onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive
* capture — no keyboard-all / all-controllers merge — so peers hash-agree. */
static void capture_local_human_pad(PsxNetPad* out) {
int idx = psx_netplay_input_player();
if (idx < 0 || idx > 1) idx = 0;
if (!capture_pad_slot_exclusive(idx, out)) {
/* Fallback: if auto picked empty P2, try P1 (two-machine guest). */
if (idx != 0 && capture_pad_slot_exclusive(0, out)) {
out->connected = 1;
psx_netplay_normalize_pad(out);
return;
}
out->buttons = 0xFFFFu;
out->lx = out->ly = out->rx = out->ry = 0x80u;
out->analog = 1;
}
out->connected = 1;
psx_netplay_normalize_pad(out);
}
/* Build a netplay pad blob from a debug-server override without writing SIO. */
static void capture_override_pad(int override_word, PsxNetPad* out) {
PlayerInput& p = g_players[0];
const uint16_t w = (uint16_t)override_word;
uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 };
int axes = 0;
#ifndef PSX_NO_DEBUG_TOOLS
axes = debug_server_get_axis_override(st);
#endif
const bool stick_live = axes != 0 && (st[0] != 0x80 || st[1] != 0x80 ||
st[2] != 0x80 || st[3] != 0x80);
const bool dpad_live = ((uint16_t)~w & 0x00F0u) != 0;
int mode;
if (p.kind != 0) mode = p.mode;
else if (dev_any_input_enabled()) mode = (int)PSXRecompV4::PAD_MODE_HYBRID;
else mode = (int)PSXRecompV4::PAD_MODE_DIGITAL;
int eff_analog;
if (mode == (int)PSXRecompV4::PAD_MODE_DIGITAL) {
eff_analog = 0;
} else if (mode == (int)PSXRecompV4::PAD_MODE_ANALOG) {
eff_analog = 1;
} else {
if (stick_live) p.hybrid_analog = true;
else if (dpad_live) p.hybrid_analog = false;
eff_analog = p.hybrid_analog ? 1 : 0;
}
if (!eff_analog) { st[0] = st[1] = st[2] = st[3] = 0x80; }
out->buttons = w;
out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3];
out->analog = eff_analog ? 1u : 0u;
out->connected = 1;
}
/* Opt-in netplay barrier split: guest quantum vs admit wait (PSX_NETPLAY_TIMING=1).
* Printed on the existing [FPS] line — no separate log files. */
static int s_np_timing_enabled = -1;
static uint64_t s_np_admit_ticks = 0;
static uint64_t s_np_guest_ticks = 0;
static uint64_t s_np_last_admit_end = 0;
static uint64_t s_np_timing_frames = 0;
static int netplay_timing_on(void) {
if (s_np_timing_enabled < 0) {
const char *e = std::getenv("PSX_NETPLAY_TIMING");
s_np_timing_enabled = (e && e[0] && e[0] != '0') ? 1 : 0;
}
return s_np_timing_enabled;
}
/* Stage local pad + poll admit until published. Parks the guest fiber when
* called from the vblank callback (or before scheduler entry). Latches one
* sample per sim tick; stalls on INPUT_CONFIRM desync. */
static void netplay_barrier_admit(int override) {
if (!psx_netplay_active()) return;
static int desync_logged = 0;
const uint64_t admit_t0 =
netplay_timing_on() ? SDL_GetPerformanceCounter() : 0;
/* Guest quantum = time since previous admit returned (fiber ran). */
if (admit_t0 && s_np_last_admit_end && admit_t0 >= s_np_last_admit_end) {
s_np_guest_ticks += admit_t0 - s_np_last_admit_end;
s_np_timing_frames++;
}
for (;;) {
uint32_t dt = 0, lh = 0, rh = 0;
/* Load apply/ready suppresses INPUT (and can sit silent for seconds on
* a hash-match .pst). timeout=0 still honors BYE (peer_gone) but does
* not treat rx silence as disconnect — that was kicking both peers to
* the lobby mid-load. */
if (psx_netplay_peer_disconnected(
psx_netplay_in_load_barrier() ? 0u : 1500u)) {
netplay_soft_exit("netplay_peer_disconnect");
if (psx_return_to_lobby_requested()) return;
}
psx_lobby_pump();
if (psx_netplay_input_desync(&dt, &lh, &rh)) {
if (!desync_logged) {
std::printf("psxrecomp: netplay INPUT desync tick=%u local=%08x remote=%08x — stalled\n",
(unsigned)dt, (unsigned)lh, (unsigned)rh);
std::fflush(stdout);
desync_logged = 1;
}
SDL_Delay(16);
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_poll();
#endif
continue;
}
if (psx_netplay_needs_local_sample()) {
PsxNetPad local{};
if (override >= 0 && !g_headless) {
capture_override_pad(override, &local);
} else if (g_headless) {
local.buttons = 0xFFFFu;
local.lx = local.ly = local.rx = local.ry = 0x80u;
local.analog = 1;
local.connected = 1;
} else {
capture_local_human_pad(&local);
}
psx_netplay_stage_local(&local);
}
if (psx_netplay_poll_admit()) {
desync_logged = 0;
if (admit_t0) {
const uint64_t t1 = SDL_GetPerformanceCounter();
if (t1 >= admit_t0) s_np_admit_ticks += t1 - admit_t0;
s_np_last_admit_end = t1;
}
return;
}
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_poll();
#endif
if (!g_headless) {
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
if (ev.type == SDL_QUIT) {
netplay_soft_exit("sdl_window_close");
if (psx_return_to_lobby_requested()) return;
}
if (ev.type == SDL_KEYDOWN && ev.key.keysym.sym == SDLK_ESCAPE) {
netplay_soft_exit("netplay_barrier_escape");
if (psx_return_to_lobby_requested()) return;
}
if (ev.type == SDL_CONTROLLERDEVICEADDED ||
ev.type == SDL_CONTROLLERDEVICEREMOVED) {
refresh_player_devices();
}
}
SDL_GameControllerUpdate();
}
if (psx_return_to_lobby_requested()) return;
/* Wake on peer UDP (or 1ms timeout). SDL_Delay(1) under dual FMV load
* often stretches multi-ms and cut MotK netplay intro ~59→~36; Delay(0)
* busy-spins and can starve the peer (tick-0 hang). */
psx_netplay_wait_recv(1);
/* Admit barriers (save/load sync) can last seconds without guest cycles. */
starvation_watchdog_heartbeat();
}
}
static void sample_pad_into_sio(int override) {
if (override >= 0) {
apply_input_override_to_sio(override);
return;
}
for (int s = 0; s < 2; s++) {
PsxNetPad pad;
if (!capture_pad_slot(s, &pad)) continue; /* no device in this port */
/* Push sticks every frame; request the pad type (digital/analog) through
* the coherent channel so a hybrid stick<->d-pad flip is applied only at
* an idle, non-config bus boundary (never mid-poll / mid-handshake). This
* is the fix for the v0.5.0 phantom-input regression: slamming the type
* each frame raced Tomba's DualShock config handshake -> garbage button
* reads. eff_analog still reflects this frame's mode (digital / analog /
* hybrid auto-switch). */
apply_pad_slot_to_sio(s, pad);
}
}
static void sample_headless_pad_into_sio(int override) {
if (override >= 0) {
apply_input_override_to_sio(override);
return;
}
#ifdef PSX_COSIM
/* Input-driven cosim (EXPERIMENTAL): the coordinator sets a HELD pad state via the
* `setpad` TCP command; the headless sampler applies it every frame so the same
* button input is fed to BOTH lockstep instances at the same guest cycle. If the
* two instances desync under input, this path is proven nondeterministic and gets
* stubbed out (see cosim.c). Default 0xFFFF = all released (PSX pad is active-low). */
{ extern volatile int g_cosim_pad_hold[2];
sio_set_pad_state_slot(0, (uint16_t)g_cosim_pad_hold[0]);
sio_set_pad_state_slot(1, (uint16_t)g_cosim_pad_hold[1]);
return; }
#endif
sio_set_pad_state_slot(0, 0xFFFFu);
sio_set_pad_state_slot(1, 0xFFFFu);
}
/* 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.
*
* The wall-clock pacer target; nudged to the host display refresh at
* window-creation time (sync-to-host-refresh) so the pacer and SDL PRESENTVSYNC
* do not fight — a fixed 59.94 pacer against a 60.00 Hz panel makes rendered
* frames land on an uneven vblank count (a 2/3/1 beat) that reads as
* moving-object judder/flicker. See g_frame_period_ms. */
static constexpr double PSX_FRAME_PERIOD_MS = 1000.0 / 59.94;
/* Live pacer period (ms). Defaults to the PSX rate; set to the host refresh
* period when the panel is within ~2% of 60 Hz so 30fps content pads evenly to
* two host refreshes. Left at the PSX rate on non-~60Hz panels to avoid running
* the sim at the wrong speed. */
static double g_frame_period_ms = PSX_FRAME_PERIOD_MS;
/* ── Host-stack-usage profile (RECURSION_BUG.md §17) ──────────────────────────
* The decisive instrument for the long-run freeze. The guest call graph mirrors
* onto the 1MB guest fiber stack (traps.c), and the native-stack guard trips at
* ~768KB used. The open question is the SHAPE of host-stack usage over frames:
* - LINEAR climb from gameplay start => a per-frame interp<->compiled boundary
* leak: every game tick leaks one un-unwound host call chain (~13 frames),
* overflowing the fiber after ~50k frames. 50k is a CAPACITY limit, not a
* trigger. (Model A — predicted.)
* - FLAT, then a cliff at one frame => a within-one-frame runaway re-entry at
* a real ~frame-50k trigger. (Model W — the old §15 premise.)
* (TEB StackBase - rsp), sampled on the guest fiber each vblank, IS the leaked
* depth. Sampled in sdl_vblank_present — a gpu_vblank_tick callback off
* psx_check_interrupts, i.e. ON the guest fiber, every vblank, in both builds.
* Decimated 1-per-STRIDE frames so the ring spans the whole run; "now" carries
* the exact latest sample. ALWAYS-ON: read live via the `stack_profile` TCP
* command while the game is still responsive (no need to reach the overflow),
* and dumped in the crash report. Query the ring; never arm-and-capture. */
#if defined(_WIN32)
#include <intrin.h> /* __readgsqword — fiber TEB StackBase */
static size_t host_stack_used(void) {
char probe;
uintptr_t base = (uintptr_t)__readgsqword(0x08); /* TEB StackBase (high) */
uintptr_t sp = (uintptr_t)&probe;
return (base > sp) ? (size_t)(base - sp) : 0;
}
#else
static size_t host_stack_used(void) { return 0; }
#endif
#define STACK_PROFILE_CAP 512u
#define STACK_PROFILE_STRIDE 128u /* 512 * 128 = 65536 frames of coverage */
typedef struct { uint32_t frame; uint32_t used_kb; } StackSample;
static StackSample g_stack_prof[STACK_PROFILE_CAP];
static uint32_t g_stack_prof_seq = 0;
static uint32_t g_stack_used_now_kb = 0;
static uint32_t g_stack_frame_now = 0;
static uint32_t g_stack_used_max_kb = 0;
static void stack_profile_sample(void) {
extern uint64_t s_frame_count;
uint32_t f = (uint32_t)s_frame_count;
uint32_t kb = (uint32_t)(host_stack_used() >> 10);
g_stack_frame_now = f;
g_stack_used_now_kb = kb;
if (kb > g_stack_used_max_kb) g_stack_used_max_kb = kb;
if ((f % STACK_PROFILE_STRIDE) == 0) {
StackSample *e = &g_stack_prof[g_stack_prof_seq++ & (STACK_PROFILE_CAP - 1u)];
e->frame = f; e->used_kb = kb;
}
}
/* JSON dump of the profile (callable from C: crash report + TCP command). */
extern "C" int stack_profile_json(char *out, int cap) {
uint32_t total = g_stack_prof_seq;
uint32_t avail = total < STACK_PROFILE_CAP ? total : STACK_PROFILE_CAP;
uint32_t start = total - avail;
int n = snprintf(out, cap,
"{\"now\":{\"f\":%u,\"kb\":%u},\"max_kb\":%u,\"stride\":%u,\"count\":%u,\"samples\":[",
g_stack_frame_now, g_stack_used_now_kb, g_stack_used_max_kb,
STACK_PROFILE_STRIDE, avail);
for (uint32_t i = 0; i < avail && n < cap - 48; i++) {
StackSample *e = &g_stack_prof[(start + i) & (STACK_PROFILE_CAP - 1u)];
n += snprintf(out + n, cap - n, "%s{\"f\":%u,\"kb\":%u}",
i ? "," : "", e->frame, e->used_kb);
}
n += snprintf(out + n, cap - n, "]}");
return n;
}
/* ---- Present-classification ring (see present_ring.h) -----------------
* Always-on record of how each present was classified, for every backend.
* A native-wide present that silently falls back to the canonical width is
* indistinguishable on screen from a deliberate stretch, so the decision
* has to live in a queryable ring rather than be re-derived after the fact. */
#define PRES_RING_CAP 4096u /* power of two; ~68 s at 60 Hz */
static PresRingEntry s_pres_ring[PRES_RING_CAP];
static uint64_t s_pres_ring_total = 0;
extern "C" uint64_t present_ring_total(void) { return s_pres_ring_total; }
extern "C" int present_ring_get(uint64_t seq, PresRingEntry* out) {
if (seq >= s_pres_ring_total) return 0;
if (s_pres_ring_total - seq > PRES_RING_CAP) return 0;
*out = s_pres_ring[seq & (PRES_RING_CAP - 1u)];
return 1;
}
/* Commit this present's classification. Returns the stored entry so the
* software wide-fallback (the only post-decision mutation) can amend it. */
static PresRingEntry* present_ring_commit(uint8_t path, uint16_t disp_w,
uint16_t disp_h, uint16_t present_w) {
GpuWsDebug ws;
gpu_ws_get_debug(&ws);
PresRingEntry* e = &s_pres_ring[s_pres_ring_total & (PRES_RING_CAP - 1u)];
s_pres_ring_total++;
e->frame = (uint32_t)ws.cur_frame;
e->disp_w = disp_w;
e->disp_h = disp_h;
e->present_w = present_w;
e->nw_extra = (uint16_t)(ws.nw_extra < 0 ? 0 : ws.nw_extra);
e->path = path;
e->wide_fellback = 0;
e->game_mode = (uint8_t)ws.game_mode;
e->native_43 = (uint8_t)ws.present_native_43;
int64_t d = (int64_t)ws.cur_frame - (int64_t)ws.last_tag_frame;
e->tag_delta = (d > INT32_MAX || d < INT32_MIN) ? INT32_MAX : (int32_t)d;
e->gte_verts = (uint16_t)(ws.gte_verts > 0xFFFF ? 0xFFFF : ws.gte_verts);
e->ovh_prims = (uint16_t)(ws.ovh_prims > 0xFFFF ? 0xFFFF : ws.ovh_prims);
return e;
}
/* ---- Load-transition ring ---------------------------------------------
* State edges only, rather than one record per frame. This makes the exact
* CD-read -> bridged-load -> turbo -> paced transitions cheap and queryable
* without changing any runtime behavior. */
#define LOAD_TRANSITION_CAP 512u
static LoadTransitionEntry s_load_transitions[LOAD_TRANSITION_CAP];
static uint64_t s_load_transition_total = 0;
extern "C" uint64_t load_transition_total(void) {
return s_load_transition_total;
}
extern "C" int load_transition_get(uint64_t seq, LoadTransitionEntry *out) {
if (seq >= s_load_transition_total) return 0;
if (s_load_transition_total - seq > LOAD_TRANSITION_CAP) return 0;
*out = s_load_transitions[seq & (LOAD_TRANSITION_CAP - 1u)];
return 1;
}
static void load_transition_note(int read_active, int load_active,
int turbo_active, int load_run) {
static int prev_read = -1, prev_load = -1, prev_turbo = -1;
if (read_active == prev_read && load_active == prev_load &&
turbo_active == prev_turbo) return;
extern uint64_t s_frame_count;
LoadTransitionEntry *e =
&s_load_transitions[s_load_transition_total & (LOAD_TRANSITION_CAP - 1u)];
s_load_transition_total++;
e->frame = (uint32_t)s_frame_count;
e->host_ms = (uint32_t)SDL_GetTicks();
e->load_run = (uint16_t)(load_run > 0xFFFF ? 0xFFFF : load_run);
e->read_active = (uint8_t)(read_active != 0);
e->load_active = (uint8_t)(load_active != 0);
e->turbo_active = (uint8_t)(turbo_active != 0);
prev_read = read_active;
prev_load = load_active;
prev_turbo = turbo_active;
}
/* Depth24 FMV: trailing RGB columns can be stale while CRTC width stays full
* (MotK 512). Present width is never shrunk — cropping the GL/SDL rect left a
* flickering black pillar when upload coverage varied.
*
* Always black-fill the last 8 RGB cols. Also blank beyond the tracked FB A0
* span when it falls short of the CRTC (movie cut / partial cover). Do NOT
* replicate the last good column — that smeared MotK's starfield into an
* 8-wide streak. */
static void depth24_fix_trailing_margin(uint32_t *buf, uint32_t w, uint32_t h,
uint32_t display_x) {
if (!buf || w < 8u || h == 0u) return;
uint32_t start = w - 8u;
uint32_t lim = gpu_depth24_rgb_limit(display_x, w);
if (lim > 0u && lim < w && lim < start)
start = lim;
for (uint32_t y = 0; y < h; y++) {
for (uint32_t x = start; x < w; x++)
buf[y * w + x] = 0xFF000000u;
}
}
/* Called from gpu_vblank_tick() at each simulated vblank. */
static void sdl_vblank_present(void) {
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_set_fmv_quiet(mdec_recently_active(2));
/* 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
runtime_perf_frame_begin();
RuntimePerfFrameScope runtime_perf_frame_scope;
runtime_perf_diag_tick();
/* Lightweight frontend telemetry from PR #13. Count simulated vblanks rather
* than presents so turbo and skipped-frame modes still report game speed.
* Skip during netplay post-load barrier — admit is stalled and the window
* is not updating, so a climbing FPS line is misleading. */
if (!psx_netplay_in_load_barrier()) {
extern uint64_t s_frame_count;
const Uint64 now = SDL_GetPerformanceCounter();
const Uint64 frequency = SDL_GetPerformanceFrequency();
if (!s_fps_last_time) {
s_fps_last_time = now;
s_fps_last_frame = s_frame_count;
if (sdl_window) {
const char *title = SDL_GetWindowTitle(sdl_window);
if (title) s_fps_base_title = title;
}
} else if (frequency && now - s_fps_last_time >= frequency) {
const double seconds = (double)(now - s_fps_last_time) / (double)frequency;
const double fps = (double)(s_frame_count - s_fps_last_frame) / seconds;
const double speed = fps / 59.94;
if (!g_headless && sdl_window) {
char title[256];
snprintf(title, sizeof(title), "%s [%.0f fps %.2fx]",
s_fps_base_title.c_str(), fps, speed);
SDL_SetWindowTitle(sdl_window, title);
}
if (netplay_timing_on() && s_np_timing_frames > 0) {
const double invf = 1000.0 / (double)frequency;
const double admit_ms =
(double)s_np_admit_ticks * invf / (double)s_np_timing_frames;
const double guest_ms =
(double)s_np_guest_ticks * invf / (double)s_np_timing_frames;
std::fprintf(stderr,
"[FPS] game: %.1f fps (%.2fx) | frames: %llu | "
"guest=%.2f ms/f admit=%.2f ms/f (n=%llu)\n",
fps, speed, (unsigned long long)s_frame_count,
guest_ms, admit_ms, (unsigned long long)s_np_timing_frames);
s_np_admit_ticks = 0;
s_np_guest_ticks = 0;
s_np_timing_frames = 0;
} else {
std::fprintf(stderr, "[FPS] game: %.1f fps (%.2fx) | frames: %llu\n",
fps, speed, (unsigned long long)s_frame_count);
}
std::fflush(stderr);
s_fps_last_time = now;
s_fps_last_frame = s_frame_count;
}
}
/* Host-stack-usage profile sample — frame counter is now current, and we are
* on the guest fiber (see §17 block above). BEFORE the turbo/fast-boot early
* returns so the curve is captured even when presents are skipped. */
stack_profile_sample();
/* 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. */
{
uint64_t perf_start = runtime_perf_section_begin();
overlay_autocapture_tick();
runtime_perf_section_end(perf_start,
&g_runtime_perf.autocapture_ticks);
}
{ /* Apply a finished batch compile via the active code provider (gcc:
* cache rescan on done). */
uint64_t perf_start = runtime_perf_section_begin();
const CodeProvider *cp = code_provider_active();
if (cp->poll_main) cp->poll_main();
runtime_perf_section_end(perf_start,
&g_runtime_perf.provider_poll_ticks);
}
if (!g_headless) {
/* Pump SDL events to prevent window freeze. */
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
if (ev.type == SDL_QUIT) {
if (psx_netplay_active()) {
netplay_soft_exit("sdl_window_close");
return;
}
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) {
const Uint16 mod = ev.key.keysym.mod;
if (ev.key.keysym.sym == SDLK_ESCAPE && psx_netplay_active()) {
netplay_soft_exit("netplay_escape");
return;
}
/* Save states: Shift+F1-F12 = save slot 0-11, F1-F12 = load.
* (F11 is a save slot per the user's spec, so fullscreen is
* Alt+Enter / Cmd+Ctrl+F only — no F11.) */
if (ev.key.keysym.sym >= SDLK_F1 && ev.key.keysym.sym <= SDLK_F12) {
int slot = (int)(ev.key.keysym.sym - SDLK_F1); /* 0..11 */
if (psx_netplay_active()) {
/* Match host only — guest F-keys must not initiate. */
if (!psx_netplay_is_host()) {
/* ignore */
} else if (mod & KMOD_SHIFT) {
(void)psx_netplay_request_save(slot);
} else {
(void)psx_netplay_request_load(slot);
}
} else if (mod & KMOD_SHIFT) {
savestate_request_save(slot);
} else {
savestate_request_load(slot);
}
}
else if (ev.key.keysym.sym == SDLK_c && (mod & KMOD_CTRL)) {
std::fprintf(stdout, "[DEBUG] Forzando reinserción de CD...\n");
debug_force_cd_reinsert();
}
/* Fullscreen toggle: Alt+Enter or Cmd/Ctrl+F. Toggles between
* windowed and the CONFIGURED tri-state mode (g_fullscreen: 1
* borderless desktop fullscreen keeping the desktop resolution
* and letterboxing the image, or 2 exclusive fullscreen — a
* real display-mode change). SDL_WINDOW_FULLSCREEN's bit is
* set in both SDL_WINDOW_FULLSCREEN and
* SDL_WINDOW_FULLSCREEN_DESKTOP, so testing just that bit
* detects "currently fullscreen, either mode". */
else if ((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;
if (is_fs) {
SDL_SetWindowFullscreen(sdl_window, 0);
} else {
/* If the configured mode is "off", the hotkey still
* needs a mode to switch INTO — default to borderless,
* matching the historical (pre-tri-state) behaviour. */
Uint32 target = psx_fullscreen_flag_for_mode(g_fullscreen);
if (target == 0) target = SDL_WINDOW_FULLSCREEN_DESKTOP;
SDL_SetWindowFullscreen(sdl_window, target);
}
}
}
}
}
/* Sample each player's device and feed the matching SIO pad slot.
* Debug server input override (when active) drives port 1 only. With
* g_low_latency_input this early sample is re-done after the pacer wait
* (below) for the interactive present path; it still covers the turbo /
* FMV-skip paths that early-return before pacing.
*
* Delay-sync netplay host FPS (MotK FMV): finish → present → admit/pace.
* Local Swap overlaps the peer's guest quantum; admit still runs every
* tick via the RAII tail (including early returns that skip present).
* Offline keeps pace-before-present. Barrier wait stays UDP poll — do
* not pair this order with SDL_Delay(0) busy-spin (tick-0 hang). */
struct NetplayVblankTail {
int override_ = -1;
bool active_ = false;
bool skip_pace_ = false;
explicit NetplayVblankTail(int ov)
: override_(ov), active_(psx_netplay_active() != 0) {}
void skip_pace() { skip_pace_ = true; }
~NetplayVblankTail() {
if (!active_) return;
if (psx_return_to_lobby_requested()) return;
netplay_barrier_admit(override_);
if (skip_pace_ || psx_return_to_lobby_requested()) return;
uint64_t perf_start = runtime_perf_section_begin();
frame_pacer_wait(&s_frame_pacer, g_frame_period_ms);
runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks);
latency_ring_mark(LAT_PACED);
}
} netplay_tail(override);
if (psx_netplay_active()) {
psx_netplay_finish_frame();
} else if (g_headless) {
sample_headless_pad_into_sio(override);
} else {
sample_pad_into_sio(override);
}
/* Latency ring: open this present cycle's slot, stamping when input was
* sampled into SIO. Always-on; queried via the debug server "latency". */
latency_ring_frame_begin();
/* Turbo-active test shared by the pacing/present gate below. */
int turbo_loads_active = 0;
extern int fntrace_is_game_started(void);
int logical_load_active = fntrace_is_game_started() && cdrom_load_in_progress();
int load_run_value = 0;
static int load_run = 0;
static int release_run = 0;
if (g_turbo_loads_enabled && !psx_netplay_active()) {
/* Require a short sustained predicate on entry, then retain turbo over
* a short false gap after it has engaged. Netplay keeps wall pacing. */
if (logical_load_active) {
if (load_run < (1 << 20)) load_run++;
if (load_run >= TURBO_LOADS_ENGAGE_FRAMES || release_run > 0) {
turbo_loads_active = 1;
release_run = TURBO_LOADS_RELEASE_FRAMES;
}
} else {
load_run = 0;
if (release_run > 0) {
turbo_loads_active = 1;
release_run--;
}
}
load_run_value = load_run;
} else {
load_run = 0;
release_run = 0;
}
/* HLE boot-skip window: from reset until the game entry PC first
* dispatches, run unpaced so the (shell-skipped) BIOS kernel init +
* SYSTEM.CNF + game EXE load compress to host speed. This replaces the
* old fast_boot snapshot restore; all guest timing is authentic. */
if (psx_bios_hle_boot_turbo_active())
turbo_loads_active = 1;
load_transition_note(cdrom_data_read_active(), logical_load_active,
turbo_loads_active, load_run_value);
/* FMV auto-skip ([video] auto_skip_fmv). A streaming FMV is XA audio + MDEC
* video together. Detect "MDEC produced a frame since the last vblank AND XA
* is streaming"; a short hold rides out brief inter-frame gaps. The present/
* pacing safety-net + audio mute below hide the one or two transition frames.
*
* End the movie the GAME's own way, chosen per game:
* - fmv_skip_total_table set (Tomba): write the CURRENT movie's per-movie
* frame-total down to fmv_skip_end_total, so the player's MDEC loop
* (FUN_8001efe8: teardown when streamed frame# >= total - 3) terminates on
* its next frame — a NATURAL end that reaches EVERY movie, including ones
* whose caller never polls the skip button. Only the active movie's table
* entry is touched (via the movie-id byte), so nothing else is disturbed.
* - no table configured (generic): hold START so a movie whose handler polls
* the pad aborts itself (can't reach unskippable movies). */
int fmv_skip_active = 0;
if (g_auto_skip_fmv) {
uint32_t mc = mdec_get_decode_count();
int mdec_decoding = (mc != s_fmv_skip_last_mdec);
s_fmv_skip_last_mdec = mc;
int xa = cdrom_xa_stream_active();
/* fmv_skip_no_xa: silent RAM-preloaded movies (no XA stream) never
* satisfy the MDEC+XA detector; with the per-game opt-in, MDEC
* activity alone qualifies. */
int detected = mdec_decoding && (xa || g_fmv_skip_no_xa);
if (detected) s_fmv_skip_hold = (!xa && g_fmv_skip_no_xa)
? g_fmv_skip_no_xa_hold : 4;
else if (s_fmv_skip_hold > 0) s_fmv_skip_hold--;
fmv_skip_active = (s_fmv_skip_hold > 0) && (xa || g_fmv_skip_no_xa);
if (fmv_skip_active) {
if (g_fmv_skip_total_table) {
/* End the active movie via its own frame-count teardown. */
uint8_t mid = psx_read_byte(g_fmv_skip_movie_id);
psx_write_half(g_fmv_skip_total_table + (uint32_t)mid * 2u,
(uint16_t)g_fmv_skip_end_total);
} else {
/* Generic fallback: hold START (PSX pad word is active-low; START
* is bit 3) so the game's FMV handler aborts the movie itself. */
sio_set_pad_state_slot(0, (uint16_t)~(1u << 3));
}
}
}
#ifndef PSX_SDL_NO_AUDIO
/* Optional turbo host sink advances the canonical SPU on the exact guest
* sample budget but drops accelerated output before SDL. This is distinct
* from the old mute/freeze model: voice and CD state never pause. FMV skip
* retains the hard mute because it deliberately fast-forwards a movie. */
sdl_audio_update(fmv_skip_active,
turbo_loads_active && g_turbo_audio_sink_enabled);
#endif
if (g_headless) {
netplay_tail.skip_pace();
return;
}
/* 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()) {
netplay_tail.skip_pace();
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) {
netplay_tail.skip_pace();
return; /* skip render this frame */
}
} else {
turbo_skip = 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++;
const int TL_PRESENT_EVERY = 30;
s_turbo_present_skip = (s_turbo_present_skip + 1) % TL_PRESENT_EVERY;
if (s_turbo_present_skip != 0) {
netplay_tail.skip_pace();
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) {
const int FMV_PRESENT_EVERY = 30;
s_fmv_skip_present_skip = (s_fmv_skip_present_skip + 1) % FMV_PRESENT_EVERY;
if (s_fmv_skip_present_skip != 0) {
netplay_tail.skip_pace();
return;
}
}
/* Netplay FMV: skip present every other depth24 vblank (admit still runs
* in the RAII tail). Present-first frame, then alternate. */
if (psx_netplay_active() && gpu_display_is_depth24()) {
if (s_netplay_depth24_present_skip) {
s_netplay_depth24_present_skip = 0;
netplay_tail.skip_pace();
return;
}
s_netplay_depth24_present_skip = 1;
} else {
s_netplay_depth24_present_skip = 0;
}
/* Offline wall-clock pacing before present. Netplay paces in NetplayVblankTail
* AFTER present so Swap overlaps the peer's guest quantum. */
if (!psx_netplay_active()) {
uint64_t perf_start = runtime_perf_section_begin();
frame_pacer_wait(&s_frame_pacer, g_frame_period_ms);
runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks);
latency_ring_mark(LAT_PACED);
/* Low-latency input: the early sample above is now ~one pacer-wait old.
* Refresh the device state and re-sample right before present so the next
* CPU frame reads near-fresh input (the dominant input->photon cost on a
* vsync-light box). Re-stamp the ring's input mark to measure from here. */
if (g_low_latency_input) {
SDL_GameControllerUpdate(); /* refresh pad state after the wait */
SDL_PumpEvents(); /* refresh keyboard state */
sample_pad_into_sio(override);
latency_ring_restamp_input();
}
}
/* Mod hooks. Run after all normal input sampling. */
mod_call_frame_hooks();
/* Resize-driven mode updates the pending aspect during BIOS boot too, but
* the actual wide compositor remains disengaged until game entry. */
update_adaptive_widescreen();
/* Resize-driven mode updates the pending aspect during BIOS boot too, but
* the actual wide compositor remains disengaged until game entry. */
update_adaptive_widescreen();
/* Depth24 GP1(07h) retarget (MotK intro→crawl): keep the prior Swap for a
* few vblanks so stale trailing VRAM never flashes on the right edge. */
if (gpu_depth24_present_hold_tick())
return;
/* Engage widescreen at game entry: BIOS boot stays authentic 4:3. */
if (!g_ws_engaged) {
extern int fntrace_is_game_started(void);
if (fntrace_is_game_started()) {
g_ws_engaged = true;
const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4;
int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0;
/* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio
* (identity squash). Squash mode: feed the real wide aspect. */
gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4,
mode == 1 ? g_video_aspect_den : 3);
gpu_ws_configure(g_video_aspect_num, g_video_aspect_den,
g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode);
}
}
/* ---- Display from our VRAM ---- */
uint32_t w = 0, h = 0;
uint32_t present_w = 0; /* display width actually presented (w + native-wide EXTRA) */
int active_scale = 1; /* hi-res mirror used only for 15-bit display */
bool fmv_frame = false; /* FMV/boot — present pillarboxed 4:3 in widescreen */
bool pin_43 = false; /* pillarbox this present (FMV, or a native-wide
game frame that could not present wide) */
bool depth24_frame = false;
if (s_force_present_after_load && g_gl_active)
gl_renderer_flush_cpu_uploads();
{
GpuDisplayInfo di;
gpu_get_display_info(&di);
depth24_frame = di.depth24 != 0;
if (di.disabled || di.width == 0 || di.height == 0) {
smooth_60_present(nullptr, 0, 0, false);
present_ring_commit(PRES_PATH_BLANK, (uint16_t)di.width,
(uint16_t)di.height, 0);
#ifndef PSX_SDL_NO_RENDER
/* After savestate load, always restage once even if we blanked
* earlier in the session — otherwise the window keeps a stale
* pre-load frame while vblanks (and FPS) keep advancing. */
if (!s_disabled_frame_presented || s_force_present_after_load) {
s_disabled_frame_presented = true;
s_force_present_after_load = false;
if (g_gl_active) {
gl_renderer_present_blank();
} else if (g_vk_active) {
vk_renderer_present_blank();
} else {
SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255);
SDL_RenderClear(sdl_renderer);
SDL_RenderPresent(sdl_renderer);
}
}
#endif
return;
}
s_disabled_frame_presented = false;
s_force_present_after_load = false;
w = di.width; h = di.height;
/* 4:3-pinned frames: the pre-game BIOS boot, plus (once engaged) every
* frame the widescreen layer presents native — FMV video and full-2D
* menu/title screens. gpu_ws_present_native_43() is the single source
* of truth, shared with the GTE/GPU squash so content and present stay
* locked: we squash IFF we stretch. depth24 always classifies as FMV
* even if the ws layer is not engaged yet (4:3 titles). */
fmv_frame = di.depth24 || !g_ws_engaged || gpu_ws_present_native_43() != 0;
/* MDEC movies are already decoded at their authored cadence and are
* CPU/upload heavy. High-refresh crossfades only contend with decoding
* and can starve audio, so present native-4:3/MDEC phases directly.
* The classification catches the transition frame before the first
* decode; the activity stamp also covers authentic 4:3 configurations. */
if (g_gl_active)
gl_renderer_set_interpolation_suspended(
fmv_frame || mdec_recently_active(2));
/* Canonical present width. Native-wide does NOT widen the canonical read
* (that bled across adjacent framebuffers); it composites into a separate
* wide surface and presents from there instead (see gpu wide compositor). */
present_w = w;
/* Native-wide present: on a game frame, if the active backend has the
* wide compositor, present the wider surface (canonical width + EXTRA)
* from the displayed buffer's surface. FMV/menu frames stay 4:3. */
bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged &&
ws_native_wide_active() && gr_wide_supported());
if (wide_present) present_w = w + (uint32_t)ws_nw_extra();
/* Native-wide invariant: canonical (320-wide) content is NEVER
* stretched across the wide window — a game frame that cannot present
* wide (compositor unsupported, or the surface fallback below)
* pillarboxes 4:3 like FMV/menus instead. Only squash mode (1) may
* stretch: its canonical content is pre-squashed FOR the stretch. */
const bool nw_pin = g_ws_engaged && g_ws_native_wide;
/* Ring the classification now that it's final (only the software/CPU
* wide path below can still fall back — it amends this entry). A
* CANONICAL game frame on a widescreen window IS the stretch. */
PresRingEntry* pres_entry = present_ring_commit(
fmv_frame ? PRES_PATH_NATIVE_43
: (wide_present ? PRES_PATH_WIDE : PRES_PATH_CANONICAL),
(uint16_t)w, (uint16_t)h, (uint16_t)present_w);
/* 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 use the CPU readout below.
* depth24: do NOT sync_cpu (FBO readback can clobber packed RGB888) and
* do NOT flush_cpu_uploads (MDEC already wrote the CPU mirror; forcing
* FBO uploads every frame cut MotK intro from ~50 to ~30 FPS). */
#ifndef PSX_SDL_NO_RENDER
if (g_gl_active && g_gl_fbo_present && !di.depth24) {
if (wide_present) {
/* GPU-direct native-wide present: blit the displayed buffer's
* wide FBO straight to the window (GPU-side, like the canonical
* present_vram). This avoids the glFinish + glReadPixels CPU
* round-trip the old path did EVERY frame — that readback was the
* GL-only slowdown (SW's wide path is pure CPU, no GPU sync, so
* SW stayed smooth). Falls through to the CPU readout path only if
* the wide surface for this buffer doesn't exist yet. */
if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y,
(int)h, g_video_aa ? 1 : 0))
return;
} else {
gl_renderer_present_vram((int)di.display_x, (int)di.display_y,
(int)present_w, (int)h, g_video_aa ? 1 : 0,
(fmv_frame || nw_pin) ? 1 : 0);
return;
}
}
if (g_gl_active && !di.depth24) gl_renderer_sync_cpu();
/* Vulkan owns every frame: 15-bit frames present straight from the GPU
* VRAM image (deterministic blit, no readback), mirroring the GL path;
* 24-bit (FMV) frames go through the CPU present (Phase 3). The Vulkan
* window has no SDL_Renderer, so we must never fall through below. */
if (g_vk_active) {
if (di.depth24) {
/* 24-bit (FMV): packed RGB lives in the CPU mirror — do NOT
* sync_cpu (FBO readback clobbers RGB888). */
for (uint32_t y = 0; y < h; y++)
for (uint32_t x = 0; x < present_w; x++)
sdl_pixel_buf[y * present_w + x] =
gpu_display_pixel_argb(&di, x, y);
/* Trailing margin: blank junk cols inside the full-width
* buffer — never shrink present width. */
depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h,
di.display_x);
vk_renderer_present_cpu(sdl_pixel_buf, (int)present_w, (int)h,
0 /* nearest */, fmv_frame ? 1 : 0);
} else if (wide_present &&
vk_renderer_present_wide((int)di.display_x, (int)di.display_y,
(int)h, g_video_aa ? 1 : 0)) {
/* presented wide */
} else {
vk_renderer_present_vram((int)di.display_x, (int)di.display_y,
(int)present_w, (int)h, g_video_aa ? 1 : 0,
(fmv_frame || nw_pin) ? 1 : 0);
}
return;
}
#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 (wide_present) {
/* The wide compositor surface is at the renderer's REAL internal
* scale, which gr_render_wide_display uses for its readback pitch.
* Use gr_scale() (== that internal scale) rather than g_video_scale:
* on the GL backend g_video_scale can be stale (1) while the renderer
* supersamples at 2, and a mismatch makes the present read the wide
* buffer at the wrong width (a magnified top-left slice). gr_scale()
* is guaranteed to match the readback. Falls back to the canonical
* hires path if the displayed buffer has no surface yet. */
int s = gr_scale();
int sw = (int)present_w * s;
int n = gr_render_wide_display(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)),
(int)di.display_x, (int)di.display_y, (int)h);
if (n > 0) {
active_scale = s;
} else {
wide_present = false;
present_w = w;
pres_entry->path = PRES_PATH_CANONICAL;
pres_entry->wide_fellback = 1;
pres_entry->present_w = (uint16_t)present_w;
}
}
/* depth24 (MotK crawl is 128 lines): pin short bands for letterbox even
* when fmv_frame is false on a plain 4:3 window (ws layer not engaged). */
pin_43 = fmv_frame || di.depth24 || (nw_pin && !wide_present);
if (!wide_present) {
if (active_scale > 1) {
int sw = (int)present_w * active_scale;
gr_render_display_hires(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)),
(int)di.display_x, (int)di.display_y,
(int)present_w, (int)h);
} else {
for (uint32_t y = 0; y < h; y++) {
for (uint32_t x = 0; x < present_w; x++) {
sdl_pixel_buf[y * present_w + x] = gpu_display_pixel_argb(&di, x, y);
}
}
}
}
/* Depth24 trailing margin: MotK CRTC is 512 RGB but trailing cols
* can be stale. Fix pixels in-place at full present_w — never crop the
* GL/SDL draw width (that caused a flickering black pillar). */
if (di.depth24 && active_scale == 1 && !wide_present)
depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h,
di.display_x);
smooth_60_present(sdl_pixel_buf,
present_w * (uint32_t)active_scale,
h * (uint32_t)active_scale,
!g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame);
/* Frame blending (CRT-persistence masker for 30fps double-buffered
* content). Some games (e.g. Crash Bash menus/characters) leave a
* dynamic object in only one of the two display buffers per 30fps cycle,
* so it strobes on/off as the display alternates buffers — the static
* background, present in both, stays put. Real CRTs hid this via phosphor
* persistence; accurate emulators either time it away (cycle accuracy) or
* offer a "blend frames" option that averages the last two presented
* frames. This is the latter: presentation only, game logic untouched.
* Guarded to the plain software present path (no GL/VK/wide/hires/FMV) so
* the buffer is a simple present_w*h ARGB grid. PSX_FRAME_BLEND=0 off. */
{
static int blend_cfg = -1; /* -1 unresolved, 0 off, 1 on */
if (blend_cfg < 0) {
const char* e = getenv("PSX_FRAME_BLEND");
blend_cfg = (e && e[0] == '1') ? 1 : 0; /* default off (masker only) */
}
const bool simple_sw = !g_gl_active && !g_vk_active && !wide_present &&
!di.depth24 && active_scale == 1;
if (blend_cfg && simple_sw &&
!g_smooth_60fps.load(std::memory_order_acquire)) {
static uint32_t prev_buf[640 * 512];
static uint32_t prev_px = 0;
const uint32_t npx = present_w * h;
if (npx <= (uint32_t)(640 * 512)) {
if (prev_px == npx) {
for (uint32_t i = 0; i < npx; i++) {
const uint32_t a = sdl_pixel_buf[i];
const uint32_t b = prev_buf[i];
prev_buf[i] = a; /* store this frame (pre-blend) */
sdl_pixel_buf[i] =
0xFF000000u |
(((a & 0xFEFEFEu) >> 1) + ((b & 0xFEFEFEu) >> 1));
}
} else {
/* Resolution changed / first frame: seed, no blend. */
for (uint32_t i = 0; i < npx; i++) prev_buf[i] = sdl_pixel_buf[i];
prev_px = npx;
}
}
}
}
}
/* 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)present_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. 24-bit (FMV) frames pin to native 4:3. */
/* FMV: nearest present — linear filtering fringes the right edge of
* low-res 24-bit scanouts into adjacent (often garbage) texels. */
gl_renderer_present(sdl_pixel_buf, src_w, src_h,
(g_video_aa && !depth24_frame) ? 1 : 0,
pin_43 ? 1 : 0, 0 /* full width */);
} else {
SDL_Rect src = { 0, 0, src_w, src_h };
SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf,
(int)(src_w * sizeof(uint32_t)));
/* FMV (24-bit) frames are authored 4:3 with no GTE squash to compensate
* the widescreen stretch — pillarbox them at native 4:3 instead. Same for
* native-wide game frames that could not present wide (pin_43): canonical
* content is never stretched across the wide window. */
int dst_w = pin_43 ? 640 * g_video_scale : g_logical_w;
int dst_h = 480 * g_video_scale;
SDL_Rect dst = { (g_logical_w - dst_w) / 2, 0, dst_w, dst_h };
/* Match GL: short display bands letterbox inside the 4:3 rect — FMV only
* (depth24), and only genuinely windowed bands (<80% of the field; a
* native short display mode like 216/224 fills the rect). */
if (pin_43 && depth24_frame && h > 0 && h < 192) {
int content_h = (dst_h * (int)h) / 240;
if (content_h < 1) content_h = 1;
dst.y = (dst_h - content_h) / 2;
dst.h = content_h;
}
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. */
{
latency_ring_mark(LAT_SWAP_BEGIN);
const Uint64 t0 = SDL_GetPerformanceCounter();
SDL_RenderPresent(sdl_renderer);
const Uint64 t1 = SDL_GetPerformanceCounter();
latency_ring_mark(LAT_SWAP_END);
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
}
#if defined(RECOMP_LAUNCHER)
// Host verification/inspection callbacks for the shared recomp-ui launcher.
// The launcher re-runs these on every disc/memory-card change so the "Disc
// verified" verdict + memcard block grids reflect the real images. The expected
// serial/CRC are passed via these file-scope statics (set just before
// recomp_launcher_run_window) because the C-ABI callback can't capture locals.
namespace {
std::string g_lnch_expected_serial;
uint32_t g_lnch_expected_crc = 0;
bool g_lnch_has_crc = false;
int ae_disc_verify(const char* disc_path, RecompLauncherCDiscVerify* out) {
if (!disc_path || !disc_path[0] || !out) return 0;
PSXRecompV4::DiscIdentity id = PSXRecompV4::identify_disc(
disc_path, g_lnch_expected_serial, g_lnch_expected_crc,
g_lnch_has_crc, /*compute_crc*/ g_lnch_has_crc);
const std::string& serial = !id.detected_serial.empty()
? id.detected_serial : g_lnch_expected_serial;
std::snprintf(out->serial, sizeof(out->serial), "%s", serial.c_str());
std::snprintf(out->region, sizeof(out->region), "%s", id.region.c_str());
out->iso_ok = id.has_header ? 1 : 0;
// Verdict shown by the launcher.
if (!id.opened || !id.has_header) out->verdict = 3; // bad
else if (id.expected_serial_given && !id.serial_matches) out->verdict = 3; // wrong disc
else if (id.expected_crc_given && id.crc_computed && !id.crc_matches) out->verdict = 2; // warn
else out->verdict = 1; // ok
return 1;
}
int ae_memcard_inspect(const char* card_path, RecompLauncherCMemcard* out) {
if (!card_path || !card_path[0] || !out) return 0;
MemcardSummary s;
if (memcard_summary_path(card_path, &s) != 0) return 0;
out->valid = s.valid;
out->used_blocks = s.used_blocks;
for (int i = 0; i < 15; ++i) out->block_used[i] = s.block_used[i];
return 1;
}
std::string g_lnch_netplay_game_name;
std::filesystem::path g_lnch_settings_path;
std::string g_lnch_lobby_url;
RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{};
bool g_lnch_hosting_lan = false;
bool g_lnch_joined_lan = false;
std::string g_lnch_lan_endpoint;
std::string g_lnch_lan_guest_bind;
struct AeLanLobbyState {
std::string name;
std::string game;
std::string endpoint;
std::string host_name;
std::string joiner_name;
std::string password;
bool started = false;
int host_slot = 0;
};
std::filesystem::path ae_np_lan_file() {
return std::filesystem::current_path() / "netplay_lan_lobby.txt";
}
bool ae_np_read_lan_state(AeLanLobbyState* state) {
if (!state) return false;
std::ifstream f(ae_np_lan_file());
if (!f) return false;
std::string started, host_slot;
std::getline(f, state->name);
std::getline(f, state->game);
std::getline(f, state->endpoint);
std::getline(f, state->host_name);
std::getline(f, state->joiner_name);
std::getline(f, started);
std::getline(f, host_slot);
std::getline(f, state->password);
state->started = started == "1";
state->host_slot = host_slot == "1" ? 1 : 0;
return !state->endpoint.empty();
}
bool ae_np_write_lan_state(const AeLanLobbyState& state) {
std::ofstream f(ae_np_lan_file(), std::ios::trunc);
if (!f) return false;
f << state.name << "\n"
<< state.game << "\n"
<< state.endpoint << "\n"
<< state.host_name << "\n"
<< state.joiner_name << "\n"
<< (state.started ? "1" : "0") << "\n"
<< state.host_slot << "\n"
<< state.password << "\n";
return (bool)f;
}
void ae_np_write_lan_lobby(const char* name, const char* endpoint,
const char* password) {
AeLanLobbyState state;
state.name = name && name[0] ? name : "LAN Lobby";
state.game = g_lnch_netplay_game_name.empty() ? "PSX" : g_lnch_netplay_game_name;
state.endpoint = endpoint && endpoint[0] ? endpoint : "127.0.0.1:7777";
state.host_name = psx_lobby_display_name();
if (state.host_name.empty()) state.host_name = "Host";
state.password = password ? password : "";
ae_np_write_lan_state(state);
g_lnch_hosting_lan = true;
g_lnch_joined_lan = false;
g_lnch_lan_endpoint = state.endpoint;
g_lnch_lan_guest_bind.clear();
}
int ae_np_read_lan_lobby(RecompLauncherCNetplayLobby* out) {
if (!out) return 0;
AeLanLobbyState state;
if (!ae_np_read_lan_state(&state)) return 0;
std::snprintf(out->lobby_id, sizeof(out->lobby_id), "lan:%s", state.endpoint.c_str());
std::snprintf(out->name, sizeof(out->name), "LAN - %s",
state.name.empty() ? "Lobby" : state.name.c_str());
std::snprintf(out->game_name, sizeof(out->game_name), "%s",
state.game.empty() ? "PSX" : state.game.c_str());
out->player_count = state.joiner_name.empty() ? 1 : 2;
out->max_slots = 2;
out->has_password = state.password.empty() ? 0 : 1;
return 1;
}
PsxLobbyMatchCaps ae_netplay_caps_from_settings(const RecompLauncherCSettings* s) {
PsxLobbyMatchCaps caps{};
caps.valid = 1;
switch (s ? s->aspect_index : 0) {
case 2: caps.aspect_num = 21; caps.aspect_den = 9; break;
case 1: caps.aspect_num = 16; caps.aspect_den = 9; break;
default: caps.aspect_num = 4; caps.aspect_den = 3; break;
}
caps.turbo_loads = s ? (s->turbo_loads != 0) : 0;
caps.auto_skip_fmv = s ? (s->auto_skip_fmv != 0) : 0;
caps.input_delay = 2;
return caps;
}
const char* ae_np_default_url(void*) {
return g_lnch_lobby_url.empty() ? psx_lobby_default_url() : g_lnch_lobby_url.c_str();
}
void ae_np_save_identity(const char* player_name, const char* lobby_url) {
if (g_lnch_settings_path.empty()) return;
PSXRecompV4::UserSettings settings =
PSXRecompV4::load_user_settings(g_lnch_settings_path);
if (settings.parse_error) return;
if (player_name && player_name[0]) {
settings.netplay_player_name = player_name;
settings.has_netplay_player_name = true;
}
if (lobby_url && lobby_url[0]) {
settings.netplay_lobby_url = lobby_url;
settings.has_netplay_lobby_url = true;
}
PSXRecompV4::save_user_settings(g_lnch_settings_path, settings);
}
void ae_np_set_lobby_url(void*, const char* url) {
g_lnch_lobby_url = url && url[0] ? url : psx_lobby_default_url();
ae_np_save_identity(nullptr, g_lnch_lobby_url.c_str());
}
int ae_np_connect(void*) {
psx_lobby_set_game_identity(g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION);
return psx_lobby_connect(ae_np_default_url(nullptr));
}
int ae_np_connected(void*) {
return psx_lobby_connected();
}
void ae_np_pump(void*) {
psx_lobby_pump();
}
void ae_np_set_player_name(void*, const char* name) {
psx_lobby_set_display_name(name && name[0] ? name : "Player");
if (name && name[0]) ae_np_save_identity(name, nullptr);
}
const char* ae_np_player_name(void*) {
return psx_lobby_display_name();
}
void ae_np_request_list(void*) {
psx_lobby_request_list();
}
int ae_np_list_count(void*) {
RecompLauncherCNetplayLobby lan{};
return psx_lobby_list_count() + (ae_np_read_lan_lobby(&lan) ? 1 : 0);
}
int ae_np_list_get(void*, int index, RecompLauncherCNetplayLobby* out) {
if (!out) return 0;
const int remote_count = psx_lobby_list_count();
if (index >= remote_count)
return ae_np_read_lan_lobby(out);
PsxLobbyRow row{};
if (!psx_lobby_list_get(index, &row)) return 0;
std::snprintf(out->lobby_id, sizeof(out->lobby_id), "%s", row.lobby_id);
std::snprintf(out->name, sizeof(out->name), "%s", row.name);
std::snprintf(out->game_name, sizeof(out->game_name), "%s", row.game_name);
std::snprintf(out->game_version, sizeof(out->game_version), "%s", row.game_version);
out->player_count = row.player_count;
out->max_slots = row.max_slots;
out->has_password = row.has_password;
return 1;
}
/* Collect non-loopback IPv4 addresses for Host Lobby IP dropdown. */
static void ae_np_collect_local_addresses(
std::vector<RecompLauncherCNetplayLocalAddress>* out) {
if (!out) return;
out->clear();
auto push = [&](const char* ip, const char* label) {
if (!ip || !ip[0]) return;
if (std::strcmp(ip, "0.0.0.0") == 0 ||
std::strcmp(ip, "127.0.0.1") == 0)
return;
for (const auto& existing : *out) {
if (std::strcmp(existing.address, ip) == 0) return;
}
RecompLauncherCNetplayLocalAddress entry{};
std::snprintf(entry.address, sizeof(entry.address), "%s", ip);
if (label && label[0])
std::snprintf(entry.label, sizeof(entry.label), "%s", label);
out->push_back(entry);
};
#ifdef _WIN32
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
ULONG flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST |
GAA_FLAG_SKIP_DNS_SERVER;
ULONG buf_len = 16 * 1024;
std::vector<unsigned char> buf(buf_len);
IP_ADAPTER_ADDRESSES* addrs =
reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buf.data());
ULONG rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len);
if (rc == ERROR_BUFFER_OVERFLOW) {
buf.resize(buf_len);
addrs = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buf.data());
rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len);
}
if (rc != NO_ERROR) return;
for (IP_ADAPTER_ADDRESSES* a = addrs; a; a = a->Next) {
if (a->OperStatus != IfOperStatusUp) continue;
if (a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) continue;
char label[64] = {};
if (a->FriendlyName) {
WideCharToMultiByte(CP_UTF8, 0, a->FriendlyName, -1, label,
(int)sizeof(label), nullptr, nullptr);
}
for (IP_ADAPTER_UNICAST_ADDRESS* u = a->FirstUnicastAddress; u;
u = u->Next) {
if (!u->Address.lpSockaddr ||
u->Address.lpSockaddr->sa_family != AF_INET)
continue;
auto* sin = reinterpret_cast<sockaddr_in*>(u->Address.lpSockaddr);
char ip[64] = {};
if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue;
push(ip, label);
}
}
#else
struct ifaddrs* ifa = nullptr;
if (getifaddrs(&ifa) != 0 || !ifa) return;
for (struct ifaddrs* i = ifa; i; i = i->ifa_next) {
if (!i->ifa_addr || i->ifa_addr->sa_family != AF_INET) continue;
if (!(i->ifa_flags & IFF_UP) || (i->ifa_flags & IFF_LOOPBACK)) continue;
auto* sin = reinterpret_cast<sockaddr_in*>(i->ifa_addr);
char ip[64] = {};
if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue;
push(ip, i->ifa_name ? i->ifa_name : "");
}
freeifaddrs(ifa);
#endif
}
int ae_np_local_address_get(void*, int index,
RecompLauncherCNetplayLocalAddress* out) {
if (!out || index < 0) return 0;
std::memset(out, 0, sizeof(*out));
std::vector<RecompLauncherCNetplayLocalAddress> addrs;
ae_np_collect_local_addresses(&addrs);
if (index >= (int)addrs.size()) return 0;
*out = addrs[(size_t)index];
return out->address[0] ? 1 : 0;
}
int ae_np_local_ip(void*, char* out, size_t out_len) {
if (!out || out_len == 0) return 0;
std::vector<RecompLauncherCNetplayLocalAddress> addrs;
ae_np_collect_local_addresses(&addrs);
if (addrs.empty()) {
std::snprintf(out, out_len, "Unavailable");
return 0;
}
std::snprintf(out, out_len, "%s", addrs[0].address);
return 1;
}
int ae_np_external_ip(void*, char* out, size_t out_len) {
if (!out || out_len == 0) return 0;
#ifdef _WIN32
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* res = nullptr;
if (getaddrinfo("api.ipify.org", "80", &hints, &res) != 0 || !res)
return 0;
SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (s == INVALID_SOCKET) {
freeaddrinfo(res);
return 0;
}
int ok = 0;
if (connect(s, res->ai_addr, (int)res->ai_addrlen) != SOCKET_ERROR) {
const char req[] =
"GET / HTTP/1.1\r\n"
"Host: api.ipify.org\r\n"
"Connection: close\r\n\r\n";
(void)send(s, req, (int)strlen(req), 0);
char resp[1024];
int n = recv(s, resp, sizeof(resp) - 1, 0);
if (n > 0) {
resp[n] = '\0';
char* body = strstr(resp, "\r\n\r\n");
body = body ? body + 4 : resp;
char ip[64] = {};
int j = 0;
for (int i = 0; body[i] && j < (int)sizeof(ip) - 1; ++i) {
if ((body[i] >= '0' && body[i] <= '9') || body[i] == '.')
ip[j++] = body[i];
else if (j > 0)
break;
}
if (j > 0) {
std::snprintf(out, out_len, "%s", ip);
ok = 1;
}
}
}
closesocket(s);
freeaddrinfo(res);
return ok;
#else
std::snprintf(out, out_len, "Unavailable");
return 0;
#endif
}
/* host_endpoint is in/out (capacity >= 64). recomp-ui applies UDP port
* policy before calling. lan_only: local registry only; else lobby server. */
int ae_np_create(void*, const char* lobby_name, char* host_endpoint,
const char* password,
const RecompLauncherCSettings* settings,
int lan_only) {
PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings);
char endpoint[96];
if (host_endpoint && host_endpoint[0])
std::snprintf(endpoint, sizeof(endpoint), "%s", host_endpoint);
else
std::snprintf(endpoint, sizeof(endpoint), "0.0.0.0:7777");
if (lan_only) {
if (psx_lobby_in_lobby())
(void)psx_lobby_leave();
ae_np_write_lan_lobby(lobby_name, endpoint, password);
if (host_endpoint)
std::snprintf(host_endpoint, 96, "%s", endpoint);
return 0;
}
if (g_lnch_hosting_lan) {
std::error_code ec;
std::filesystem::remove(ae_np_lan_file(), ec);
g_lnch_hosting_lan = false;
}
g_lnch_joined_lan = false;
g_lnch_lan_endpoint.clear();
g_lnch_lan_guest_bind.clear();
if (host_endpoint)
std::snprintf(host_endpoint, 96, "%s", endpoint);
return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby",
g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION,
password ? password : "", endpoint, &caps);
}
/* guest_bind is in/out (capacity >= 64). recomp-ui fills a real UDP port
* (prefer 7778); never advertise :0 to the lobby. */
int ae_np_join(void*, const char* lobby_id, const char* password,
char* guest_bind) {
char bind_buf[64];
const char* bind = guest_bind;
const char* colon = (bind && bind[0]) ? std::strrchr(bind, ':') : nullptr;
const unsigned port = (colon && colon[1])
? static_cast<unsigned>(std::strtoul(colon + 1, nullptr, 10)) : 0u;
if (!bind || !bind[0] || port == 0u) {
std::snprintf(bind_buf, sizeof(bind_buf), "0.0.0.0:7778");
bind = bind_buf;
if (guest_bind)
std::snprintf(guest_bind, 64, "%s", bind_buf);
}
if (lobby_id && strncmp(lobby_id, "lan:", 4) == 0) {
AeLanLobbyState state;
if (!ae_np_read_lan_state(&state) || !state.joiner_name.empty()) return -1;
if (state.password != (password ? password : "")) return -2;
state.joiner_name = psx_lobby_display_name();
if (state.joiner_name.empty()) state.joiner_name = "Player";
state.started = false;
if (!ae_np_write_lan_state(state)) return -1;
g_lnch_hosting_lan = false;
g_lnch_joined_lan = true;
g_lnch_lan_endpoint = state.endpoint;
g_lnch_lan_guest_bind = bind;
return 0;
}
return psx_lobby_join(lobby_id, password ? password : "", bind);
}
int ae_np_leave(void*) {
if (g_lnch_hosting_lan) {
std::error_code ec;
std::filesystem::remove(ae_np_lan_file(), ec);
g_lnch_hosting_lan = false;
} else if (g_lnch_joined_lan) {
AeLanLobbyState state;
if (ae_np_read_lan_state(&state)) {
state.joiner_name.clear();
state.started = false;
ae_np_write_lan_state(state);
}
}
g_lnch_joined_lan = false;
g_lnch_lan_endpoint.clear();
g_lnch_lan_guest_bind.clear();
g_lnch_pending_direct_launch = {};
return psx_lobby_leave();
}
int ae_np_in_lobby(void*) {
return g_lnch_hosting_lan || g_lnch_joined_lan || psx_lobby_in_lobby();
}
int ae_np_is_host(void*) {
if (g_lnch_hosting_lan || g_lnch_joined_lan) return g_lnch_hosting_lan ? 1 : 0;
return psx_lobby_is_host();
}
int ae_np_member_count(void*) {
if (g_lnch_hosting_lan || g_lnch_joined_lan) return 2;
return psx_lobby_member_count();
}
int ae_np_member_get(void*, int index, RecompLauncherCNetplayMember* out) {
if (!out) return 0;
if (g_lnch_hosting_lan || g_lnch_joined_lan) {
if (index < 0 || index > 1) return 0;
AeLanLobbyState state;
if (!ae_np_read_lan_state(&state)) return 0;
const bool host = index == 0;
out->slot = host ? state.host_slot : 1 - state.host_slot;
const std::string& name = host ? state.host_name : state.joiner_name;
std::snprintf(out->display_name, sizeof(out->display_name), "%s", name.c_str());
out->ready = !name.empty();
out->is_host = host ? 1 : 0;
return 1;
}
PsxLobbyMember mem{};
if (!psx_lobby_member_get(index, &mem)) return 0;
out->slot = mem.slot;
std::snprintf(out->display_name, sizeof(out->display_name), "%s", mem.display_name);
out->ready = mem.ready;
out->is_host = mem.slot == 0;
return 1;
}
int ae_np_move_member(void*, int from_slot, int to_slot) {
if (!g_lnch_hosting_lan || from_slot < 0 || from_slot > 1 ||
to_slot < 0 || to_slot > 1 || from_slot == to_slot) return -1;
AeLanLobbyState state;
if (!ae_np_read_lan_state(&state)) return -1;
state.host_slot = 1 - state.host_slot;
state.started = false;
return ae_np_write_lan_state(state) ? 0 : -1;
}
int ae_np_local_ready(void*) { return psx_lobby_local_ready(); }
int ae_np_all_ready(void*) { return psx_lobby_all_ready(); }
int ae_np_set_ready(void*, int ready) { return psx_lobby_set_ready(ready); }
int ae_np_request_start(void*, const RecompLauncherCSettings* settings) {
if (g_lnch_hosting_lan) {
AeLanLobbyState state;
if (!ae_np_read_lan_state(&state) || state.joiner_name.empty()) return -1;
state.started = true;
return ae_np_write_lan_state(state) ? 0 : -1;
}
PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings);
return psx_lobby_request_start(&caps);
}
int ae_np_launch_pending(void*) {
if ((g_lnch_hosting_lan || g_lnch_joined_lan) &&
!g_lnch_pending_direct_launch.enabled) {
AeLanLobbyState state;
if (ae_np_read_lan_state(&state) && state.started) {
g_lnch_pending_direct_launch = {};
g_lnch_pending_direct_launch.enabled = 1;
g_lnch_pending_direct_launch.local_slot = g_lnch_hosting_lan
? state.host_slot : 1 - state.host_slot;
g_lnch_pending_direct_launch.input_player = 0;
g_lnch_pending_direct_launch.session_id = 1;
g_lnch_pending_direct_launch.input_delay = 2;
if (g_lnch_hosting_lan) {
const size_t colon = state.endpoint.rfind(':');
const char* port = colon == std::string::npos
? "7777" : state.endpoint.c_str() + colon + 1;
std::snprintf(g_lnch_pending_direct_launch.bind_hostport,
sizeof(g_lnch_pending_direct_launch.bind_hostport),
"0.0.0.0:%s", port);
} else {
std::snprintf(g_lnch_pending_direct_launch.bind_hostport,
sizeof(g_lnch_pending_direct_launch.bind_hostport), "%s",
g_lnch_lan_guest_bind.empty()
? "0.0.0.0:0" : g_lnch_lan_guest_bind.c_str());
std::snprintf(g_lnch_pending_direct_launch.peer_hostport,
sizeof(g_lnch_pending_direct_launch.peer_hostport), "%s",
state.endpoint.c_str());
}
}
}
return g_lnch_pending_direct_launch.enabled || psx_lobby_launch_pending();
}
void ae_np_clear_launch_pending(void*) {
g_lnch_pending_direct_launch = {};
psx_lobby_clear_launch_pending();
}
int ae_np_fill_launch(void*, RecompLauncherCNetplayLaunch* out) {
if (!out) return 0;
if (g_lnch_pending_direct_launch.enabled) {
*out = g_lnch_pending_direct_launch;
return 1;
}
const PsxLobbyJoinInfo* ji = psx_lobby_join_info();
if (!ji || !ji->ok) return 0;
const PsxLobbyMatchCaps* caps = psx_lobby_match_caps();
out->enabled = 1;
out->local_slot = ji->local_slot;
out->input_player = 0;
std::snprintf(out->bind_hostport, sizeof(out->bind_hostport), "%s", ji->bind_hostport);
std::snprintf(out->peer_hostport, sizeof(out->peer_hostport), "%s", ji->peer_hostport);
out->session_id = ji->session_id;
out->input_delay = (caps && caps->valid) ? caps->input_delay : 2;
return 1;
}
const char* ae_np_last_error(void*) {
const PsxLobbyJoinInfo* ji = psx_lobby_join_info();
return (ji && ji->last_error[0]) ? ji->last_error : nullptr;
}
void ae_np_clear_last_error(void*) {
PsxLobbyJoinInfo* ji = const_cast<PsxLobbyJoinInfo*>(psx_lobby_join_info());
if (ji) ji->last_error[0] = '\0';
}
RecompLauncherCNetplayCallbacks g_lnch_netplay_callbacks = {
nullptr,
ae_np_default_url,
ae_np_set_lobby_url,
ae_np_connect,
ae_np_connected,
ae_np_pump,
ae_np_set_player_name,
ae_np_player_name,
ae_np_request_list,
ae_np_list_count,
ae_np_list_get,
ae_np_local_ip,
ae_np_external_ip,
ae_np_create,
ae_np_join,
ae_np_leave,
ae_np_in_lobby,
ae_np_is_host,
ae_np_member_count,
ae_np_member_get,
ae_np_move_member,
ae_np_local_ready,
ae_np_all_ready,
ae_np_set_ready,
ae_np_request_start,
ae_np_launch_pending,
ae_np_clear_launch_pending,
ae_np_fill_launch,
ae_np_local_address_get,
nullptr, /* kick_member — optional */
ae_np_last_error,
ae_np_clear_last_error,
};
} // namespace
#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;
/* CLI overrides for running several instances side by side (soak fleet).
* These win over any game-config value and, crucially, work for the BIOS
* (which has no [game]-block config schema, so debug_port/renderer can't be
* supplied via --game). -1 = "not set on the CLI". */
int cli_debug_port = -1;
int cli_renderer = -1; /* 0=software 1=opengl 2=vulkan */
const char* cli_window_title = nullptr; /* label windows in a fleet */
const char* cli_memcard_dir = nullptr; /* isolate writable state in a fleet */
PsxNetplayConfig net_cfg;
psx_netplay_config_defaults(&net_cfg);
psx_netplay_apply_env(&net_cfg); /* CLI flags below win over env */
/* 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
* --debug-port <n> override the TCP debug-server port (multi-instance)
* --memcard-dir <path> override card/save/options state (multi-instance)
* --renderer <name> override the renderer: software|opengl|vulkan
* --launcher force the GUI launcher (overrides skip_launcher)
* --no-launcher skip the GUI launcher (boot straight in)
* --headless skip SDL window/audio; use TCP screenshots/state
* --netplay enable delay-sync LAN (also PSX_NETPLAY=1)
* --net-slot N local player slot (0|1)
* --net-input-player N host device to sample (0=P1, 1=P2; default auto)
* --net-bind H:P local UDP bind (default 0.0.0.0:7777)
* --net-peer H:P peer host:port
* --net-delay N input delay in sim ticks (default 2)
* --net-session-id N must match peer (default 1)
* <positional> deprecated alias for --bios
* No --game-root flag: that remains 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], "--debug-port") == 0 && i + 1 < argc) {
cli_debug_port = std::atoi(argv[++i]);
} else if (std::strcmp(argv[i], "--memcard-dir") == 0 && i + 1 < argc) {
cli_memcard_dir = argv[++i];
} else if (std::strcmp(argv[i], "--renderer") == 0 && i + 1 < argc) {
const char* r = argv[++i];
if (std::strcmp(r, "software") == 0) cli_renderer = 0;
else if (std::strcmp(r, "opengl") == 0) cli_renderer = 1;
else if (std::strcmp(r, "vulkan") == 0) cli_renderer = 2;
} else if (std::strcmp(argv[i], "--window-title") == 0 && i + 1 < argc) {
cli_window_title = 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 (std::strcmp(argv[i], "--headless") == 0) {
g_headless = 1;
force_no_launcher = true;
} else if (std::strcmp(argv[i], "--netplay") == 0) {
net_cfg.enabled = 1;
} else if (std::strcmp(argv[i], "--net-slot") == 0 && i + 1 < argc) {
net_cfg.enabled = 1;
net_cfg.local_slot = std::atoi(argv[++i]);
} else if (std::strcmp(argv[i], "--net-input-player") == 0 && i + 1 < argc) {
net_cfg.enabled = 1;
net_cfg.input_player = std::atoi(argv[++i]);
} else if (std::strcmp(argv[i], "--net-bind") == 0 && i + 1 < argc) {
net_cfg.enabled = 1;
std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", argv[++i]);
} else if (std::strcmp(argv[i], "--net-peer") == 0 && i + 1 < argc) {
net_cfg.enabled = 1;
std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", argv[++i]);
} else if (std::strcmp(argv[i], "--net-delay") == 0 && i + 1 < argc) {
net_cfg.enabled = 1;
net_cfg.input_delay = std::atoi(argv[++i]);
} else if (std::strcmp(argv[i], "--net-session-id") == 0 && i + 1 < argc) {
net_cfg.enabled = 1;
net_cfg.session_id = (uint32_t)std::strtoul(argv[++i], nullptr, 10);
} else if (argv[i][0] != '-') {
/* The positional BIOS alias predates the named CLI. Consume it at
* most once. In particular, never let a stray split argument
* replace an explicit --bios path (for example when a Windows
* launcher fails to quote a multi-word --window-title value). */
if (!bios_from_cli) {
bios_path = argv[i];
bios_from_cli = true;
} else {
std::fprintf(stderr,
"psxrecomp: ignoring unexpected positional argument after BIOS selection: %s\n",
argv[i]);
}
}
}
if (const char *e = std::getenv("PSX_HEADLESS")) {
if (e[0] && e[0] != '0') {
g_headless = 1;
force_no_launcher = 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();
}
} else if (!std::filesystem::path(game_config_path).is_absolute()) {
// An explicit --game with a relative path must ALSO anchor on the exe
// dir, never cwd — otherwise the disc / memcard_dir / game_options.toml
// that resolve against this file's parent silently point at cwd. Resolve
// it the same way the default path is resolved.
std::filesystem::path resolved =
resolve_existing_runtime_path(game_config_path, argv[0]);
if (!resolved.empty()) {
default_game_config_storage = resolved.string();
game_config_path = default_game_config_storage.c_str();
}
}
/* Record the resolved config path for the TCP fmv_state report (observability:
* proves which game.toml actually loaded — CLI --game vs the baked default). */
g_active_config_path = game_config_path;
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). */
/* Dev builds default Player 1 to the first connected controller ("auto"):
* combined with dev-any-input (dev_any_input_enabled(), default ON) the
* selected controller, EVERY other plugged-in controller, AND the keyboard
* all drive P1 with no launcher setup. If no controller is present, "auto"
* opens nothing and the keyboard/any-controller merge still drives P1.
* Release keeps "keyboard" (the launcher assigns devices). */
#if defined(PSX_DEBUG_TOOLS)
std::string p1_device = "auto";
#else
std::string p1_device = "keyboard";
#endif
std::string p2_device = "none";
int p1_mode = PSXRecompV4::PAD_MODE_HYBRID;
int p2_mode = PSXRecompV4::PAD_MODE_HYBRID;
bool ctrl_allow_hybrid = true; /* game.toml [controller] allow_hybrid; false hides Hybrid in the launcher */
bool ctrl_lock_mode = false; /* game.toml [controller] lock_mode; true hides the whole pad-mode selector */
bool ctrl_lock_device = false; /* game.toml [controller] lock_device; true hides the Player controller cards entirely */
/* The game-DECLARED port modes, captured at game.toml load and immune to the
* settings.toml overrides below. Under lock_mode these are the only valid
* modes (the game supports exactly one pad type), so they are what the
* runtime clamps to and what the launcher locks its selector to. */
int ctrl_locked_p1_mode = PSXRecompV4::PAD_MODE_HYBRID;
int ctrl_locked_p2_mode = PSXRecompV4::PAD_MODE_HYBRID;
bool ws_offered = true; /* game.toml [widescreen] offer; false hides the launcher toggle + clamps 4:3 */
bool ws_ultrawide_offered = false;
bool ws_adaptive_view_supported = false;
bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */
int resolved_deadzone = -1; /* <0 => keep input.ini/runtime default (12000) */
/* Localization: the effective language (game.toml default -> settings.toml ->
* launcher choice), applied to the translation layer AFTER the launcher runs.
* lang_menu_options drives the launcher's "Localization" dropdown (empty =>
* no dropdown; only games that declare [localization].languages get one). */
std::string resolved_language = "en";
std::vector<PSXRecompV4::RuntimeConfig::LanguageOption> lang_menu_options;
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 game_region; /* game.toml [game] region; empty => derive from game_id serial */
int game_players = 1; /* game.toml [game] players; launcher hides the P2 row when 1 */
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 */
std::vector<PSXRecompV4::RuntimeConfig::WarmCdRoute> warm_cd_routes;
uint32_t game_entry_pc = 0;
bool fast_boot = false; /* DEPRECATED alias: HLE boot-skip only */
bool bios_hle = false; /* HLE kernel-service tier (bios_hle.c) */
bool bios_hle_keep_intro = false;
/* Text-image guard source, captured at config load; armed after the disc
* path is resolved (arm_text_image_guard). */
std::string text_guard_exe_path;
uint32_t text_guard_load_addr = 0;
if (game_config_path) {
try {
const auto gc = PSXRecompV4::load_game_config(game_config_path);
game_name = gc.name;
game_id = gc.id;
game_region = gc.region;
game_players = gc.players;
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;
/* On-the-fly string translation / localization (framework feature —
* text_xlate.cpp): load translations/ *.toml under the project root
* and select the language. Capture inventory is always-on; APPLY is
* gated by language + table presence. See docs/STRING_TRANSLATION.md. */
text_xlate_init(gc.project_root.string().c_str(),
gc.runtime.language.c_str());
resolved_language = gc.runtime.language; /* launcher/settings may override */
lang_menu_options = gc.runtime.languages; /* launcher localization dropdown */
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;
warm_cd_routes = gc.runtime.warm_cd_routes;
if (gc.runtime.turbo_loads) {
g_turbo_loads_enabled = 1;
std::fprintf(stdout, "psxrecomp: turbo_loads enabled (opt-in)\n");
}
if (gc.runtime.turbo_audio_sink) {
g_turbo_audio_sink_enabled = 1;
std::fprintf(stdout,
"psxrecomp: turbo_audio_sink enabled (opt-in)\n");
}
{
extern int g_idle_skip_enabled;
const char *idle_env = std::getenv("PSX_IDLE_SKIP");
g_idle_skip_enabled = idle_env
? (idle_env[0] == '1' ? 1 : 0)
: (gc.runtime.idle_skip ? 1 : 0);
std::fprintf(stdout, "psxrecomp: idle_skip %s%s\n",
g_idle_skip_enabled ? "enabled" : "disabled",
idle_env ? " (environment override)" : "");
psx_precise_slice_init_from_env();
}
for (uint32_t site : gc.vsync_event_horizon_sites)
psx_vsync_query_hle_add_event_horizon_site(site);
for (uint32_t site : gc.vsync_event_horizon_extra_sites)
psx_vsync_query_hle_add_extra_event_horizon_site(site);
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_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0;
g_video_vsync = gc.runtime.video_vsync;
g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0;
g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps;
g_fmv_skip_total_table = gc.runtime.video_fmv_skip_total_table;
g_fmv_skip_movie_id = gc.runtime.video_fmv_skip_movie_id;
if (gc.runtime.video_fmv_skip_end_total)
g_fmv_skip_end_total = gc.runtime.video_fmv_skip_end_total;
g_fmv_skip_no_xa = gc.runtime.video_fmv_skip_no_xa ? 1 : 0;
g_fmv_skip_no_xa_hold = gc.runtime.video_fmv_skip_no_xa_hold;
g_ws_anchor_addr = gc.ws_sprite_anchor_addr;
g_ws_hud_sprt = gc.ws_hud_sprt_squash;
/* [widescreen] full_2d — opt a pure-2D sprite game (MMX6) into the
* widescreen present path. Applied to the GPU layer up front so the
* ws engage at game entry classifies every frame as gameplay. */
gpu_ws_set_full_2d(gc.ws_full_2d ? 1 : 0);
/* [widescreen.bg2d] engine tile-ring layout for the freshness
* refill shared by the MMX5/MMX6 2D background hook. */
gpu_ws_bg2d_configure(gc.ws_bg2d_layer_base,
gc.ws_bg2d_ring_base,
gc.ws_bg2d_map_size_addr,
gc.ws_bg2d_layer_stride_addr,
gc.ws_bg2d_ring_cols,
gc.ws_bg2d_layer_count,
gc.ws_bg2d_layer_struct_stride,
gc.ws_bg2d_packet_cap);
/* [widescreen] gte_game_mode — 3D-title gameplay detector (Ape). */
gpu_ws_set_gte_game_mode(gc.ws_gte_game_mode ? 1 : 0);
/* Keep titles with known native-wide regressions on the original
* projection-squash + stretched-present widescreen path. */
g_ws_native_wide = gc.ws_native_wide ? 1 : 0;
/* [widescreen] nw_hud_corners — push HUD to the true wide corners. */
gpu_ws_set_nw_hud_corners(gc.ws_nw_hud_corners ? 1 : 0);
/* Targeted left-HUD packet range — avoids shifting 2D scenery. */
gpu_ws_set_nw_left_hud_packet_range(gc.ws_nw_left_hud_packet_lo,
gc.ws_nw_left_hud_packet_hi);
/* [widescreen] nw_backdrop — stretch full-frame 2D sky backdrop. */
gpu_ws_set_nw_backdrop(gc.ws_nw_backdrop ? 1 : 0);
/* [widescreen] nw_flat_backdrop — stretch flat sky/backdrop prims
* in the native-wide mirror, preserving the canonical 4:3 image. */
gpu_ws_set_nw_flat_backdrop(gc.ws_nw_flat_backdrop ? 1 : 0);
/* [widescreen] nw_phase_backdrop — stretch only the textured
* backdrop phase emitted before shaded 3D foreground geometry. */
gpu_ws_set_nw_phase_backdrop(gc.ws_nw_phase_backdrop ? 1 : 0);
gpu_ws_set_nw_textured_edges(gc.ws_nw_textured_edges ? 1 : 0,
gc.ws_nw_textured_edge_scale);
gl_renderer_set_wide_fast(gc.ws_nw_full_mirror ? 0 : 1);
{
std::vector<uint32_t> addresses, expected;
addresses.reserve(gc.ws_signed_x_bound_sites.size());
expected.reserve(gc.ws_signed_x_bound_sites.size());
for (const auto& site : gc.ws_signed_x_bound_sites) {
addresses.push_back(site.address);
expected.push_back(site.expected);
}
gpu_ws_set_signed_x_bound_sites(addresses.data(), expected.data(),
(int)addresses.size());
}
/* [widescreen] clear_reveal — enable opted-in scene/map-boundary
* cleanup of synthetic native-wide margins. */
gpu_ws_set_clear_reveal(gc.ws_clear_reveal ? 1 : 0);
gpu_ws_set_cull_guard_pixels(gc.ws_cull_guard_pixels);
gpu_ws_set_explicit_cull_sites(
gc.ws_cull_bias_sites.data(), (int)gc.ws_cull_bias_sites.size(),
gc.ws_cull_slti_sites.data(), (int)gc.ws_cull_slti_sites.size(),
gc.ws_cull_range_sites.data(), (int)gc.ws_cull_range_sites.size());
gpu_ws_set_negsub_cull_sites(
gc.ws_cull_negsub_sites.data(), (int)gc.ws_cull_negsub_sites.size());
gpu_ws_set_vxrange_cull_sites(
gc.ws_cull_vxrange_sites.data(), (int)gc.ws_cull_vxrange_sites.size());
gpu_ws_set_depth_cull_sites(
gc.ws_cull_depth_sites.data(), (int)gc.ws_cull_depth_sites.size());
gpu_ws_set_plane_nx_sites(
gc.ws_cull_plane_nx_sites.data(), (int)gc.ws_cull_plane_nx_sites.size());
gpu_ws_set_xclip_load_sites(
gc.ws_cull_xclip_load_sites.data(), (int)gc.ws_cull_xclip_load_sites.size());
gte_ws_configure_dome_sites(
gc.ws_dome_call_sites.data(), (int)gc.ws_dome_call_sites.size());
/* [widescreen.cull] per-game gates + signature immediates for the
* pattern-scanned interp widen hooks. A title that never opted in
* must never have its live code scanned and rewritten. */
gpu_ws_set_auto_hooks(gc.ws_auto_screen_x_cull ? 1 : 0,
gc.ws_auto_backdrop_preload ? 1 : 0);
if (!gc.ws_cull_w_imms.empty() || !gc.ws_cull_h_imms.empty())
gpu_ws_set_cull_imms(gc.ws_cull_w_imms.data(), (int)gc.ws_cull_w_imms.size(),
gc.ws_cull_h_imms.data(), (int)gc.ws_cull_h_imms.size());
ws_offered = gc.ws_offered;
ws_ultrawide_offered = gc.ws_ultrawide_offered;
ws_adaptive_view_supported = gc.ws_adaptive_view;
vulkan_offered = gc.vulkan_offered;
/* Register the [widescreen.backdrop] store PCs so the dirty-RAM
* interpreter applies the backdrop screenX squash on the interp
* path (overlay backdrop handlers run interpreted when no cache
* DLL is loaded — the recompiler emit only covers native). */
if (!gc.ws_backdrop_x_sites.empty())
psx_ws_set_backdrop_sites(gc.ws_backdrop_x_sites.data(),
(int)gc.ws_backdrop_x_sites.size());
g_audio_spu_hq = gc.runtime.audio_spu_hq;
g_auto_skip_fmv = gc.runtime.video_auto_skip_fmv ? 1 : 0;
/* [controller] game-declared input defaults (settings.toml/launcher
* still override below). */
if (gc.runtime.has_default_mode) {
p1_mode = gc.runtime.default_p1_mode;
p2_mode = gc.runtime.default_p2_mode;
}
ctrl_locked_p1_mode = gc.runtime.default_p1_mode;
ctrl_locked_p2_mode = gc.runtime.default_p2_mode;
ctrl_allow_hybrid = gc.runtime.controller_allow_hybrid;
ctrl_lock_mode = gc.runtime.controller_lock_mode;
ctrl_lock_device = gc.runtime.controller_lock_device;
if (gc.runtime.has_deadzone) resolved_deadzone = gc.runtime.deadzone;
/* LEGACY per-game pad-config opt-in (default modern). Only Tomba sets
* it, so its launcher Hybrid mode's analog<->digital flip doesn't make
* libpad manufacture a 1-frame "pad unplugged". sio_init() does not
* touch this flag, so applying it here (config-load time) is stable.
* Full history + removal plan: psxrecomp sio.c g_pad_legacy_cfg. */
sio_set_legacy_cfg(gc.runtime.legacy_pad_config ? 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;
bios_hle = gc.runtime.bios_hle;
bios_hle_keep_intro = gc.runtime.bios_hle_keep_intro;
/* Let the dispatch layer distinguish "dirty because text was
* loaded" from "diverged because runtime wrote different code over
* the original EXE image". Packed/self-modifying games can rewrite
* their own text; those pages must execute from live RAM, not stale
* static native code. Arming is DEFERRED until the disc path is
* resolved (arm_text_image_guard below): release installs have no
* local EXE file, so the guard extracts the boot EXE from the
* user's disc image instead. */
text_guard_exe_path = gc.exe_path.string();
text_guard_load_addr = gc.load_address;
/* HLE-tier scheduler subsystem replacement default (env
* PSX_HLE_SCHEDULER still wins; latched at first dispatch). */
psx_hle_scheduler_set_default(gc.runtime.hle_scheduler ? 1 : 0);
/* Pin the overlay-region floor to THIS game's main-EXE text end so
* runtime-loaded overlays (which load just above it) are dispatched
* via in-interpreter local-flow chaining, NOT the slow block-by-block
* + bail-prone non-local-call path. Hardcoding the floor to Tomba 1's
* text end (0x98000) wedged Tomba 2 (text ends 0x38800, overlays at
* 0x85000+) at the Whoopee-Camp splash. See dirty_ram_interp.h. */
{
extern uint32_t g_overlay_region_floor;
uint32_t text_end = (gc.load_address + gc.text_size) & 0x1FFFFFFFu;
if (text_end > 0x00010000u /* DIRTY_RAM_KERNEL_WINDOW_END */)
g_overlay_region_floor = text_end;
std::fprintf(stdout,
"psxrecomp: overlay_region_floor = 0x%05X (game text end)\n",
g_overlay_region_floor);
}
/* 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();
std::filesystem::path captures_path =
resolve_overlay_capture_path(gc.project_root, exe_dir, game_id);
if (!overlay_capture_set_path(captures_path.string().c_str())) {
captures_path = exe_dir / "overlay_captures.json";
if (!overlay_capture_set_path(captures_path.string().c_str()))
throw std::runtime_error("overlay capture path exceeds runtime limit");
}
overlay_capture_set_enabled(1);
std::fprintf(stdout,
"psxrecomp: additive overlay capture store = %s (+ .d history)\n",
captures_path.string().c_str());
std::string capture_persist_dir;
if (gc.runtime.overlay_capture_history &&
!gc.runtime.overlay_capture_persist_dir.empty()) {
std::filesystem::path persist =
gc.project_root / gc.runtime.overlay_capture_persist_dir;
std::error_code persist_ec;
std::filesystem::create_directories(persist, persist_ec);
if (persist_ec) {
std::fprintf(stderr,
"psxrecomp: cannot create overlay capture history %s: %s\n",
persist.string().c_str(), persist_ec.message().c_str());
} else {
capture_persist_dir = persist.string();
}
}
overlay_capture_configure_history(
gc.runtime.overlay_capture_history ? 1 : 0,
capture_persist_dir.empty() ? nullptr :
capture_persist_dir.c_str(),
game_id.c_str());
overlay_loader_init(cache_dir.c_str(), game_id.c_str());
for (uint32_t addr : gc.runtime.overlay_native_block) {
overlay_loader_native_block_add(addr);
}
if (!gc.runtime.overlay_native_block.empty()) {
std::fprintf(stdout,
"psxrecomp: overlay native blocklist seeded with %zu entr%s\n",
gc.runtime.overlay_native_block.size(),
gc.runtime.overlay_native_block.size() == 1 ? "y" : "ies");
}
/* Scoped pre-DMA journaling and the shutdown snapshot remain
* active whenever the cache is on, including toolchain-less
* production machines. The periodic pressure trigger exists to
* feed a live compiler; leave it off when no provider command
* exists, otherwise it rewrites manifests every cooldown while
* being unable to reduce interpreter residency. */
overlay_autocapture_set_enabled(0);
/* Resolve the overlay tier first so we wire the RIGHT compiler's
* autocompile command. gcc is "available" only when a gcc cmd is
* configured AND a gcc toolchain is actually reachable (a real
* dev/production box). auto => gcc if so, else tcc; auto-no-gcc =>
* tcc even with gcc present (simulate a toolchain-less user box).
* env PSX_OVERLAY_BACKEND overrides. Tiers: static > gcc > tcc >
* interp. */
const char *cfg_backend = gc.runtime.overlay_backend.empty()
? nullptr : gc.runtime.overlay_backend.c_str();
int gcc_avail = gc.runtime.has_overlay_autocompile_cmd
&& autocompile_toolchain_available();
OverlayBackend eff = overlay_backend_resolve(cfg_backend, gcc_avail);
/* gcc and tcc run the IDENTICAL recompiler->C->DLL->load pipeline;
* only the compiler binary differs. Wire the autocompile spawn with
* the command for the resolved tier (tcc cmd for the tcc tier, gcc
* cmd otherwise). gcc shards already on disk still LOAD either way
* (the loader is compiler-blind), so a tcc box uses shipped gcc
* shards first and fills the rest with tcc. */
std::string built_tcc_cmd; /* runtime-constructed bundled tcc cmd */
std::string env_ac_cmd;
const std::string *ac_cmd = nullptr;
if (eff == OVERLAY_BACKEND_TCC) {
if (gc.runtime.has_overlay_autocompile_cmd_tcc) {
ac_cmd = &gc.runtime.overlay_autocompile_cmd_tcc; /* explicit override (dev) */
} else {
/* PRODUCTION: construct the tcc autocompile cmd from the
* self-contained toolchain bundled beside the exe
* (<exe>/overlay_toolchain/ = embedded python + tcc +
* recompiler + compile_overlays.py + runtime headers). No
* system python or gcc required. */
extern int g_psx_cps_mode;
std::filesystem::path xd = exe_dir_from_argv(argv[0]);
std::filesystem::path tk = xd / "overlay_toolchain";
std::filesystem::path py = tk / "python" / "python.exe";
if (std::filesystem::exists(py)) {
auto cmd_quote = [](const std::string& s) {
return std::string("\"") + s + "\"";
};
built_tcc_cmd =
cmd_quote(py.string()) + " " +
cmd_quote((tk / "compile_overlays.py").string()) +
" --captures " + cmd_quote(captures_path.string()) +
" --game-toml " + cmd_quote(std::string(
game_config_path ? game_config_path : "game.toml")) +
" --recompiler " + cmd_quote((tk / "psxrecomp-game.exe").string()) +
" --runtime-include " + cmd_quote((tk / "include").string()) +
" --out-dir " + cmd_quote((xd / "cache").string()) +
(g_psx_cps_mode ? " --cps" : "") +
" --compiler tcc --tcc " +
cmd_quote((tk / "tcc" / "tcc.exe").string());
ac_cmd = &built_tcc_cmd;
std::fprintf(stdout,
"psxrecomp: tcc tier using bundled toolchain (%s)\n",
tk.string().c_str());
} else {
std::fprintf(stdout,
"psxrecomp: tcc tier active but no bundled toolchain at %s "
"(overlay gaps -> interpreter)\n", tk.string().c_str());
}
}
} else {
if (gc.runtime.has_overlay_autocompile_cmd)
ac_cmd = &gc.runtime.overlay_autocompile_cmd;
}
/* A developer may run a game config from one checkout against a
* runtime/recompiler built in another worktree. Let the launch
* pin the producer command to that exact worktree so the baked
* codegen hash, additive-capture reader, and runtime headers
* cannot silently drift through a game-repo junction. */
if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CMD")) {
if (e[0]) {
env_ac_cmd = e;
ac_cmd = &env_ac_cmd;
std::fprintf(stdout,
"psxrecomp: overlay autocompile command overridden by environment\n");
}
}
if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_OFF")) {
if (e[0] && e[0] != '0') {
ac_cmd = nullptr;
std::fprintf(stdout,
"psxrecomp: overlay autocompile disabled by environment\n");
}
}
if (ac_cmd) {
/* Pin the compile's WRITE cache + READ captures to the SAME
* canonical locations the loader uses (cache_dir = <exe>/cache,
* <exe>/overlay_captures.json — set above). The framework owns
* the cache location; no game.toml --out-dir/--captures can make
* the write drift from the read. Single source of truth, all
* games, dev or prod. */
autocompile_set_cache_paths(cache_dir.c_str(),
captures_path.string().c_str());
std::string ac_cwd = gc.project_root.string();
if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CWD")) {
if (e[0]) ac_cwd = e;
}
autocompile_configure(ac_cmd->c_str(), ac_cwd.c_str());
overlay_autocapture_set_enabled(1);
std::fprintf(stdout,
"psxrecomp: overlay autocompile enabled (%s); cache=%s; captures=%s\n",
overlay_backend_name(eff), cache_dir.c_str(),
captures_path.string().c_str());
}
code_provider_init(cfg_backend, gcc_avail);
/* (sljit removed 2026-07-15: overlay_loader_apply_live_policy was
* called here once the backend resolved.) */
}
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::string netplay_player_name; /* [netplay] player_name from settings.toml */
bool has_netplay_player_name = false;
{
std::filesystem::path settings_path =
exe_dir_from_argv(argv[0]) / "settings.toml";
#if defined(RECOMP_LAUNCHER)
g_lnch_settings_path = settings_path;
#endif
const PSXRecompV4::UserSettings us =
PSXRecompV4::load_user_settings(settings_path);
if (us.parse_error) {
/* The file exists but is not valid TOML: every setting in it (the
* user's renderer choice, BIOS/disc paths, ...) is being ignored,
* and any later launcher save would overwrite it with defaults.
* Preserve their file and say so loudly instead of both failing
* silently (GH Tomba2Recomp#1 triage: a stray character disabled a
* user's whole settings file with no indication anywhere). */
std::error_code rn_ec;
std::filesystem::path bad = settings_path;
bad += ".bad";
std::filesystem::remove(bad, rn_ec);
rn_ec.clear();
std::filesystem::rename(settings_path, bad, rn_ec);
launcher_warning("Settings file ignored",
"settings.toml has a TOML syntax error, so ALL settings in it were "
"ignored this run (renderer, BIOS/disc paths, everything).\n\n" +
(rn_ec ? "The broken file was left at:\n" + settings_path.string()
: "The broken file was preserved as:\n" + bad.string()) +
"\n\nFix the syntax error and restore the file, or set your options "
"again from the launcher (a fresh settings.toml will be written).");
}
if (us.has_skip_launcher) skip_launcher_setting = us.skip_launcher;
if (us.has_renderer) {
if (us.renderer == 2 && !vulkan_offered) {
g_video_renderer = 1;
std::fprintf(stdout,
"psxrecomp: settings requested Vulkan, but this game does not "
"offer Vulkan in the launcher; using OpenGL.\n");
} else {
g_video_renderer = us.renderer;
}
}
if (us.has_netplay_player_name && !us.netplay_player_name.empty()) {
netplay_player_name = us.netplay_player_name;
has_netplay_player_name = true;
}
#if defined(RECOMP_LAUNCHER)
if (us.has_netplay_lobby_url && !us.netplay_lobby_url.empty())
g_lnch_lobby_url = us.netplay_lobby_url;
#endif
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_turbo_loads) g_turbo_loads_enabled = us.turbo_loads ? 1 : 0;
if (us.has_fast_boot) fast_boot = us.fast_boot;
if (us.has_bios_hle) bios_hle = us.bios_hle;
if (us.has_fullscreen) g_fullscreen = us.fullscreen;
if (us.has_aspect_ratio) {
g_video_aspect_num = us.aspect_num;
g_video_aspect_den = us.aspect_den;
}
if (us.has_adaptive_view) g_ws_adaptive_view = us.adaptive_view;
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 = normalize_disc_path_for_launch(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_language) resolved_language = us.language;
if (us.has_p1_device) p1_device = us.p1_device;
if (us.has_p2_device) p2_device = us.p2_device;
if (us.has_p1_mode) p1_mode = us.p1_mode;
if (us.has_p2_mode) p2_mode = us.p2_mode;
if (us.has_deadzone) resolved_deadzone = us.deadzone;
if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0;
if (us.has_vsync) g_video_vsync = us.vsync;
if (us.has_frame_interpolation)
g_frame_interpolation = us.frame_interpolation ? 1 : 0;
if (us.has_frame_interpolation_fps)
g_frame_interpolation_fps = us.frame_interpolation_fps;
}
/* lock_mode: the game supports exactly ONE pad type (e.g. X4 / Tomba 2 are
* digital-only — X4's pre-DualShock libpad silently discards input from a
* pad answering id 0x73). The launcher hides its selector for such games,
* but that alone left two holes: (a) launcher-less builds still honoured a
* settings.toml p1_mode/p2_mode, and (b) a settings.toml persisted BEFORE
* the game declared lock_mode fed the stale mode back as the launcher's
* locked_mode. Clamp to the game-declared modes here, after every
* config/settings source has been applied, so a locked game can never boot
* a pad type it doesn't support. */
if (ctrl_lock_mode) {
p1_mode = ctrl_locked_p1_mode;
p2_mode = ctrl_locked_p2_mode;
}
/* [widescreen] offer=false: this title's widescreen is unported/unvalidated,
* so the launcher hides its toggle — and, same completeness treatment as
* lock_mode above, the runtime clamps the display aspect to native 4:3 here
* so a stale persisted 16:9 in settings.toml can't engage the hack in
* launcher-less builds either. */
if (!ws_offered && (g_video_aspect_num != 4 || g_video_aspect_den != 3)) {
std::fprintf(stdout, "psxrecomp: widescreen not offered for this title; "
"clamping display aspect %d:%d -> 4:3\n",
g_video_aspect_num, g_video_aspect_den);
g_video_aspect_num = 4;
g_video_aspect_den = 3;
}
if (!ws_ultrawide_offered && g_video_aspect_num * 9 == g_video_aspect_den * 21) {
std::fprintf(stdout, "psxrecomp: 21:9 is not offered for this title; clamping to %s\n",
ws_offered ? "16:9" : "4:3");
g_video_aspect_num = ws_offered ? 16 : 4;
g_video_aspect_den = ws_offered ? 9 : 3;
}
if (!ws_adaptive_view_supported) g_ws_adaptive_view = false;
g_ws_adaptive_max_num = ws_ultrawide_offered ? 21 : 16;
g_ws_adaptive_max_den = 9;
/* Latency knobs: env overrides win over config (for A/B measurement).
* PSX_LOW_LATENCY_INPUT=0/1 ; PSX_VSYNC=1(vsync)/0(immediate)/-1(adaptive);
* PSX_FRAME_INTERPOLATION=0/1; PSX_FRAME_INTERPOLATION_FPS=0|90+. */
if (const char *e = std::getenv("PSX_LOW_LATENCY_INPUT")) g_low_latency_input = atoi(e) ? 1 : 0;
if (const char *e = std::getenv("PSX_VSYNC")) g_video_vsync = atoi(e);
if (const char *e = std::getenv("PSX_SMOOTH_60FPS"))
psx_smooth_60fps_set(atoi(e) ? 1 : 0);
if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION"))
g_frame_interpolation = atoi(e) ? 1 : 0;
if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION_FPS")) {
int fps = atoi(e);
if (fps == 0 || fps >= 90) g_frame_interpolation_fps = fps;
}
/* Apply writable-state isolation before game-options and launcher setup,
* not with the later renderer/port overrides. Explicit slot paths from a
* shared settings.toml must not escape the isolated directory. */
if (cli_memcard_dir) {
memcard_dir = std::filesystem::path(cli_memcard_dir);
if (memcard_dir.is_relative())
memcard_dir = exe_dir_from_argv(argv[0]) / memcard_dir;
memcard_dir = memcard_dir.lexically_normal();
memcard1_path.clear();
memcard2_path.clear();
std::error_code memcard_ec;
std::filesystem::create_directories(memcard_dir, memcard_ec);
if (memcard_ec) {
std::fprintf(stderr,
"psxrecomp: cannot create --memcard-dir %s: %s\n",
memcard_dir.string().c_str(), memcard_ec.message().c_str());
return 1;
}
std::fprintf(stdout, "psxrecomp: CLI writable-state directory = %s\n",
memcard_dir.string().c_str());
}
/* 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]);
/* The game's OWN native OPTION settings (game_options.toml, next to
* game.toml) — persisted across launches, kept separate from game.toml
* (recomp config) and settings.toml (launcher). Values are saved to
* <memcard_dir>/<game_id>.options. Best-effort: a malformed file disables
* the feature, it never blocks boot. */
if (game_config_path) {
try {
std::filesystem::path go_path =
std::filesystem::path(game_config_path).parent_path() / "game_options.toml";
const PSXRecompV4::GameOptions go = PSXRecompV4::load_game_options(go_path);
if (!go.options.empty()) {
std::vector<uint32_t> go_addrs;
std::vector<uint8_t> go_sizes;
std::vector<const char*> go_names;
std::vector<int32_t> go_vmins;
std::vector<int32_t> go_vmaxs;
for (const auto& o : go.options) {
go_addrs.push_back(o.addr);
go_sizes.push_back((uint8_t)o.size);
go_names.push_back(o.name.c_str());
/* Declared range validates the persisted value at restore; no
* range => full int32 span (accept anything that fits). */
go_vmins.push_back(o.has_range ? (int32_t)o.vmin : (int32_t)0x80000000);
go_vmaxs.push_back(o.has_range ? (int32_t)o.vmax : (int32_t)0x7FFFFFFF);
}
std::string go_state = (memcard_dir /
((game_id.empty() ? std::string("game") : game_id) + ".options")).string();
game_options_configure(go_state.c_str(), go_addrs.data(), go_sizes.data(),
go_names.data(), go_vmins.data(), go_vmaxs.data(),
(int)go_addrs.size());
std::fprintf(stdout,
"psxrecomp: game options persistence armed (%d field(s)) -> %s\n",
(int)go_addrs.size(), go_state.c_str());
}
} catch (const std::exception& ex) {
std::fprintf(stderr, "psxrecomp: game_options.toml ignored: %s\n", ex.what());
}
}
#if defined(RECOMP_LAUNCHER)
/* Integrated recomp-ui 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) {
int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */
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.turbo_loads = (g_turbo_loads_enabled != 0); seed.has_turbo_loads = true;
seed.fast_boot = fast_boot; seed.has_fast_boot = true;
seed.bios_hle = bios_hle; seed.has_bios_hle = true;
seed.fullscreen = g_fullscreen; seed.has_fullscreen = true;
seed.frame_interpolation = (g_frame_interpolation != 0);
seed.has_frame_interpolation = true;
seed.frame_interpolation_fps = g_frame_interpolation_fps;
seed.has_frame_interpolation_fps = true;
seed.aspect_num = g_video_aspect_num;
seed.aspect_den = g_video_aspect_den; seed.has_aspect_ratio = true;
seed.adaptive_view = g_ws_adaptive_view; seed.has_adaptive_view = 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 (has_netplay_player_name) {
seed.netplay_player_name = netplay_player_name;
seed.has_netplay_player_name = true;
}
if (!g_lnch_lobby_url.empty()) {
seed.netplay_lobby_url = g_lnch_lobby_url;
seed.has_netplay_lobby_url = 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.language = resolved_language; seed.has_language = true;
seed.p1_device = p1_device; seed.has_p1_device = true;
seed.p2_device = p2_device; seed.has_p2_device = true;
seed.p1_mode = p1_mode; seed.has_p1_mode = true;
seed.p2_mode = p2_mode; seed.has_p2_mode = true;
seed.deadzone = resolved_deadzone >= 0 ? resolved_deadzone : 12000;
seed.has_deadzone = true;
seed.window_width = g_video_win_w; seed.has_window_width = true;
/* recomp-ui creates + owns its SDL2/GL window internally, so there
* is no launcher window/context to manage here. */
std::string assets_dir_str = exe_dir_from_argv(argv[0]).string();
std::string rui_initial_disc = resolved_disc.string();
std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name)
+ " - Launcher";
RecompLauncherCSettings ls{};
ls.output_method = 2; /* OpenGL */
ls.window_scale = std::max(1, std::min(4, g_video_win_w / 320));
ls.fullscreen = seed.fullscreen;
ls.ignore_aspect = 0;
ls.linear_filter = (seed.texture_filter != 0) ? 1 : 0;
ls.widescreen = (seed.aspect_num == 16 && seed.aspect_den == 9) ? 1 : 0;
ls.widescreen_hud = ls.widescreen;
ls.enable_audio = 1;
ls.audio_freq = 44100;
ls.volume = 100;
ls.player_src[0] = PSXRecompV4::launcher_source_from_device(p1_device);
ls.player_src[1] = PSXRecompV4::launcher_source_from_device(p2_device);
#if defined(RECOMP_LAUNCHER_HAS_PLAYER_GAMEPAD_GUID)
if (ls.player_src[0] == 2) {
std::snprintf(ls.player_gamepad_guid[0],
sizeof(ls.player_gamepad_guid[0]), "%s",
p1_device.c_str());
}
if (ls.player_src[1] == 2) {
std::snprintf(ls.player_gamepad_guid[1],
sizeof(ls.player_gamepad_guid[1]), "%s",
p2_device.c_str());
}
#endif
{
int rui_deadzone_pct = seed.deadzone * 100 / 32767;
ls.deadzone[0] = rui_deadzone_pct;
ls.deadzone[1] = rui_deadzone_pct;
}
ls.skip_launcher = seed.skip_launcher ? 1 : 0;
ls.msu1_enabled = 0;
ls.msu1_dir[0] = '\0';
std::snprintf(ls.netplay_player_name, sizeof(ls.netplay_player_name), "%s",
has_netplay_player_name ? netplay_player_name.c_str() : "");
ls.pad_mode[0] = seed.p1_mode;
ls.pad_mode[1] = seed.p2_mode;
/* Adaptive is part of the aspect vocabulary, not a second toggle.
* It follows 21:9 when ultrawide is offered, otherwise 16:9. */
const int adaptive_aspect_index = ws_ultrawide_offered ? 3 : 2;
ls.aspect_index = seed.adaptive_view ? adaptive_aspect_index :
(seed.aspect_num * 9 == seed.aspect_den * 21) ? 2 :
(seed.aspect_num == 16 && seed.aspect_den == 9) ? 1 : 0;
ls.adaptive_view = seed.adaptive_view ? 1 : 0;
/* ---- deeper PSX-style settings (capability-gated via launcher_profile
* below). Sourced 1:1 from PSXRecompV4::UserSettings (config_loader.h). */
ls.window_width = seed.window_width;
ls.renderer = seed.renderer;
if (ls.renderer < 0 || ls.renderer > (vulkan_offered ? 2 : 1))
ls.renderer = 1;
ls.supersampling = seed.supersampling;
ls.antialiasing = seed.antialiasing ? 1 : 0;
ls.texture_filter = seed.texture_filter;
ls.screen_kind = seed.screen_kind;
ls.frame_interp = seed.frame_interpolation ? 1 : 0;
ls.frame_interp_fps = seed.frame_interpolation_fps;
ls.spu_hq = seed.spu_hq ? 1 : 0;
ls.auto_skip_fmv = seed.auto_skip_fmv ? 1 : 0;
ls.turbo_loads = seed.turbo_loads ? 1 : 0;
/* Localization: index of resolved_language within lang_menu_options
* (match by code; games with no [runtime].languages list leave
* lang_menu_options empty and this stays 0 / unused). */
ls.language_index = 0;
for (size_t li = 0; li < lang_menu_options.size(); li++) {
if (lang_menu_options[li].code == resolved_language) {
ls.language_index = (int)li;
break;
}
}
{
std::string bios_str = seed.bios_path.string();
std::snprintf(ls.bios_path, sizeof(ls.bios_path), "%s", bios_str.c_str());
}
/* Memory-card slots: point each slot at the REAL card the runtime
* will use — an explicit settings.toml path if set, else the default
* <memcard_dir>/cardN.mcd the runtime derives — so the launcher's
* block grids reflect the actual on-disk saves (memcard_inspect). */
{
std::string mc1 = seed.has_memcard1_path ? seed.memcard1_path.string()
: (memcard_dir / "card1.mcd").string();
std::string mc2 = seed.has_memcard2_path ? seed.memcard2_path.string()
: (memcard_dir / "card2.mcd").string();
std::snprintf(ls.memcard_path[0], sizeof(ls.memcard_path[0]), "%s", mc1.c_str());
std::snprintf(ls.memcard_path[1], sizeof(ls.memcard_path[1]), "%s", mc2.c_str());
}
ls.memcard_enabled[0] = seed.memcard1_enabled ? 1 : 0;
ls.memcard_enabled[1] = seed.memcard2_enabled ? 1 : 0;
/* Region badge: game.toml [game] region wins verbatim; otherwise derive
* from the game_id serial prefix (SCUS/SLUS/LSP -> USA, SCES/SLES ->
* Europe, SCPS/SLPS/SLPM -> Japan). Empty/unknown -> no badge (gi.region
* left null; the launcher model treats that as "" and hides the row). */
const std::string rui_region =
!game_region.empty() ? game_region : region_label_from_serial(game_id);
/* Localization: one C-string array pointing at lang_menu_options'
* own storage (that vector outlives this call, it's a main()-scope
* local), so no separate copy is needed. Empty when the game declares
* no [runtime].languages -> Localization stays hidden (num_languages=0). */
std::vector<const char*> rui_lang_labels;
rui_lang_labels.reserve(lang_menu_options.size());
for (const auto& lo : lang_menu_options) rui_lang_labels.push_back(lo.label.c_str());
RecompLauncherCGameInfo gi{};
static const char* const kPsxRendererLabels[] = {
"Software",
"OpenGL (Recommended)",
"Vulkan"
};
static const char* const kAdaptiveAspectLabels[] = {
"4:3 (Native)",
"16:9 (Widescreen)",
"21:9 (Ultrawide)",
"Adaptive"
};
static const char* const kAdaptiveAspectLabelsNoUltrawide[] = {
"4:3 (Native)",
"16:9 (Widescreen)",
"Adaptive"
};
/* System identity + full PS1 settings-surface capability set (theme,
* platform label, rom_noun, pad-mode support, aspect mask base, and
* all has_* deep-settings flags) — one profile call keeps them from
* drifting apart across PSX titles. Per-game specifics below override
* only what the profile can't know. */
launcher_profile_apply("psx", &gi);
gi.name = game_name.empty() ? nullptr : game_name.c_str();
gi.region = rui_region.empty() ? nullptr : rui_region.c_str();
gi.has_expected_crc = 0; /* the launcher's simple file-CRC doesn't fit
PSX multi-track discs — skip verification */
gi.num_known_sha256 = 0;
gi.widescreen_supported = ws_offered ? 1 : 0;
gi.num_players = game_players;
gi.msu1_supported = 0;
gi.sram_path = nullptr; /* PSX uses memory cards, not SRAM -> hide SAVES */
/* Pad-mode + aspect capabilities sourced from game.toml
* [controller]/[widescreen] via GameConfig. PSX always has pad modes. */
gi.pad_mode_selectable = ctrl_lock_mode ? 0 : 1;
gi.allow_hybrid = ctrl_allow_hybrid ? 1 : 0;
gi.locked_pad_mode = p1_mode; /* force the game's declared mode (default_mode) */
gi.lock_device = ctrl_lock_device ? 1 : 0;
gi.aspect_mask = 0x1 | (ws_offered ? 0x2 : 0) | (ws_ultrawide_offered ? 0x4 : 0);
if (ws_adaptive_view_supported) {
gi.aspect_labels = ws_ultrawide_offered
? kAdaptiveAspectLabels : kAdaptiveAspectLabelsNoUltrawide;
gi.num_aspect_labels = ws_ultrawide_offered ? 4 : 3;
gi.aspect_experimental = 1;
}
gi.renderer_labels = kPsxRendererLabels;
gi.num_renderers = vulkan_offered ? 3 : 2;
/* Localization menu: shown only when the game declares languages. */
if (!rui_lang_labels.empty()) {
gi.language_labels = rui_lang_labels.data();
gi.num_languages = (int)rui_lang_labels.size();
}
/* Real disc-verify + memcard-inspect (identify_disc / memcard_summary_path
* via the ae_* callbacks). The launcher re-runs these whenever the
* user changes the disc or a memory card, so the verdict + block
* grids reflect the actual images.
* The expected serial/CRC ride on file-scope statics (the C-ABI
* callback can't capture these locals). */
g_lnch_expected_serial = game_id;
g_lnch_expected_crc = game_disc_crc;
g_lnch_has_crc = game_has_disc_crc;
gi.disc_verify = ae_disc_verify;
gi.memcard_inspect = ae_memcard_inspect;
#if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT)
g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name;
gi.netplay_supported = (game_players == 2) ? 1 : 0;
gi.netplay = &g_lnch_netplay_callbacks;
#endif
char rui_out_disc[1024] = {0};
int rui_rc = recomp_launcher_run_window(
rui_title.c_str(), &ls, &gi, assets_dir_str.c_str(),
rui_initial_disc.c_str(), rui_out_disc, sizeof(rui_out_disc));
lr = rui_rc;
if (lr == 0) {
seed.netplay_player_name = ls.netplay_player_name;
seed.has_netplay_player_name = true;
if (rui_out_disc[0]) {
seed.disc_path = rui_out_disc;
seed.has_disc_path = true;
}
seed.fullscreen = ls.fullscreen; seed.has_fullscreen = true;
seed.skip_launcher = ls.skip_launcher != 0; seed.has_skip_launcher = true;
/* Adaptive preserves the previous fixed aspect as the initial
* window shape. Selecting a fixed entry turns adaptation off. */
if (ws_adaptive_view_supported &&
ls.aspect_index == adaptive_aspect_index) {
seed.adaptive_view = true;
} else {
seed.adaptive_view = false;
switch (ls.aspect_index) {
case 2: seed.aspect_num = 21; seed.aspect_den = 9; break;
case 1: seed.aspect_num = 16; seed.aspect_den = 9; break;
default: seed.aspect_num = 4; seed.aspect_den = 3; break;
}
}
seed.has_aspect_ratio = true;
seed.has_adaptive_view = true;
/* has_texture_filter is on (PSX profile) so the launcher edits
* ls.texture_filter directly (0=nearest,1=bilinear); ls.linear_filter
* is the legacy fallback field for consoles without the cap and is
* left unused here. */
seed.texture_filter = ls.texture_filter ? 1 : 0; seed.has_texture_filter = true;
std::string p1_launcher_device = p1_device;
std::string p2_launcher_device = p2_device;
#if defined(RECOMP_LAUNCHER_HAS_PLAYER_GAMEPAD_GUID)
if (ls.player_gamepad_guid[0][0])
p1_launcher_device = ls.player_gamepad_guid[0];
if (ls.player_gamepad_guid[1][0])
p2_launcher_device = ls.player_gamepad_guid[1];
#endif
p1_device = PSXRecompV4::launcher_device_from_source(
ls.player_src[0], p1_launcher_device);
p2_device = PSXRecompV4::launcher_device_from_source(
ls.player_src[1], p2_launcher_device);
seed.p1_device = p1_device; seed.has_p1_device = true;
seed.p2_device = p2_device; seed.has_p2_device = true;
seed.deadzone = ls.deadzone[0] * 32767 / 100; seed.has_deadzone = true;
seed.p1_mode = ls.pad_mode[0]; seed.has_p1_mode = true;
seed.p2_mode = ls.pad_mode[1]; seed.has_p2_mode = true;
/* ---- deeper PSX-style settings write-back (mirrors the seed
* fields above), all gated on by the "psx" launcher_profile caps. */
seed.window_width = ls.window_width; seed.has_window_width = true;
seed.renderer = ls.renderer; seed.has_renderer = true;
seed.supersampling = ls.supersampling; seed.has_supersampling = true;
seed.antialiasing = ls.antialiasing != 0; seed.has_antialiasing = true;
seed.screen_kind = ls.screen_kind; seed.has_screen_kind = true;
seed.frame_interpolation = ls.frame_interp != 0; seed.has_frame_interpolation = true;
seed.frame_interpolation_fps = ls.frame_interp_fps; seed.has_frame_interpolation_fps = true;
seed.spu_hq = ls.spu_hq != 0; seed.has_spu_hq = true;
seed.auto_skip_fmv = ls.auto_skip_fmv != 0; seed.has_auto_skip_fmv = true;
seed.turbo_loads = ls.turbo_loads != 0; seed.has_turbo_loads = true;
if (ls.bios_path[0]) {
seed.bios_path = ls.bios_path;
seed.has_bios_path = true;
}
/* Memory-card slots: enable flags + any Browse/New paths. */
seed.memcard1_enabled = ls.memcard_enabled[0] != 0; seed.has_memcard1_enabled = true;
seed.memcard2_enabled = ls.memcard_enabled[1] != 0; seed.has_memcard2_enabled = true;
if (ls.memcard_path[0][0]) { seed.memcard1_path = ls.memcard_path[0]; seed.has_memcard1_path = true; }
if (ls.memcard_path[1][0]) { seed.memcard2_path = ls.memcard_path[1]; seed.has_memcard2_path = true; }
/* Localization: persist the chosen language code (only meaningful
* when the game declared a menu; otherwise leave seed.language
* untouched. */
if (!lang_menu_options.empty() && ls.language_index >= 0 &&
ls.language_index < (int)lang_menu_options.size()) {
seed.language = lang_menu_options[ls.language_index].code;
seed.has_language = true;
}
#if defined(PSX_HAS_LOBBY_CLIENT)
if (ls.netplay_launch.enabled) {
const PsxLobbyMatchCaps* caps = psx_lobby_match_caps();
if (caps && caps->valid) {
seed.aspect_num = caps->aspect_num ? caps->aspect_num : seed.aspect_num;
seed.aspect_den = caps->aspect_den ? caps->aspect_den : seed.aspect_den;
seed.has_aspect_ratio = true;
seed.turbo_loads = caps->turbo_loads != 0;
seed.has_turbo_loads = true;
seed.auto_skip_fmv = caps->auto_skip_fmv != 0;
seed.has_auto_skip_fmv = true;
if (caps->language[0]) {
seed.language = caps->language;
seed.has_language = true;
}
}
}
#endif
}
if (lr == 1) {
std::fprintf(stdout, "psxrecomp: launcher closed; exiting.\n");
return 0;
}
if (lr == 0) {
if (ls.netplay_launch.enabled) {
net_cfg.enabled = 1;
net_cfg.local_slot = ls.netplay_launch.local_slot;
net_cfg.input_player = ls.netplay_launch.input_player;
net_cfg.session_id = ls.netplay_launch.session_id;
net_cfg.input_delay = ls.netplay_launch.input_delay;
std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s",
ls.netplay_launch.bind_hostport);
std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s",
ls.netplay_launch.peer_hostport);
g_netplay_from_lobby = 1;
std::fprintf(stdout,
"psxrecomp: launcher netplay slot=%d bind=%s peer=%s session=%u\n",
net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport,
(unsigned)net_cfg.session_id);
std::fflush(stdout);
} else {
g_netplay_from_lobby = 0;
}
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_turbo_loads_enabled = seed.turbo_loads ? 1 : 0;
fast_boot = seed.fast_boot;
bios_hle = seed.bios_hle;
g_fullscreen = seed.fullscreen;
g_frame_interpolation = seed.frame_interpolation ? 1 : 0;
g_frame_interpolation_fps = seed.frame_interpolation_fps;
g_video_aspect_num = seed.aspect_num;
g_video_aspect_den = seed.aspect_den;
g_ws_adaptive_view = seed.adaptive_view;
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) {
seed.disc_path = normalize_disc_path_for_launch(seed.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;
if (seed.has_language) resolved_language = seed.language;
p1_device = seed.p1_device; p2_device = seed.p2_device;
p1_mode = seed.p1_mode; p2_mode = seed.p2_mode;
if (seed.has_deadzone) resolved_deadzone = seed.deadzone;
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
/* Re-apply the resolved language to the translation layer. text_xlate_init
* (at config load) only saw the game.toml default; this folds in the
* settings.toml override and the launcher's choice. No-op when unchanged. */
text_xlate_set_language(resolved_language.c_str());
/* CLI overrides win over config — applied last, before backend/port init.
* Enables a soak fleet: several instances on distinct ports + renderers,
* including BIOS instances that have no [game]-block config. */
if (cli_debug_port >= 0) debug_port = (uint16_t)cli_debug_port;
if (cli_renderer >= 0) g_video_renderer = cli_renderer;
if (cli_window_title) window_title = cli_window_title;
if (cli_memcard_dir) {
memcard_dir = std::filesystem::path(cli_memcard_dir);
if (memcard_dir.is_relative())
memcard_dir = exe_dir_from_argv(argv[0]) / memcard_dir;
memcard_dir = memcard_dir.lexically_normal();
memcard1_path.clear();
memcard2_path.clear();
}
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();
session_reboot:
/* Rematch after lobby soft-return re-enters here with updated net_cfg. */
static int s_emu_session = 0;
const bool rematch_session = (++s_emu_session > 1);
std::fprintf(stdout, "psxrecomp runtime: loading BIOS from %s%s\n",
bios_path_str.c_str(),
rematch_session ? " (rematch)" : "");
/* Soft-exit longjmps out of device service with a leftover guest clock;
* rematch must start from cycle 0 or vblanks never fire again. */
psx_cycles_reset_for_boot();
starvation_ring_reset();
present_session_reset();
{
extern uint64_t s_frame_count;
extern uint32_t g_debug_current_func_addr;
extern uint32_t g_debug_last_store_pc;
s_frame_count = 0;
g_debug_current_func_addr = 0;
g_debug_last_store_pc = 0;
}
memory_init(bios_path_str.c_str());
#ifndef PSX_HAVE_VULKAN
/* Vulkan was not compiled in (PSX_ENABLE_VULKAN=OFF or no SDK found).
* Refuse a vulkan request from ANY source (config / CLI / launcher seed)
* and fall back to OpenGL, so the window is never created with
* SDL_WINDOW_VULKAN against an inert backend stub. */
if (g_video_renderer == 2) {
std::fprintf(stdout, "psxrecomp: Vulkan renderer is not available in this build "
"(PSX_ENABLE_VULKAN=OFF); using OpenGL instead.\n");
g_video_renderer = 1;
}
#endif
/* 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 == 2 ? GR_BACKEND_VULKAN :
g_video_renderer == 1 ? GR_BACKEND_OPENGL : GR_BACKEND_SOFTWARE);
std::fprintf(stdout, "psxrecomp: renderer backend requested: %s\n",
g_video_renderer == 2 ? "vulkan" :
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. Identity at the default 4:3. The present letterbox uses
* this aspect; native-wide fills it with a genuinely wider frame (no
* stretch), squash mode stretches the 4:3 frame into it. */
gl_renderer_set_display_aspect(g_video_aspect_num, g_video_aspect_den);
if (g_video_aspect_num * 3 != g_video_aspect_den * 4 || g_ws_adaptive_view) {
/* Hold widescreen off through the BIOS boot (authentic 4:3 logos);
* the per-frame present path engages it at game entry. */
g_ws_engaged = false;
std::fprintf(stdout,
"psxrecomp: widescreen %d:%d (%s%s%s%s; engages at game entry)\n",
g_video_aspect_num, g_video_aspect_den,
g_ws_native_wide ? "native-wide, present 1:1"
: "GTE X-squash + stretched present",
g_ws_anchor_addr ? " + sprite tags" : "",
g_ws_hud_sprt ? " + HUD squash" : "",
g_ws_adaptive_view ? " + adaptive view" : "");
}
/* 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();
psx_event_step_conservative_env_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_mode);
set_player_device(g_players[1], p2_device, p2_mode);
for (int s = 0; s < 2; s++) {
/* Dev-any-input keeps P1 connected even with no assigned controller so the
* keyboard / any plugged-in controller can drive port 1 standalone. */
const bool dev_p1 = (dev_any_input_enabled() && s == 0);
sio_set_pad_connected(s, (g_players[s].kind != 0 || dev_p1) ? 1 : 0);
sio_set_pad_analog(s, pad_mode_boot_analog(g_players[s].mode), 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());
for (const auto& route : warm_cd_routes) {
cdrom_register_warm_route(route.arm_lba, route.lbas.data(),
(int)route.lbas.size(),
route.instant_max_per_frame);
std::fprintf(stdout,
"psxrecomp: warm CD route armed at LBA %d (%zu entries, "
"%d sectors/frame)\n",
route.arm_lba, route.lbas.size(),
route.instant_max_per_frame);
}
if (!disc_path_str.empty()) {
/* GetID must report the inserted disc's license region (the BIOS CD
* driver revalidates it mid-game). Derive it from the disc's boot
* serial via the same disc_identity module the launch check uses. */
const auto ident = PSXRecompV4::identify_disc(
disc_path_str, /*expected_serial*/"", /*expected_crc*/0,
/*has_expected_crc*/false, /*compute_crc*/false);
if (ident.region == "PAL") cdrom_set_disc_scex("SCEE");
else if (ident.region == "NTSC-J") cdrom_set_disc_scex("SCEI");
else if (ident.region == "NTSC-U") cdrom_set_disc_scex("SCEA");
if (!ident.region.empty())
std::fprintf(stdout, "psxrecomp: disc region %s (serial %s)\n",
ident.region.c_str(), ident.detected_serial.c_str());
}
/* Arm the text-image guard now that both possible sources are resolved:
* the local EXE file (dev checkouts) and the disc image (every install). */
if (game_config_path)
arm_text_image_guard(text_guard_exe_path, text_guard_load_addr,
disc_path_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);
}
if (!rematch_session) {
std::atexit(memcard_flush_all);
/* Persist the game's native OPTION settings on any exit path (belt-and-
* suspenders; save-on-change already persists them live). No-op if not yet
* armed, so it can't overwrite the saved file with boot defaults. */
std::atexit(game_options_save_now);
#ifndef PSX_NO_DEBUG_TOOLS
debug_server_init(debug_port);
#else
(void)debug_port;
#endif
#ifdef PSX_COSIM
cosim_init(); /* first-divergence oracle server */
#endif
/* Heartbeat always on — see freeze_heartbeat.c rationale. */
freeze_heartbeat_start("psx-runtime");
} else {
#ifndef PSX_NO_DEBUG_TOOLS
(void)debug_port;
#endif
}
/* 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);
if (g_headless) {
std::fprintf(stdout, "psxrecomp: headless frontend enabled\n");
} else {
/* ---- 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");
/* ...but HIDAPI's Xbox sub-driver is OFF by default on Windows (Xbox pads are
* normally RAWINPUT/XInput there). With RAWINPUT disabled above, a PHYSICAL
* Xbox One/Series controller would be claimed by nobody -> not a GameController
* -> zero input (PS5 DualSense works regardless: its HIDAPI driver is on by
* default). Enable the HIDAPI Xbox driver so HIDAPI handles Xbox pads too. */
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_XBOX, "1");
if (!SDL_WasInit(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER)) {
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]);
/* The launcher / settings.toml / game.toml deadzone (when set) is the
* user-facing authority; apply it over the input.ini value here, after
* load_input_config has read input.ini. */
if (resolved_deadzone >= 0)
controller_deadzone = std::max(0, std::min(32767, resolved_deadzone));
refresh_player_devices(); /* open SDL handles to match the player config */
#ifndef PSX_SDL_NO_AUDIO
audio_trace_init();
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;
const bool legacy = audio_legacy_mode();
if (!legacy)
want.callback = sdl_drc_callback; /* pull model: bridge resamples + DRC */
sdl_audio_device = SDL_OpenAudioDevice(NULL, 0, &want, &have,
legacy ? 0 : SDL_AUDIO_ALLOW_FREQUENCY_CHANGE);
if (sdl_audio_device) {
if (!legacy) {
rab_config cfg; rab_config_defaults(&cfg);
cfg.channels = 2;
cfg.source_rate = 44100.0; /* SPU render rate */
cfg.host_rate = (double)have.freq; /* actual device rate */
if (rab_init(&s_drc, &cfg) == 0) s_drc_ready = true;
}
g_audio_host_rate = have.freq;
audio_trace_set_tap_rate(AUDIO_TAP_HOST, (uint32_t)have.freq);
SDL_PauseAudioDevice(sdl_audio_device, 0);
}
}
#endif
Uint32 win_flags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE;
if (g_video_renderer == 1) {
configure_core_gl_context_attributes();
win_flags |= SDL_WINDOW_OPENGL;
}
if (g_video_renderer == 2) win_flags |= SDL_WINDOW_VULKAN;
/* Fullscreen on launch (launcher's tri-state Fullscreen control): 1 =
* borderless desktop fullscreen (keeps the desktop resolution, letterboxes
* the image), 2 = exclusive fullscreen (real display-mode change), 0 =
* windowed. Matches the in-game Alt+Enter / Cmd+Ctrl+F hotkey behaviour. */
win_flags |= psx_fullscreen_flag_for_mode(g_fullscreen);
/* 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;
}
/* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock
* pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank
* count (2/3/1 beat) that reads as moving-object judder. If the panel is
* within ~2% of 60 Hz, nudge the pacer to the exact panel period so the pacer
* and vsync agree and 30fps content pads to a steady 2 refreshes each.
* Non-~60Hz panels keep the PSX rate (vsync then governs; wrong-speed sim is
* worse than a benign slow beat). */
{
SDL_DisplayMode dm;
int disp_idx = SDL_GetWindowDisplayIndex(sdl_window);
if (disp_idx >= 0 && SDL_GetCurrentDisplayMode(disp_idx, &dm) == 0 && dm.refresh_rate > 0) {
double host_hz = (double)dm.refresh_rate;
g_host_refresh_hz = host_hz;
if (host_hz >= 58.8 && host_hz <= 61.2) {
g_frame_period_ms = 1000.0 / host_hz;
std::printf("psxrecomp: sync-to-host-refresh: pacing to %d Hz panel "
"(%.4f ms/frame)\n", dm.refresh_rate, g_frame_period_ms);
} else {
std::printf("psxrecomp: host panel %d Hz not ~60 Hz; keeping PSX "
"59.94 Hz pacing\n", dm.refresh_rate);
}
}
}
/* 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) {
gl_renderer_set_swap_interval(g_video_vsync); /* applied at context init */
g_gl_active = (gl_renderer_init_context(sdl_window) != 0);
if (!g_gl_active) gr_set_backend(GR_BACKEND_SOFTWARE);
/* The GL backend establishes its real internal scale HERE (raster init),
* which is AFTER the earlier `g_video_scale = gr_scale()` sync (that ran
* before this and so saw the default scale 1). Re-sync now so the staging
* buffer below is sized for the true scale and the native-wide present
* (which uses gr_scale()) matches it — otherwise sdl_pixel_buf is
* undersized and the wide readback overflows it. */
g_video_scale = gr_scale();
gl_renderer_set_interpolation(g_frame_interpolation, g_host_refresh_hz,
(double)g_frame_interpolation_fps);
}
/* Vulkan backend: create the instance/device/swapchain on the
* SDL_WINDOW_VULKAN window. On failure, fall back to software (vkb_init
* already initialized the software renderer on the shared VRAM array). */
if (g_video_renderer == 2) {
vk_renderer_set_present_mode(g_video_vsync);
g_vk_active = (vk_renderer_init_context(sdl_window) != 0);
if (!g_vk_active) gr_set_backend(GR_BACKEND_SOFTWARE);
g_video_scale = gr_scale();
}
latency_ring_set_backend(g_vk_active ? "vulkan" : g_gl_active ? "opengl" : "software");
latency_ring_set_present_mode(g_video_vsync);
/* 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 && !g_vk_active) {
#ifdef _WIN32
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
#endif
/* Vsync off (g_video_vsync==0) drops PRESENTVSYNC for lowest display
* latency; the wall-clock pacer still holds 59.94Hz (may tear). */
Uint32 rflags = SDL_RENDERER_ACCELERATED |
(g_video_vsync != 0 ? SDL_RENDERER_PRESENTVSYNC : 0u);
sdl_renderer = SDL_CreateRenderer(sdl_window, -1, rflags);
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 && !g_vk_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);
/* Delay-sync LAN (recomp-net). Menu/lobby UI is later work — CLI/env only.
* Must start after SDL so local pad capture has devices; before the guest
* fiber so the first vblanks already pump HELLO/START. */
if (net_cfg.enabled) {
/* Transport role and gameplay slot are independent. An empty peer
* means this process listens for the first peer, even when the lobby
* owner was reordered into gameplay slot 1 (Player 2). */
if (!net_cfg.bind_hostport[0]) {
std::fprintf(stderr,
"psxrecomp: netplay refused — empty bind "
"(bind='%s' peer='%s' session=%u)\n",
net_cfg.bind_hostport, net_cfg.peer_hostport,
(unsigned)net_cfg.session_id);
return 1;
}
/* Resolve which host PlayerInput feeds this peer's net sample.
* Auto: prefer g_players[local_slot] when assigned (same-PC: host
* C40 on P1 + guest keyboard on P2); else player 0 (two-machine). */
if (net_cfg.input_player != 0 && net_cfg.input_player != 1) {
const int prefer = (net_cfg.local_slot == 1) ? 1 : 0;
if (prefer == 1 && g_players[1].kind != 0)
net_cfg.input_player = 1;
else
net_cfg.input_player = 0;
}
const int nrc = psx_netplay_start(&net_cfg);
if (nrc != 0) {
std::fprintf(stderr,
"psxrecomp: netplay start failed (%d) — built without recomp-net, "
"or bind/peer invalid (slot=%d bind=%s peer=%s)\n",
nrc, net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport);
return 1;
}
std::printf("psxrecomp: netplay LAN slot=%d input_player=%d delay=%d "
"bind=%s peer=%s session=%u\n",
net_cfg.local_slot, net_cfg.input_player, net_cfg.input_delay,
net_cfg.bind_hostport, net_cfg.peer_hostport,
(unsigned)net_cfg.session_id);
}
/* Initialize CPU state. */
CPUState cpu;
std::memset(&cpu, 0, sizeof(cpu));
/* R3000A load-delay interlock init (Beetle: BACKED_LDWhich=0x20 = no pending
* load; ReadFudge=0 so the first load gets no fudge). Rest is correctly 0. */
cpu.ld_which_t = 0x20;
psx_icache_reset(); /* all I-cache lines cold at reset */
/* Wire memory function pointers. Guest data loads now route their CPU cycle
* cost through the faithful load-delay interlock (psx_cyc_load_* in memory.c),
* so these VALUE accessors are the UNCHARGED psx_read_* — the data-access
* wait-state is charged once, inside the interlock, never here. */
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;
/* (sljit removed 2026-07-15: overlay_sljit_init_helpers wired the JIT-shard
* host-helper table into cpu here; no shards consume it now.) */
/* BIOS backend select (CLAUDE.md §0 amendment 2026-07-02): LLE (the
* recompiled BIOS — default, reference implementation, oracle) vs the
* opt-in HLE tier (implemented kernel services computed in-runtime, LLE
* fallback for everything else). Boot always starts at the real reset
* vector; with the HLE boot-skip on, the tier's dispatch hook intercepts
* the shell entry one-shot (the boot animation is skipped; kernel init +
* game EXE load still run in the real recompiled BIOS, unpaced) — this
* REPLACES the old fast_boot snapshot restore, and fast_boot=true now
* aliases the boot-skip alone. Env overrides: PSX_BIOS_HLE /
* PSX_BIOS_HLE_KEEP_INTRO ('0' = off, anything else = on). */
{
if (const char* e = std::getenv("PSX_BIOS_HLE"))
bios_hle = (e[0] && e[0] != '0');
if (const char* e = std::getenv("PSX_BIOS_HLE_KEEP_INTRO"))
bios_hle_keep_intro = (e[0] && e[0] != '0');
const bool boot_skip =
(bios_hle && !bios_hle_keep_intro) || fast_boot;
psx_bios_hle_configure(bios_hle ? 1 : 0,
(boot_skip && game_entry_pc != 0) ? 1 : 0);
std::fprintf(stdout, "psxrecomp: bios_backend=%s bios_boot=%s\n",
psx_bios_hle_backend_name(),
psx_bios_hle_boot_skip_enabled()
? "HLE (shell skipped)" : "LLE (real intro)");
}
/* R3000A reset state. */
cpu.pc = 0xBFC00000u;
cpu.cop0[12] = 0x00400000u; /* SR: BEV=1 (boot exception vectors) */
/* User save states (F1-F12 / Shift+F1-F12): slots live in the per-game
* memcard/save dir, keyed by entry_pc + guarded by the boot_state integrity
* key (codegen hash/abi/ver) so a stale/foreign state can never load. */
if (game_entry_pc != 0) {
savestate_configure(memcard_dir.string().c_str(),
memory_get_bios_checksum(), game_entry_pc);
/* Headless / agent load: PSX_LOAD_SLOT=N stages F1..F12 load (0..11)
* at the next safe block boundary after boot. */
if (const char *ls = std::getenv("PSX_LOAD_SLOT")) {
int slot = atoi(ls);
if (slot >= 0 && slot < 12) {
if (psx_netplay_active()) {
std::fprintf(stdout,
"psxrecomp: PSX_LOAD_SLOT=%d ignored (use host F1–F12 after netplay starts)\n",
slot);
} else if (savestate_request_load(slot)) {
std::fprintf(stdout, "psxrecomp: PSX_LOAD_SLOT=%d staged\n", slot);
}
}
}
}
/* Guest netplay: sandbox .pst/.mcd under saves/netplay/ after personal
* roots are known (must follow savestate_configure + memcard_init). */
psx_netplay_bind_guest_saves();
/* 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
/* Deterministic TCB-scheduler trampoline (carve-out): wraps the top-level
* dispatch so a cooperative thread switch unwinds here and re-dispatches the
* target thread, instead of the old per-frame host-fiber recreate. Returns
* only on the abnormal top-level pc==0 exit; the diagnostic dump below runs
* exactly as it did for the bare psx_dispatch. (HLE_SCHEDULER_CARVEOUT_PLAN.md)
*
* Hidden toggle: PSX_HLE_SCHEDULER=0 reverts to the legacy LLE host-fiber
* bridge (default 1 = HLE). The trampoline is transparent in LLE mode (the
* fiber path never longjmps to it). */
std::fprintf(stdout, "psxrecomp: thread scheduler = %s (PSX_HLE_SCHEDULER)\n",
psx_hle_scheduler_enabled() ? "HLE (deterministic TCB)"
: "LLE (host fibers)");
std::fflush(stdout);
/* General control-flow parity trace (parity_trace.h). Armed from boot via
* PSX_PARITY_TRACE=1 so the pre-divergence window is always covered (never
* arm-then-time). Watched TCB / freeze trigger / watch words are env-tunable;
* defaults target the MMX6 cutscene→gameplay wedge (thread1 0xA000E35C,
* trigger dispatch 0x800CD3F8, watch the handshake flag + state struct). */
if (const char* pt = std::getenv("PSX_PARITY_TRACE"); pt && pt[0] && pt[0] != '0') {
auto envhex = [](const char* k, uint32_t dflt) -> uint32_t {
const char* v = std::getenv(k);
return (v && v[0]) ? (uint32_t)std::strtoul(v, nullptr, 0) : dflt;
};
uint32_t tcb = envhex("PSX_PARITY_TCB", 0xA000E35Cu);
uint32_t trigger = envhex("PSX_PARITY_TRIGGER", 0x800CD3F8u);
uint32_t watch[PARITY_WATCH_MAX] = {
0x801FEB78u, /* thread1 stack saved-ra slot */
0x8006D9ACu, /* handshake flag (setter target) */
0x800CD3F8u, /* cutscene state struct: outer/sub */
0x800CD3FCu, /* countdown */
0x800CD404u, /* done flag region */
0x800200F4u, /* func_8002000C resume point */
};
int wc = PARITY_WATCH_MAX;
if (const char* wl = std::getenv("PSX_PARITY_WATCH"); wl && wl[0]) {
wc = 0; std::string s(wl); size_t p = 0;
while (p < s.size() && wc < PARITY_WATCH_MAX) {
size_t c = s.find(',', p);
std::string tok = s.substr(p, c == std::string::npos ? c : c - p);
if (!tok.empty()) watch[wc++] = (uint32_t)std::strtoul(tok.c_str(), nullptr, 0);
if (c == std::string::npos) break; p = c + 1;
}
}
parity_trace_config(tcb, trigger, 0x88u, 0x00u, watch, wc);
parity_trace_arm(1);
std::fprintf(stdout, "psxrecomp: parity trace ARMED tcb=0x%08X trigger=0x%08X (%d watch)\n",
tcb, trigger, wc);
std::fflush(stdout);
}
/* Device-event cycle ring (device_trace.h): armed from boot under the same
* PSX_PARITY_TRACE gate, or PSX_DEVTRACE=1 standalone. Captures every
* CD/DMA/timer/VBlank/SIO/SPU IRQ raise with its guest cycle for the
* cross-process device-timing diff (tools/devtrace_diff.py). */
{
const char* pt = std::getenv("PSX_PARITY_TRACE");
const char* dt = std::getenv("PSX_DEVTRACE");
if ((pt && pt[0] && pt[0] != '0') || (dt && dt[0] && dt[0] != '0')) {
device_trace_arm(1);
std::fprintf(stdout, "psxrecomp: device-event trace ARMED\n");
std::fflush(stdout);
}
}
/* Delay-sync: do not free-run boot while HELLO/START is in flight.
* Park until tick 0 pads are published, then enter the guest. */
if (psx_netplay_active()) {
std::printf("psxrecomp: netplay waiting for peer START + tick-0 admit…\n");
std::fflush(stdout);
netplay_barrier_admit(-1);
if (psx_return_to_lobby_requested())
goto soft_return_lobby;
netplay_host_present_uncap();
std::printf("psxrecomp: netplay lockstep armed (sim_tick=%u, vsync off)\n",
(unsigned)psx_netplay_sim_tick());
std::fflush(stdout);
}
psx_scheduler_run(&cpu);
if (psx_return_to_lobby_requested() && g_netplay_from_lobby)
goto soft_return_lobby;
#endif
/* If we reach here, all execution completed without MMIO abort.
* During normal operation the guest runs an infinite main loop and the
* top-level psx_dispatch NEVER returns. Reaching this point means the
* outermost trampoline loop saw cpu->pc == 0 (some jr/tail-transfer
* published a null PC) — an abnormal boot exit. Dump the always-on
* fntrace ring tail (last dispatch chain) to a JSON artifact so we can
* see exactly which targets led to the null PC. (CLAUDE.md ring-buffer
* model: consume the always-on ring after the fact, not arm-and-time.) */
{
FILE* tf = std::fopen("psx_cps_exit_trace.json", "wb");
if (tf) {
std::fprintf(tf, "{\n \"final_pc\": \"0x%08X\",\n \"final_ra\": \"0x%08X\",\n"
" \"final_sp\": \"0x%08X\",\n \"fntrace_seq\": %llu,\n \"tail\": [\n",
cpu.pc, cpu.gpr[31], cpu.gpr[29],
(unsigned long long)g_fntrace_seq);
uint64_t seq = g_fntrace_seq;
uint32_t n = seq < 128u ? (uint32_t)seq : 128u;
for (uint32_t i = 0; i < n; i++) {
uint64_t idx = seq - n + i;
const FntraceEntry* e = &g_fntrace_ring[idx % FNTRACE_RING_CAP];
std::fprintf(tf,
" {\"seq\":%llu,\"frame\":%u,\"target\":\"0x%08X\",\"ra\":\"0x%08X\","
"\"sp\":\"0x%08X\",\"a0\":\"0x%08X\",\"a1\":\"0x%08X\"}%s\n",
(unsigned long long)idx, e->frame, e->target, e->ra, e->sp,
e->a0, e->a1, (i + 1 < n) ? "," : "");
}
std::fprintf(tf, " ],\n \"stack\": [\n");
uint32_t sbase = (cpu.gpr[29] - 0x40u) & ~3u;
for (int w = 0; w < 40; w++) {
uint32_t a = sbase + (uint32_t)w * 4u;
uint32_t v = cpu.read_word(a);
std::fprintf(tf, " {\"addr\":\"0x%08X\",\"val\":\"0x%08X\"}%s\n",
a, v, (w < 39) ? "," : "");
}
std::fprintf(tf, " ]\n}\n");
std::fclose(tf);
}
}
/* Diagnostic: the guest published a null PC at the top level (abnormal). With
* PSX_EXIT_HALT set, halt-and-serve here instead of shutting down so the
* still-loaded overlays + full guest state are live-inspectable over TCP. */
{ const char *e = std::getenv("PSX_EXIT_HALT");
if (e && e[0] && e[0] != '0') {
extern void psx_fatal_halt(const char *reason);
psx_fatal_halt("top-level dispatch returned PC=0 (abnormal boot exit — inspect live)");
}
}
std::fprintf(stdout, "psxrecomp runtime: execution completed, PC=0x%08X\n", cpu.pc);
{ extern uint64_t g_slice_fired, g_slice_irq_taken, g_dirty_ram_insns_run;
extern uint32_t g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter;
extern uint32_t g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want;
std::fprintf(stdout, "psxrecomp runtime: [slice diag] slice_fired=%llu slice_irq_taken=%llu dirty_insns=%llu exit_pc=0x%08X reason=%u iter=%u dispatchable=%u dirty=%u in_text=%u want=%u\n",
(unsigned long long)g_slice_fired, (unsigned long long)g_slice_irq_taken,
(unsigned long long)g_dirty_ram_insns_run, g_slice_exit_pc, g_slice_exit_reason,
g_slice_exit_iter, g_slice_exit_dispatchable, g_slice_exit_dirty,
g_slice_exit_in_text, g_slice_exit_want); }
shutdown_runtime();
if (g_gl_active) gl_renderer_shutdown();
if (g_vk_active) vk_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;
soft_return_lobby:
/* Netplay soft-exit: tear down the match window. Lobby resume will be
* restored through recomp-ui. */
teardown_game_session_keep_lobby();
psx_lobby_disconnect();
SDL_Quit();
return 0;
}