/* 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 "projection_scale.hpp" #include "projection_scale_config.hpp" #include "mod_controller_source.h" #include "window_size.h" /* default game-window size */ #include "internal_resolution.h" /* Settings -> Display -> Internal resolution */ #include "dynamic_resolution.h" /* [video] dynamic_resolution: the step controller */ #include "render_thread.h" /* rt_get_stats: queue backpressure (dynres) */ #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 "render_pass_projection.h" #include "text_xlate.h" #include "boot_state.h" #include "bios_hle.h" #include "bios_hle_plan.h" #include "psx_bios_known_images.h" #include "psx_bios_backend.h" #include "psx_bios_module.h" #include "psx_cycles.h" #include "starvation_ring.h" #include "load_accel.h" #include "savestate.h" #include "psx_rewind.h" #include "psx_savestate_menu.h" #include "host_osd.h" #include "local_mouse_sdl.h" #include "pad_external_input.h" #include "host_keymap.h" #include "png_write.h" /* png_write_rgb — present_shot readback */ #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 "draw_distance.h" #include "display_scanout.h" #include "pgxp.h" #include "pgxp_session.h" #include "interrupts.h" #include "psx_video_timing.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 "render_pass.h" /* Declarations only: STB_IMAGE_IMPLEMENTATION lives in psx_window_icon.cpp. */ #define STBI_NO_STDIO #include "../third_party/stb_image.h" #include "gpu_vk_renderer.h" #include "frame_pacing.h" #include "latency_ring.h" #include "sio.h" #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 #endif #include "psx_netplay.h" #include "psx_stick.h" /* radial SDL-stick -> DualShock response transform */ #include "psx_trigger.h" /* continuous SDL trigger -> 0..255 magnitude */ #include "mod_pad_transform.h" #include "psx_hotkey_pad.h" /* host shortcut bindings, direct claims */ #include "psx_controller_type.h" /* mapped wheel-name classification */ #include "psx_netplay_rb.h" #include "psx_selfcheck.h" #include "psx_lobby_client.h" #include "netplay_bios_settle.h" #include "netplay_exit_reason.h" #include "host_time.h" #if defined(PSX_HAS_RECOMP_NET) #include "recomp_net/auth.h" #include "recomp_net/host_relay.h" /* RNetHostRelayStatus for the launcher relay line */ #include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */ #endif #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 "bios_rom_alias.h" #include "host_path.h" #include "launcher_device.h" #include "game_options.h" #include "mod_plugins.h" #include "mod_local_input_policy.h" #include "mod_session_baseline.h" #include "mod_runtime.h" #include "host_launch_timing.h" #include "mod_packages.h" #include "present_image_ring.h" #include "gpu_timeline.h" #include "crc32.h" #include "disc_identity.h" #include "sbi_setup.h" #include "disc_path.h" #include "iso_reader.h" /* text-image guard: extract the boot EXE from the disc */ #include "psx_keybinds.h" /* configurable keyboard->DualShock keybinds (keybinds.ini) */ #include "psx_window_icon.h" #if defined(RECOMP_LAUNCHER) #include "recomp_launcher.h" /* shared recomp-ui Dear ImGui launcher */ /* A recomp-ui that shows the Internal resolution row; an older one only has * the legacy Supersampling row (internal_resolution.h, launcher round trip). */ #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) static constexpr int kLauncherHasInternalResolution = 1; #else static constexpr int kLauncherHasInternalResolution = 0; #endif #include "launcher_profile.h" /* per-system variant profile (theme/caps bundle) */ #include "launcher_boot_timing.h" /* PSX_LAUNCHER_BOOT_TIMING stamps */ #if defined(PSX_HAS_CODEGEN_SETUP_HOST) extern "C" void psx_game_codegen_setup_apply(RecompLauncherCGameInfo* gi); extern "C" void psx_game_codegen_relaunch_or_exit(const char* disc_path); #endif #endif /* Setup-host relaunch hook: only exists when a codegen_setup.c-style host was * actually linked (PSX_HAS_CODEGEN_SETUP_HOST) — that file depends on * recomp-ui/launcher headers a --no-recomp-ui build does not have, so this * must NOT be gated on PSX_HAS_GAME_CODEGEN (set for any linked game C) or * RECOMP_LAUNCHER alone. */ #if defined(PSX_HAS_CODEGEN_SETUP_HOST) extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv); #endif #include "psx_sdl.h" #include "window_fullscreen.h" #if defined(PSX_SDL3) /* * SDL_main.h is a single-header implementation in SDL3. Keep it in the one * translation unit that defines main(); including it through psx_sdl.h makes * every SDL-using source emit WinMain under MinGW. */ #include #endif #include "psx_sdl_audio.h" #if defined(PSX_WEB) #include #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #include #include #include #include #include #else #include #include #include #include #include #include #include #include #include #include #endif #ifndef PSX_DEFAULT_BIOS_PATH /* Compile-time fallback name only — not an implicit player choice. * Runtime default with no explicit pick is bios/openbios.bin (see * resolve_bios_for_runtime / docs/BIOS_SELECTION.md). */ #define PSX_DEFAULT_BIOS_PATH "bios/SCPH1001.BIN" #endif #ifndef PSX_BUNDLED_BIOS_PATH #define PSX_BUNDLED_BIOS_PATH "bios/openbios.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 uint32_t g_text_image_lo; 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 uint32_t i_stat, i_mask; extern uint64_t g_guest_store_count; extern uint64_t g_vblank_ack_count; /* interrupts.c */ extern uint64_t g_vblank_raise_count, g_vblank_deliver_count; /* dirty_ram_interp.c */ extern uint64_t g_dirty_ram_blocks_run; extern uint64_t g_dirty_pump_count; /* overlay_loader.c */ extern int g_call_unit_depth; /* psx_bios_backend.c */ extern int g_psx_dispatch_depth; } /* memory.c */ extern "C" void memory_init(const char* bios_path); /* psx_ram_geometry.c: drop a plugin's 8 MiB request before the next boot. */ extern "C" void psx_ram_reset_size_request(void); extern "C" void memory_set_sr_ptr(const uint32_t *p); /* interrupts.c */ extern "C" void psx_irq_set_cause_ptr(uint32_t *p); extern "C" void psx_set_midframe_audio_pump(void (*fn)(void)); 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); extern "C" void psx_host_write_half(uint32_t addr, uint16_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 PsxWindowFullscreen s_window_fullscreen; 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): 1=analog (default), 2=digital. * Game-owned plugins may register a trusted per-sample presentation policy * for title-specific switching, but the global launcher/config surface only * persists analog or digital. */ int mode = PSXRecompV4::PAD_MODE_ANALOG; int deadzone = 3277; /* raw SDL axis units, ~10% default */ SDL_GameController* handle = nullptr; SDL_JoystickID instance = -1; uint8_t rumble_small = 0; uint8_t rumble_large = 0; bool rumble_known = false; bool rumble_warned = false; bool steering_wheel = false; }; static PlayerInput g_players[PSX_MAX_PLAYERS]; /* Offline SIO sample loop bound (from game.toml players; clamped). */ static int g_offline_pad_count = 2; /* Set when [controller] lock_mode pins every seat to digital — blocks the * DualShock-on-tap hack from settings.toml / Lobby Settings / match_caps. */ static int g_force_digital_pads = 0; /* Offline seat ceiling from game.toml players, optionally capped at 2 when * the launcher Multitap toggle is off (3+ player titles). Netplay ignores * this and arms multitap whenever session slot_count > 2. */ static void apply_offline_pad_count(int game_players, bool multitap_enabled) { int n = game_players > 0 ? game_players : 1; if (n > PSX_MAX_PLAYERS) n = PSX_MAX_PLAYERS; if (game_players >= 3 && !multitap_enabled && n > 2) n = 2; g_offline_pad_count = n; } /* ARGB8888 staging buffer. The 576-row maximum preserves the full interlaced * PAL active canvas; wide frames can require more than the canonical width. */ static uint32_t* sdl_pixel_buf = nullptr; static size_t sdl_pixel_buf_capacity = 0; static int sdl_texture_width = 0; static int sdl_texture_height = 0; typedef void (*ModFrameHook)(void); static std::vector& mod_frame_hooks() { static std::vector 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 g_smooth_60fps{0}; struct Smooth60State { std::vector 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 void dynres_note_savestate_loaded(void); /* dynamic resolution hold */ /* §33 SW hold-last: sdl_texture is 640x512; Live only uploads the active * display rect. Resim must reuse that src/dst — RenderCopy(NULL,NULL) sticks * the image in the upper-left corner (user-confirmed). */ static int s_sw_hold_valid = 0; static SDL_Rect s_sw_hold_src; static SDL_Rect s_sw_hold_dst; /* After LOADED: optional freeze probe (PSX_POST_LOAD_PROBE=1). Off by default. */ static int s_post_load_probe_enabled = -1; /* -1 = unread env */ static int s_post_load_probe_left = 0; static int s_post_load_probe_i = 0; static uint64_t s_post_load_probe_gp00 = 0; static uint64_t s_post_load_probe_skip_tot = 0; static uint64_t s_post_load_probe_swap_tot = 0; static uint64_t s_post_load_probe_dirty_tot = 0; static uint64_t s_post_load_probe_gp0_tot = 0; static uint64_t s_post_load_probe_vb_raise0 = 0; static uint64_t s_post_load_probe_vb_deliv0 = 0; static uint64_t s_post_load_probe_vb_ack0 = 0; static uint64_t s_post_load_probe_dirty_blks0 = 0; static uint64_t s_post_load_probe_chk_entry0 = 0; static uint64_t s_post_load_probe_chk_fsr0 = 0; static uint64_t s_post_load_probe_chk_fnone0 = 0; static uint64_t s_post_load_probe_chk_mid0 = 0; static uint64_t s_post_load_probe_chk_eval0 = 0; static uint64_t s_post_load_probe_chk_deliv0 = 0; static uint64_t s_post_load_probe_cyc0 = 0; static uint64_t s_post_load_probe_ci_unit0 = 0; static uint64_t s_post_load_probe_ci_supp0 = 0; static uint64_t s_post_load_probe_ci_none0 = 0; static uint64_t s_post_load_probe_ci_sr0 = 0; static uint64_t s_post_load_probe_ci_deliv0 = 0; static uint64_t s_post_load_probe_ci_enter0 = 0; static uint64_t s_post_load_probe_adv_calls0 = 0; static uint64_t s_post_load_probe_adv_sum0 = 0; static uint64_t s_post_load_probe_svc0 = 0; static uint64_t s_post_load_probe_dirty_insns0 = 0; static uint64_t s_post_load_probe_dirty_pump0 = 0; static uint64_t s_post_load_probe_stores0 = 0; static uint64_t s_post_load_probe_idle_n0 = 0; static uint64_t s_post_load_probe_idle_cyc0 = 0; static uint64_t s_post_load_probe_hz_hits0 = 0; static uint64_t s_post_load_probe_hz_cyc0 = 0; static Uint64 s_post_load_probe_host_t0 = 0; static int s_post_load_probe_stall_run = 0; static int s_post_load_probe_in_stall = 0; #define POST_LOAD_STALL_PC_CAP 8 static uint32_t s_stall_pc[POST_LOAD_STALL_PC_CAP]; static uint32_t s_stall_pc_n[POST_LOAD_STALL_PC_CAP]; static int s_stall_pc_used = 0; #define POST_LOAD_LIVE_PC_CAP 8 static uint32_t s_live_pc[POST_LOAD_LIVE_PC_CAP]; static uint32_t s_live_pc_n[POST_LOAD_LIVE_PC_CAP]; static int s_live_pc_used = 0; static int post_load_probe_env_on(void) { if (s_post_load_probe_enabled < 0) { const char *e = std::getenv("PSX_POST_LOAD_PROBE"); s_post_load_probe_enabled = (e && e[0] == '1') ? 1 : 0; } return s_post_load_probe_enabled; } static Uint64 s_fps_last_time = 0; static uint64_t s_fps_last_frame = 0; static std::string s_fps_base_title; static int s_fps_telemetry_enabled = -1; /* -1 = unread env */ 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; /* Depth24 FMV cutover: blank the first depth24+MDEC presents after leaving * depth24 or a *long* MDEC idle so one-frame RGB888 junk never reaches the * window. Short inter-frame gaps must not re-arm this (BPE ~15fps flicker). */ static int s_d24_prev_mdec = 0; static int s_d24_saw_gap = 0; static int s_d24_cutover_blank = 0; /* Savestate restore → audio pump: re-anchor last_cycles (declared early so * psx_frontend_on_savestate_loaded can set it). */ static int g_audio_cycle_resync = 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; } /* Declared early: present_session_reset zeros it on rematch. */ static int sdl_audio_fadein_left = 0; static void present_session_reset(void) { s_disabled_frame_presented = false; s_force_present_after_load = false; s_sw_hold_valid = 0; 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; s_d24_prev_mdec = 0; s_d24_saw_gap = 0; s_d24_cutover_blank = 0; sdl_audio_fadein_left = 0; /* Soft-exit can leave deferred present / flush reentrancy armed; gpu_init * does not clear them. Rematch then no-ops every flush → black window. */ gpu_vblank_clear_deferred_present(); smooth_60_reset(); } static int fps_telemetry_enabled(void) { if (s_fps_telemetry_enabled < 0) { const char *e = std::getenv("PSX_FPS_TELEMETRY"); s_fps_telemetry_enabled = (e && e[0] && e[0] != '0') ? 1 : 0; } return s_fps_telemetry_enabled; } static void fps_telemetry_toggle(void) { const int enabled = fps_telemetry_enabled() ? 0 : 1; s_fps_telemetry_enabled = enabled; s_fps_last_time = 0; s_fps_last_frame = 0; if (!enabled && sdl_window && !s_fps_base_title.empty()) SDL_SetWindowTitle(sdl_window, s_fps_base_title.c_str()); if (!enabled) host_osd_set_status(NULL); s_fps_base_title.clear(); host_osd_push(enabled ? "FPS readout on" : "FPS readout off", 1500); } /* Hold-to-act host hotkeys must not fire while the game window does not hold * keyboard focus. * * SDL_GetKeyboardState() returns a CACHED array that SDL updates from events. * A key whose key-UP is delivered to some other window stays latched in that * array with nothing to clear it -- and the canonical way to move focus away * is Alt+TAB, where TAB is exactly the fast-forward bind. The game then runs * at the fast-forward multiplier with no key held, indefinitely. Regaining * focus makes SDL reconcile the array, which is why the symptom "stops" the * moment you click back into the window and press anything. * * Gating on real input focus fixes that and is independently correct: a * hold-to-turbo must not run while the player is typing in another * application. Edge-triggered hotkeys are unaffected -- they go through * host_keymap_match() on real key EVENTS, which are only delivered to the * focused window in the first place. * * No window (headless, or before the window exists) means focus is not a * concept here; do not suppress in that case. */ static bool host_hotkey_input_focused(void) { if (!sdl_window) return true; return (SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_INPUT_FOCUS) != 0; } static int manual_fast_forward_multiplier(void) { static int value = -2; /* -2 = unread, -1 = max/unbounded */ if (value == -2) { const char *e = std::getenv("PSX_FAST_FORWARD_SPEED"); value = 4; if (e && e[0]) { if (std::strcmp(e, "max") == 0 || std::strcmp(e, "MAX") == 0 || std::strcmp(e, "0") == 0) { value = -1; } else { int parsed = std::atoi(e); if (parsed >= 2 && parsed <= 16) value = parsed; } } } return value; } static void post_load_probe_stall_pc_note(uint32_t pc) { if (!pc) return; for (int i = 0; i < s_stall_pc_used; i++) { if (s_stall_pc[i] == pc) { if (s_stall_pc_n[i] < 0xffffffffu) s_stall_pc_n[i]++; return; } } if (s_stall_pc_used >= POST_LOAD_STALL_PC_CAP) return; s_stall_pc[s_stall_pc_used] = pc; s_stall_pc_n[s_stall_pc_used] = 1; s_stall_pc_used++; } static void post_load_probe_stall_pc_dump(const char *why) { if (s_stall_pc_used <= 0) return; std::fprintf(stderr, "post_load_probe stall_pcs (%s):", why); for (int i = 0; i < s_stall_pc_used; i++) { std::fprintf(stderr, " 0x%08X×%u", (unsigned)s_stall_pc[i], (unsigned)s_stall_pc_n[i]); } std::fprintf(stderr, "\n"); } static void post_load_probe_live_pc_note(uint32_t pc) { if (!pc) return; for (int i = 0; i < s_live_pc_used; i++) { if (s_live_pc[i] == pc) { if (s_live_pc_n[i] < 0xffffffffu) s_live_pc_n[i]++; return; } } if (s_live_pc_used >= POST_LOAD_LIVE_PC_CAP) return; s_live_pc[s_live_pc_used] = pc; s_live_pc_n[s_live_pc_used] = 1; s_live_pc_used++; } static void post_load_probe_live_pc_dump(const char *why) { if (s_live_pc_used <= 0) return; std::fprintf(stderr, "post_load_probe live_pcs (%s):", why); for (int i = 0; i < s_live_pc_used; i++) { std::fprintf(stderr, " 0x%08X×%u", (unsigned)s_live_pc[i], (unsigned)s_live_pc_n[i]); } std::fprintf(stderr, "\n"); } static void post_load_probe_arm(void) { if (!post_load_probe_env_on()) { s_post_load_probe_left = 0; g_plp_cycle_diag = 0; return; } extern uint64_t g_vblank_raise_count, g_vblank_deliver_count, g_vblank_ack_count; extern uint64_t g_dirty_ram_blocks_run; extern uint64_t g_dirty_ram_insns_run; extern uint64_t g_dirty_pump_count; extern uint64_t g_guest_store_count; uint64_t e = 0, fsr = 0, fn = 0, mid = 0, ev = 0, id = 0; psx_interrupt_check_path_diag(&e, &fsr, &fn, &mid, &ev, &id); uint64_t ci_u = 0, ci_s = 0, ci_n = 0, ci_sr = 0, ci_d = 0, ci_e = 0; overlay_loader_get_ci_skip_diag(&ci_u, &ci_s, &ci_n, &ci_sr, &ci_d, &ci_e); uint64_t hz_h = 0, hz_c = 0; psx_vsync_query_hle_horizon_totals(&hz_h, &hz_c); g_plp_cycle_diag = 1; g_plp_adv_calls = 0; g_plp_adv_max_chunk = 0; g_plp_adv_sum = 0; g_plp_svc_calls = 0; s_post_load_probe_left = 300; s_post_load_probe_i = 0; s_post_load_probe_gp00 = gpu_get_gp0_count(); s_post_load_probe_skip_tot = 0; s_post_load_probe_swap_tot = 0; s_post_load_probe_dirty_tot = 0; s_post_load_probe_gp0_tot = 0; s_post_load_probe_vb_raise0 = g_vblank_raise_count; s_post_load_probe_vb_deliv0 = g_vblank_deliver_count; s_post_load_probe_vb_ack0 = g_vblank_ack_count; s_post_load_probe_dirty_blks0 = g_dirty_ram_blocks_run; s_post_load_probe_chk_entry0 = e; s_post_load_probe_chk_fsr0 = fsr; s_post_load_probe_chk_fnone0 = fn; s_post_load_probe_chk_mid0 = mid; s_post_load_probe_chk_eval0 = ev; s_post_load_probe_chk_deliv0 = id; s_post_load_probe_cyc0 = psx_get_cycle_count(); s_post_load_probe_ci_unit0 = ci_u; s_post_load_probe_ci_supp0 = ci_s; s_post_load_probe_ci_none0 = ci_n; s_post_load_probe_ci_sr0 = ci_sr; s_post_load_probe_ci_deliv0 = ci_d; s_post_load_probe_ci_enter0 = ci_e; s_post_load_probe_adv_calls0 = 0; s_post_load_probe_adv_sum0 = 0; s_post_load_probe_svc0 = 0; s_post_load_probe_dirty_insns0 = g_dirty_ram_insns_run; s_post_load_probe_dirty_pump0 = g_dirty_pump_count; s_post_load_probe_stores0 = g_guest_store_count; s_post_load_probe_idle_n0 = g_idle_skip_count; s_post_load_probe_idle_cyc0 = g_idle_skip_cycles; s_post_load_probe_hz_hits0 = hz_h; s_post_load_probe_hz_cyc0 = hz_c; s_post_load_probe_host_t0 = SDL_GetPerformanceCounter(); s_post_load_probe_stall_run = 0; s_post_load_probe_in_stall = 0; s_stall_pc_used = 0; s_live_pc_used = 0; gl_renderer_present_probe_reset(); std::fprintf(stderr, "savestate: post_load_probe armed (300 vblanks; " "PSX_POST_LOAD_PROBE=1; live_pc/ci/adv diag on)\n"); } static void post_load_probe_on_vblank(int turbo_active, int present_reached) { if (s_post_load_probe_left <= 0) return; s_post_load_probe_i++; s_post_load_probe_left--; uint64_t skip = 0, swap = 0, dirty_marks = 0; int force_left = 0; gl_renderer_present_probe_take(&skip, &swap, &dirty_marks, &force_left); s_post_load_probe_skip_tot += skip; s_post_load_probe_swap_tot += swap; s_post_load_probe_dirty_tot += dirty_marks; const uint64_t gp0_now = gpu_get_gp0_count(); const uint64_t gp0_delta = gp0_now - s_post_load_probe_gp00; s_post_load_probe_gp00 = gp0_now; s_post_load_probe_gp0_tot += gp0_delta; extern uint64_t g_vblank_raise_count, g_vblank_deliver_count, g_vblank_ack_count; extern uint64_t g_dirty_ram_blocks_run; extern uint32_t i_stat, i_mask; extern CPUState *debug_cpu_ptr; const uint64_t vb_r = g_vblank_raise_count - s_post_load_probe_vb_raise0; const uint64_t vb_d = g_vblank_deliver_count - s_post_load_probe_vb_deliv0; const uint64_t vb_a = g_vblank_ack_count - s_post_load_probe_vb_ack0; s_post_load_probe_vb_raise0 = g_vblank_raise_count; s_post_load_probe_vb_deliv0 = g_vblank_deliver_count; s_post_load_probe_vb_ack0 = g_vblank_ack_count; const uint64_t dirty_blks = g_dirty_ram_blocks_run - s_post_load_probe_dirty_blks0; s_post_load_probe_dirty_blks0 = g_dirty_ram_blocks_run; uint32_t tcb = 0, gp_a = 0, gp_b = 0, gp_reg = 0; uint32_t sr = 0, cause = 0; int iec = 0, im2 = 0; if (debug_cpu_ptr) { gp_reg = debug_cpu_ptr->gpr[28]; tcb = psx_sched_current_tcb(debug_cpu_ptr); sr = debug_cpu_ptr->cop0[12]; /* COP0 Status */ cause = debug_cpu_ptr->cop0[13]; /* COP0 Cause */ iec = (sr & 0x1u) ? 1 : 0; im2 = (sr & (1u << 10)) ? 1 : 0; /* func_8004FD14 frame-counter compare at 0x800501E8. */ if (debug_cpu_ptr->read_word) { gp_a = debug_cpu_ptr->read_word(gp_reg + 2552u); gp_b = debug_cpu_ptr->read_word(gp_reg + 2632u); } } /* Hot-path check_interrupts attribution (not delivery_needed — that only * samples at present time and was misleading). */ uint64_t chk_e = 0, chk_fsr = 0, chk_fn = 0, chk_mid = 0, chk_ev = 0, chk_id = 0; psx_interrupt_check_path_diag(&chk_e, &chk_fsr, &chk_fn, &chk_mid, &chk_ev, &chk_id); const uint64_t d_entry = chk_e - s_post_load_probe_chk_entry0; const uint64_t d_fsr = chk_fsr - s_post_load_probe_chk_fsr0; const uint64_t d_fnone = chk_fn - s_post_load_probe_chk_fnone0; const uint64_t d_mid = chk_mid - s_post_load_probe_chk_mid0; const uint64_t d_eval = chk_ev - s_post_load_probe_chk_eval0; const uint64_t d_irqd = chk_id - s_post_load_probe_chk_deliv0; s_post_load_probe_chk_entry0 = chk_e; s_post_load_probe_chk_fsr0 = chk_fsr; s_post_load_probe_chk_fnone0 = chk_fn; s_post_load_probe_chk_mid0 = chk_mid; s_post_load_probe_chk_eval0 = chk_ev; s_post_load_probe_chk_deliv0 = chk_id; const uint64_t cyc_now = psx_get_cycle_count(); const uint64_t d_cyc = cyc_now - s_post_load_probe_cyc0; s_post_load_probe_cyc0 = cyc_now; uint64_t ci_u = 0, ci_s = 0, ci_n = 0, ci_sr = 0, ci_d = 0, ci_e = 0; overlay_loader_get_ci_skip_diag(&ci_u, &ci_s, &ci_n, &ci_sr, &ci_d, &ci_e); const uint64_t d_ci_unit = ci_u - s_post_load_probe_ci_unit0; const uint64_t d_ci_supp = ci_s - s_post_load_probe_ci_supp0; const uint64_t d_ci_none = ci_n - s_post_load_probe_ci_none0; const uint64_t d_ci_sr = ci_sr - s_post_load_probe_ci_sr0; const uint64_t d_ci_deliv = ci_d - s_post_load_probe_ci_deliv0; const uint64_t d_ci_enter = ci_e - s_post_load_probe_ci_enter0; s_post_load_probe_ci_unit0 = ci_u; s_post_load_probe_ci_supp0 = ci_s; s_post_load_probe_ci_none0 = ci_n; s_post_load_probe_ci_sr0 = ci_sr; s_post_load_probe_ci_deliv0 = ci_d; s_post_load_probe_ci_enter0 = ci_e; const uint64_t adv_calls = g_plp_adv_calls; const uint64_t adv_sum = g_plp_adv_sum; const uint32_t adv_max = g_plp_adv_max_chunk; const uint64_t svc_calls = g_plp_svc_calls; const uint64_t d_adv_calls = adv_calls - s_post_load_probe_adv_calls0; const uint64_t d_adv_sum = adv_sum - s_post_load_probe_adv_sum0; const uint64_t d_svc = svc_calls - s_post_load_probe_svc0; s_post_load_probe_adv_calls0 = adv_calls; s_post_load_probe_adv_sum0 = adv_sum; s_post_load_probe_svc0 = svc_calls; g_plp_adv_max_chunk = 0; /* per-frame max */ extern uint64_t g_dirty_ram_insns_run; extern uint64_t g_dirty_pump_count; extern uint64_t g_guest_store_count; const uint64_t d_dirty_insns = g_dirty_ram_insns_run - s_post_load_probe_dirty_insns0; const uint64_t d_dirty_pump = g_dirty_pump_count - s_post_load_probe_dirty_pump0; const uint64_t d_stores = g_guest_store_count - s_post_load_probe_stores0; s_post_load_probe_dirty_insns0 = g_dirty_ram_insns_run; s_post_load_probe_dirty_pump0 = g_dirty_pump_count; s_post_load_probe_stores0 = g_guest_store_count; const uint64_t d_idle_n = g_idle_skip_count - s_post_load_probe_idle_n0; const uint64_t d_idle_cyc = g_idle_skip_cycles - s_post_load_probe_idle_cyc0; s_post_load_probe_idle_n0 = g_idle_skip_count; s_post_load_probe_idle_cyc0 = g_idle_skip_cycles; uint64_t hz_h = 0, hz_c = 0; psx_vsync_query_hle_horizon_totals(&hz_h, &hz_c); const uint64_t d_hz_hits = hz_h - s_post_load_probe_hz_hits0; const uint64_t d_hz_cyc = hz_c - s_post_load_probe_hz_cyc0; s_post_load_probe_hz_hits0 = hz_h; s_post_load_probe_hz_cyc0 = hz_c; const Uint64 host_now = SDL_GetPerformanceCounter(); const Uint64 host_freq = SDL_GetPerformanceFrequency(); const double host_ms = (host_freq > 0) ? (1000.0 * (double)(host_now - s_post_load_probe_host_t0) / (double)host_freq) : 0.0; s_post_load_probe_host_t0 = host_now; GpuDisplayInfo di; gpu_get_display_info(&di); const int rect_dirty = (di.width > 0 && di.height > 0) ? gl_renderer_present_rect_dirty((int)di.display_x, (int)di.display_y, (int)di.width, (int)di.height) : 0; CDROMDebugState cd; cdrom_debug_snapshot(&cd); const int cd_wait = cdrom_savestate_cd_wait_active(); const int boost_left = cdrom_savestate_boost_vblanks_remaining(); const int xa = cdrom_xa_stream_active(); const uint32_t irq_pc = psx_last_irq_check_pc(); const uint32_t resume_pc = psx_compiled_irq_resume_pc(); extern uint32_t g_debug_current_func_addr; extern uint32_t g_debug_last_store_pc; extern int g_psx_dispatch_depth; const uint32_t func = g_debug_current_func_addr; const uint32_t store_pc = g_debug_last_store_pc; const uint32_t live_pc = debug_cpu_ptr ? debug_cpu_ptr->pc : 0u; const uint32_t live_ra = debug_cpu_ptr ? debug_cpu_ptr->gpr[31] : 0u; const int unit_depth = overlay_loader_call_unit_depth(); const int disp_depth = g_psx_dispatch_depth; int cooldown_left = 0; int in_exc = 0; psx_get_freeze_diag(NULL, NULL, &in_exc, &cooldown_left, NULL, NULL); const int stalled = (gp0_delta == 0); if (stalled) { s_post_load_probe_stall_run++; s_post_load_probe_in_stall = 1; post_load_probe_stall_pc_note(irq_pc ? irq_pc : resume_pc); post_load_probe_stall_pc_note(func); post_load_probe_live_pc_note(live_pc); } else if (s_post_load_probe_in_stall) { std::fprintf(stderr, "post_load_probe STALL_END at #%d after %d vblanks " "(irq_pc=0x%08X resume=0x%08X func=0x%08X " "live=0x%08X ra=0x%08X unit=%d disp=%d)\n", s_post_load_probe_i, s_post_load_probe_stall_run, (unsigned)irq_pc, (unsigned)resume_pc, (unsigned)func, (unsigned)live_pc, (unsigned)live_ra, unit_depth, disp_depth); post_load_probe_stall_pc_dump("end"); post_load_probe_live_pc_dump("end"); s_post_load_probe_in_stall = 0; s_post_load_probe_stall_run = 0; s_stall_pc_used = 0; s_live_pc_used = 0; } /* Dense samples during soft-stall; otherwise first 32 + every 15. */ const int log_line = stalled || (s_post_load_probe_i <= 32) || (s_post_load_probe_i % 15 == 0) || (s_post_load_probe_left == 0); if (log_line) { std::fprintf(stderr, "post_load_probe #%d: live=0x%08X ra=0x%08X " "irq_pc=0x%08X resume=0x%08X func=0x%08X " "store=0x%08X idle=0x%08X unit=%d disp=%d turbo=%d reached=%d " "swap=%llu skip=%llu dirty_marks=%llu force=%d rect_dirty=%d " "fb=%ux%u@(%u,%u) dis=%d d24=%d gp0=%llu " "cd(pend=%d cmd=0x%02X dly=%d read=%d rdly=%d xa=%d wait=%d boost=%d) " "exc=%d cool=%d istat=0x%X imask=0x%X " "vb(r=%llu d=%llu a=%llu) tcb=0x%08X " "gp9f8=%d gpA48=%d dirty_blks=%llu dins=%llu dpump=%llu stores=%llu " "sr=0x%08X iec=%d im2=%d cause=0x%08X cyc=%llu host_ms=%.2f " "chk(e=%llu fsr=%llu fn=%llu mid=%llu eval=%llu irq=%llu) " "ci(unit=%llu supp=%llu none=%llu sr=%llu deliv=%llu enter=%llu) " "adv(n=%llu sum=%llu max=%u svc=%llu) " "idle_skip(n=%llu cyc=%llu) hz(n=%llu cyc=%llu)\n", s_post_load_probe_i, (unsigned)live_pc, (unsigned)live_ra, (unsigned)irq_pc, (unsigned)resume_pc, (unsigned)func, (unsigned)store_pc, (unsigned)g_idle_skip_last_pc, unit_depth, disp_depth, turbo_active, present_reached, (unsigned long long)swap, (unsigned long long)skip, (unsigned long long)dirty_marks, force_left, rect_dirty, (unsigned)di.width, (unsigned)di.height, (unsigned)di.display_x, (unsigned)di.display_y, di.disabled ? 1 : 0, di.depth24 ? 1 : 0, (unsigned long long)gp0_delta, cd.pending_pending, (unsigned)cd.pending_cmd, cd.pending_delay, cd.reading, cd.read_delay, xa, cd_wait, boost_left, in_exc, cooldown_left, (unsigned)i_stat, (unsigned)i_mask, (unsigned long long)vb_r, (unsigned long long)vb_d, (unsigned long long)vb_a, (unsigned)tcb, (int)gp_a, (int)gp_b, (unsigned long long)dirty_blks, (unsigned long long)d_dirty_insns, (unsigned long long)d_dirty_pump, (unsigned long long)d_stores, (unsigned)sr, iec, im2, (unsigned)cause, (unsigned long long)d_cyc, host_ms, (unsigned long long)d_entry, (unsigned long long)d_fsr, (unsigned long long)d_fnone, (unsigned long long)d_mid, (unsigned long long)d_eval, (unsigned long long)d_irqd, (unsigned long long)d_ci_unit, (unsigned long long)d_ci_supp, (unsigned long long)d_ci_none, (unsigned long long)d_ci_sr, (unsigned long long)d_ci_deliv, (unsigned long long)d_ci_enter, (unsigned long long)d_adv_calls, (unsigned long long)d_adv_sum, (unsigned)adv_max, (unsigned long long)d_svc, (unsigned long long)d_idle_n, (unsigned long long)d_idle_cyc, (unsigned long long)d_hz_hits, (unsigned long long)d_hz_cyc); } if (s_post_load_probe_left == 0) { if (s_post_load_probe_in_stall) { post_load_probe_stall_pc_dump("done-still-stalled"); post_load_probe_live_pc_dump("done-still-stalled"); } g_plp_cycle_diag = 0; std::fprintf(stderr, "post_load_probe DONE: n=%d swap_tot=%llu skip_tot=%llu " "dirty_tot=%llu gp0_tot=%llu\n", s_post_load_probe_i, (unsigned long long)s_post_load_probe_swap_tot, (unsigned long long)s_post_load_probe_skip_tot, (unsigned long long)s_post_load_probe_dirty_tot, (unsigned long long)s_post_load_probe_gp0_tot); } } /* 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. */ static void savestate_input_guard_arm(void); extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok) { char buf[64]; const int disp = slot + 1; if (!is_load && ok) psx_savestate_menu_note_slots_changed(); if (is_load && ok) savestate_input_guard_arm(); if (is_load) { if (ok) snprintf(buf, sizeof(buf), "Loaded slot %d", disp); else snprintf(buf, sizeof(buf), "Load failed slot %d", disp); } else { if (ok) snprintf(buf, sizeof(buf), "Saved slot %d", disp); else snprintf(buf, sizeof(buf), "Save failed slot %d", disp); } host_osd_push(buf, 2000); } extern "C" void psx_frontend_on_savestate_refused(int is_load, int slot, const char *reason) { (void)is_load; (void)slot; host_osd_push(reason, 3000); } extern "C" void psx_frontend_on_savestate_loaded(void) { psx_projection_reset_session(); psx_local_mouse_reset(); mod_runtime_on_savestate_loaded(); dynres_note_savestate_loaded(); #ifndef PSX_NO_DEBUG_TOOLS debug_server_note_savestate_loaded(); #endif 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; /* Re-anchor guest-cycle→sample budgeting (pump clears queued PCM too). */ g_audio_cycle_resync = 1; /* Depth24 FMV: drop ephemeral present hold/cutover so restored VRAM shows. * Upload span came back with the GPU snap; treat MDEC as already active if * depth24 is on so we don't full-black the first post-load presents. */ gpu_depth24_on_savestate_loaded(); s_d24_cutover_blank = 0; s_d24_saw_gap = 0; s_d24_prev_mdec = gpu_display_is_depth24() ? 1 : 0; /* Depth24 load skipped framebuffer-sized CPU→GPU uploads — including the * full-VRAM boot_state blit — so restage the mirror into the FBO/image * before present. Otherwise post-FMV menus miss texture pages. Safe * no-ops when that backend was never brought up. */ gl_renderer_restage_vram_after_savestate(); vk_renderer_restage_vram_after_savestate(); /* 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(); post_load_probe_arm(); } /* Rollback snap apply / realign — keep depth24 hold clear + restage. Full * savestate present thrash (post_load_probe) stays disk-only. When the tip * is already in FMV/media, mirror the disk cutover reset so resume into * FMV entry does not treat the tip as a fresh gap (permanent black blank). */ extern "C" void psx_frontend_on_rb_snap_loaded(void) { const int media = gpu_display_is_depth24() || mdec_recently_active(8) || cdrom_fmv_stream_pending() || cdrom_xa_stream_active(); g_audio_cycle_resync = 1; gpu_depth24_on_savestate_loaded(); s_d24_cutover_blank = 0; if (media) { s_d24_saw_gap = 0; s_d24_prev_mdec = (gpu_display_is_depth24() || mdec_recently_active(8)) ? 1 : 0; s_disabled_frame_presented = false; s_force_present_after_load = true; } gl_renderer_restage_vram_after_savestate(); vk_renderer_restage_vram_after_savestate(); /* Dual-raster: restaged FBO must reach the window; drop hold-last. */ gl_renderer_invalidate_present(); /* §63: guest SIO came from the snap — drop Live pad-edge trackers. */ psx_netplay_on_rb_snap_loaded(); } static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) { uint64_t hash = 1469598103934665603ull; const size_t step = std::max(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(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 */ /* FMV present reconstruction (VIDEO_FMV_FILTER_*), pushed to the GL renderer * once the config is resolved. Only consulted while g_video_aa is on. */ static int g_video_fmv_filter = PSXRecompV4::VIDEO_FMV_FILTER_DEFAULT; /* Scanline post-process (host display enhancement). Off by default; toggled by * the launcher Display card, the PSX_SCANLINES env override, the F6 hotkey, or * the `scanline` TCP command. Strength 0..1 is the dark-gap depth. Pushed to the * GL renderer each present alongside the FMV filter. */ static bool g_video_scanlines = false; static float g_video_scanline_strength = 0.5f; /* Single point that changes scanline state: keeps the g_video_* mirror (used by * the hotkey and startup banner) in lockstep with the GL renderer, so the F6 * hotkey, the PSX_SCANLINES env override, and the `scanline` TCP command can be * mixed without drifting. Declared extern "C" so debug_server.c can call it. */ extern "C" void psx_video_set_scanlines(int on, float strength) { g_video_scanlines = on ? true : false; if (strength >= 0.f && strength <= 1.f) g_video_scanline_strength = strength; gl_renderer_set_scanlines(g_video_scanlines ? 1 : 0, g_video_scanline_strength); } extern "C" int psx_video_get_scanlines(float *strength) { if (strength) *strength = g_video_scanline_strength; return g_video_scanlines ? 1 : 0; } /* recomp-ui stores this 1-based so a zero-initialized (older) host reads as * "unset" rather than pinning nearest; the config enum is 0-based. Convert at * the boundary, and treat anything out of range as the default. */ static inline int launcher_fmv_filter_to_cfg(int ls_value) { if (ls_value < 1 || ls_value > PSXRecompV4::VIDEO_FMV_FILTER_COUNT) return PSXRecompV4::VIDEO_FMV_FILTER_DEFAULT; return ls_value - 1; } static inline int cfg_fmv_filter_to_launcher(int cfg_value) { if (cfg_value < 0 || cfg_value >= PSXRecompV4::VIDEO_FMV_FILTER_COUNT) cfg_value = PSXRecompV4::VIDEO_FMV_FILTER_DEFAULT; return cfg_value + 1; } static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ static int g_mod_texfilter = -1; extern "C" void psx_mod_set_texture_filter(int mode) { if(mode < 0 || mode > 2) return; g_mod_texfilter = mode; gr_set_texture_filter(mode); } /* Sub-pixel vertex precision + perspective-correct UVs (PGXP-style). Visual * only: the PS1-visible GTE SXY FIFO stays integer, so guest-side culling and * SXY readback are untouched. Default off = the faithful floor. */ static int g_video_geometry_correction = 0; static int g_video_perspective_texturing = 0; static int g_video_pgxp_cpu_mode = 0; static float g_video_pgxp_tolerance = 0.5f; /* docs/ENHANCEMENTS.md G1.11: dataflow-only and exact-projection shadows. * Defaults keep the historical behaviour. game.toml [video] only. */ static int g_video_pgxp_position_fallback = 1; static int g_video_pgxp_preserve_projection = 0; /* [video] pgxp_mod_only (G1.12): the title ships PGXP through the * psx.enhancement.pgxp mod, which is then the one switch -- the [video] * geometry_correction / perspective_texturing / pgxp_cpu_mode values are not * applied and the launcher hides its Perspective textures row. */ static int g_video_pgxp_mod_only = 0; static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; /* Settings -> Display -> Internal resolution (internal_resolution.h). The * preset supersedes the legacy supersampling factor once set; PSX_IR_UNSET * leaves the factor as the player (or game.toml) wrote it. This is the * configured preset (game.toml < settings.toml < launcher) and the only one * the launcher shows or settings.toml saves. PSX_INTERNAL_RESOLUTION lives * apart in g_video_internal_res_env: it wins for the run but is never * persisted, so two local peers sharing one settings.toml stay independent. */ static int g_video_internal_res = PSX_IR_UNSET; static int g_video_internal_res_env = PSX_IR_UNSET; /* Dynamic resolution ([video] dynamic_resolution, dynamic_resolution_min; * dynamic_resolution.h): the internal resolution above is the ceiling, and * the GL scale steps down to the floor only while the game would miss frames. * Same precedence as the preset (game.toml < settings.toml < launcher); * PSX_DYNRES=0/1 and PSX_DYNRES_MIN win for one run, never persisted. */ static int g_video_dynres = 0; static int g_video_dynres_min = 720; static int g_video_dynres_env = -1; static int g_video_dynres_min_env = PSX_IR_UNSET; /* The controller and its per-interval books (dynres_tick). */ struct DynresHost { bool active = false; DynresController ctl{}; uint64_t last_t = 0; uint64_t last_pacer = 0; GlHostLedger last_ledger{}; uint64_t pacer_ticks = 0; /* frame_pacer_wait, while active */ uint64_t step_ticks = 0; /* the step taken after the last sample */ int drawable_w = 0, drawable_h = 0; bool game_started = false; double hold_request_s = 0.0; /* asked by an event (savestate load) */ const char *hold_reason = ""; unsigned long long last_windows = 0; /* the last step, for the trace and the dynres command */ int step_from = 0, step_to = 0; double step_ms = 0.0, step_interval_ms = 0.0; bool step_measure_next = false; FILE *trace = nullptr; double t0_s = 0.0; /* Render-thread mode (dynres_tick_rt): the render thread's per-frame * costs and the queue's backpressure replace the wall-time model. */ bool rt_mode = false; DynrtController rt{}; GlRthCosts last_costs{}; uint64_t last_bp_ns = 0; unsigned long long rt_last_windows = 0; bool rt_trace_header = false; double rt_win_cpu_ms = 0.0, rt_win_gpu_ms = 0.0; /* last window's means */ double rt_acc_cpu = 0.0, rt_acc_gpu = 0.0; uint64_t rt_acc_frames = 0, rt_acc_gpu_frames = 0; }; static DynresHost g_dynres; /* A savestate load re-stages VRAM and re-anchors pacing: hold for a while. */ static void dynres_note_savestate_loaded(void) { g_dynres.hold_request_s = 2.0; } static int g_video_ref_lines = PSX_IR_DEFAULT_REF_LINES; /* The scale asked of the backend before its own clamp (GL reports its real * scale only after context init), and whether that request applies (netplay * software-only forces 1x). Used for the opt-in HiDPI window and Match display. */ static int g_video_requested_scale = 1; static bool g_video_scale_applies = true; static bool g_video_hidpi_window = false; static_assert(PSX_IR_LEGACY_SS_MAX == SW_MAX_INTERNAL_SCALE, "settings.toml supersampling for an older runtime is the software ceiling"); static int video_scale_ceiling(void) { return g_video_renderer == 1 ? GL_MAX_INTERNAL_SCALE : SW_MAX_INTERNAL_SCALE; } /* The preset in effect this run: the environment override, else the * configured one. */ static int effective_internal_resolution(void) { return g_video_internal_res_env != PSX_IR_UNSET ? g_video_internal_res_env : g_video_internal_res; } /* Resolve the Internal resolution preset into g_video_scale. display_px_h is * the monitor's pixel height for Match display (0 = not known yet: 1x until * the window exists). No-op while unset. */ static void apply_internal_resolution(int display_px_h) { const int preset = effective_internal_resolution(); if (preset == PSX_IR_UNSET) return; g_video_scale = psx_resolve_internal_scale(preset, g_video_ref_lines, display_px_h, video_scale_ceiling()); } /* The value the launcher row starts on: the preset, or the legacy factor shown * as the preset it matches (3 -> 720p) or as its own "Nx" entry. */ [[maybe_unused]] static int internal_resolution_for_launcher(void) { if (g_video_internal_res != PSX_IR_UNSET) return g_video_internal_res; return psx_ir_from_supersampling(g_video_scale < 1 ? 1 : g_video_scale, g_video_ref_lines); } static std::string g_bezel_path; /* mod-owned OpenGL margin artwork */ 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 = 0; /* 0 = fit the display; see clamp_window_aspect */ static bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */ static int g_audio_freq = 44100; /* host device request */ static int g_auto_skip_fmv = 0; /* skip FMVs the instant they're detected */ /* Local rewind is opt-in: the snap ring is whole-machine state on a frame * cadence, too much to charge every host for a feature many never open. */ static int g_rewind_enabled = 0; static int g_rewind_depth = 50; /* local rewind snap count (50/100/150/200) */ static int g_rewind_interval = 15; /* frames between snaps (1/4/8/12/15) */ static int g_hotkey_pad_rewind = 1272; /* select + r3 */ static int g_hotkey_pad_save_state_menu = 2040;/* select + r1 */ static int g_hotkey_pad_fast_forward = 1528; /* select + l1 (hold) */ static int g_hotkey_pad_fast_forward_toggle = 0; /* unbound: latch fast-forward */ /* Direct host shortcuts the running title allows (bit per PSX_MOD_SHORTCUT_*, * psx_mod_allow_direct_shortcut); cleared at every mod session start. */ static uint32_t g_direct_shortcut_allowed = 0; /* SDL buttons of P1's gamepad (or the debug host layer) currently claimed by * a direct shortcut and removed from the guest pad; see psx_hotkey_pad.h. */ static uint32_t g_p1_claimed_buttons = 0; static uint32_t g_p1_claim_latched = 0; /* Title-declared direct shortcut ([controller] direct_shortcut / _button): * the launcher captures that action as one button and defaults it there. */ static int g_title_direct_shortcut = -1; static int g_title_direct_button = -1; extern "C" int psx_mod_allow_direct_shortcut(uint32_t shortcut) { if (shortcut > PSX_MOD_SHORTCUT_FAST_FORWARD_TOGGLE) return 0; g_direct_shortcut_allowed |= 1u << shortcut; return 1; } extern "C" void psx_mod_set_rewind_blocked(int blocked) { psx_rewind_set_title_blocked(blocked); } static uint32_t g_savestate_input_guard_min_until = 0; static uint32_t g_savestate_input_guard_max_until = 0; static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ /* Test frontend: the full windowed runtime (presenter, frame interpolation, * render passes) in a window that is never shown. For automated checks of * presentation features without putting a window on the desktop; drive it * over TCP like --headless. */ static int g_hidden_window = 0; static int g_headless_opengl = 0; /* explicit GL validation; fail instead of fallback */ static SDL_Window* s_headless_gl_window = nullptr; /* separate from the host input/present window */ /* 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. * * Driver vsync and the wall-clock pacer are XOR. Waiting on both (pacer then * FIFO SwapBuffers) double-blocks on Linux compositors: ~16.7 ms + ~16.7 ms * = 30 Hz / 0.50x with the CPU idle. ~60 Hz panels may use vsync as the clock; * otherwise the pacer holds 59.94 Hz and present must not wait on the swap. */ static int g_low_latency_input = 1; /* [video] render_thread (docs/RENDER_THREAD.md): the GL backend runs on its * own thread. Started at the first vblank once the context and every startup * GL call are done; s_render_thread_tried keeps it to one attempt. */ static int g_render_thread = 0; static int g_render_thread_frames = 2; static int s_render_thread_tried = 0; static int g_video_vsync = 1; static int g_frame_interpolation = 0; static int g_frame_interpolation_fps = 0; static int g_frame_interpolation_blend = PSX_MOD_FRAME_INTERPOLATION_LINEAR; static int g_frame_interpolation_blend_default = PSX_MOD_FRAME_INTERPOLATION_LINEAR; /* Presentation is sped up this VBlank (manual fast-forward, turbo-through- * loads, FMV auto-skip, TCP turbo). Render passes are refused meanwhile. */ static int s_presentation_fast_forward = 0; extern "C" int psx_presentation_fast_forward(void) { return s_presentation_fast_forward; } /* Mod-owned blend source; reset_mod_owned_presentation() sets VBLANK. */ static int g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK; static std::array g_mod_controller_mode_override = [] { std::array modes{}; modes.fill(-1); return modes; }(); struct ModControllerPresentationPolicy { PSXModControllerPresentationCallback callback = nullptr; int initial_mode = PSXRecompV4::PAD_MODE_ANALOG; int config_capable = 0; }; static ModControllerPresentationPolicy g_mod_controller_policy[PSX_MAX_PLAYERS]; static_assert((int)PSX_MOD_CONTROLLER_ANALOG == (int)PSXRecompV4::PAD_MODE_ANALOG); static_assert((int)PSX_MOD_CONTROLLER_DIGITAL == (int)PSXRecompV4::PAD_MODE_DIGITAL); static double g_host_refresh_hz = 0.0; static constexpr double PSX_FRAME_PERIOD_MS = 1000.0 / 59.94; static double g_guest_frame_period_ms = PSX_FRAME_PERIOD_MS; static double g_frame_period_ms = PSX_FRAME_PERIOD_MS; static int g_host_refresh_display_idx = -2; static uint64_t g_host_refresh_last_probe_ms = 0; static bool g_mod_native_vblank_rate = false; static uint32_t g_mod_native_vblank_fps = 0; /* Activation-time request. -1 means no enabled mod owns load acceleration. */ static int g_mod_load_wall_multiplier = -1; static int g_mod_load_release_frames = -1; static int g_mod_disc_speed_divisor = -1; static int g_mod_disc_instant_rate = -1; static int present_vsync_owns_cadence(void); static int present_effective_swap_interval(void); static int present_should_wall_pace(void); static void apply_present_cadence(void); static void refresh_host_display_cadence(int force_log, int force_probe); /* 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_depth24_trailing_margin = 8; static int g_video_aspect_num = 4; static int g_video_aspect_den = 3; /* [video] fov_scale — VR perspective multiplier on the GTE projection distance * H (1.0 = faithful). env PSX_GTE_FOV_SCALE overrides it. */ static double g_fov_scale = 1.0; /* 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; static bool ensure_sdl_pixel_buf_capacity(size_t pixels) { if (pixels <= sdl_pixel_buf_capacity) return true; void* next = std::realloc(sdl_pixel_buf, pixels * sizeof(uint32_t)); if (!next) { std::fprintf(stderr, "psxrecomp: failed to allocate %zu present pixels\n", pixels); return false; } sdl_pixel_buf = (uint32_t*)next; sdl_pixel_buf_capacity = pixels; return true; } /* [netplay] content_negotiation (opt-in, default off). Off keeps every netplay * session vanilla (mods cleared), the LAN MOTK1 wire protocol, lobby caps * without mod fields, and the neutral headless idle pad. */ static int g_netplay_content_negotiation = 0; /* Install the netplay session's mod plan without touching the persisted * offline selection. Default: vanilla (clear every mod). Opted in: the * host-published lobby plan, or the locally selected verified plan for a * direct/LAN session without published caps (peers then compare its * fingerprint before guest execution). */ static bool netplay_commit_mods(const std::filesystem::path& disc, std::string* error) { if (!g_netplay_content_negotiation) return PSXRecompV4::mod_runtime_commit_netplay_view(disc, error); const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); return (!caps || !caps->valid) ? PSXRecompV4::mod_runtime_commit_for_direct_netplay(disc, error) : PSXRecompV4::mod_runtime_commit_for_netplay(disc, error); } /* game.toml [netplay] local_viewport = "vertical_split": during real netplay, * present only this peer's native split-screen half and stretch it to the * window. Presentation-only; the guest still renders the original framebuffer. */ static int g_netplay_local_viewport = 0; /* 0 off, 1 vertical split */ /* Optional aspect for netplay local-view extraction. Mirrors trusted mod aspect * activation, but remains game.toml opt-in so normal netplay stays vanilla. */ static int g_netplay_local_viewport_aspect = 0; /* 0 off, 1 16:9, 2 21:9, 3 adaptive, 4 fixed mod choice */ /* [netplay] local_viewport_renderer = "projection": widen the projection into * the local half instead of rendering native-wide columns beside it. */ static int g_netplay_local_viewport_projection = 0; /* Native-wide per-camera views ([netplay] local_viewport_renderer = * "native_wide"). Projection views keep the shared presentation path. */ static bool netplay_local_viewport_native_wide(void) { return g_netplay_local_viewport == 1 && !g_netplay_local_viewport_projection; } extern "C" int psx_mod_set_fixed_display_aspect( uint32_t numerator, uint32_t denominator) { if (numerator == 0 || denominator == 0 || numerator > 99 || denominator > 99 || 3u * numerator < 4u * denominator) { std::fprintf(stderr, "psxrecomp: mod rejected invalid display aspect %u:%u\n", (unsigned)numerator, (unsigned)denominator); return 0; } g_video_aspect_num = (int)numerator; g_video_aspect_den = (int)denominator; g_ws_adaptive_view = false; std::fprintf(stdout, "psxrecomp: mod selected fixed display aspect %u:%u\n", (unsigned)numerator, (unsigned)denominator); return 1; } extern "C" int psx_mod_set_adaptive_display_aspect( uint32_t max_numerator, uint32_t max_denominator) { const bool uncapped = max_numerator == 0 && max_denominator == 0; if (!uncapped && (max_numerator == 0 || max_denominator == 0 || max_numerator > 99 || max_denominator > 99 || 3u * max_numerator < 4u * max_denominator)) { std::fprintf(stderr, "psxrecomp: mod rejected invalid adaptive display aspect %u:%u\n", (unsigned)max_numerator, (unsigned)max_denominator); return 0; } g_ws_adaptive_view = true; g_ws_adaptive_max_num = (int)max_numerator; g_ws_adaptive_max_den = (int)max_denominator; if (uncapped) { std::fprintf(stdout, "psxrecomp: mod selected adaptive display aspect " "(initial %d:%d, fit to window, no upper aspect limit)\n", g_video_aspect_num, g_video_aspect_den); } else std::fprintf(stdout, "psxrecomp: mod selected adaptive display aspect " "(initial %d:%d, range 4:3 through %u:%u)\n", g_video_aspect_num, g_video_aspect_den, (unsigned)max_numerator, (unsigned)max_denominator); return 1; } /* * Mod-owned session state: what a trusted plugin's psx_mod_* setters (or the * netplay local viewport, through the same setters) change and no launcher * control owns. Called at every session start, BEFORE activation: by * start_mod_session() in main(), immediately before * mod_runtime_activate_plugins(). The first boot and the lobby rematch both * run that sequence after their commit or netplay clear. * * The rematch is the only in-process second session, and the session before * it is always a netplay match: an offline session ends the process, and only * a netplay match launched from the lobby soft-returns. Netplay clears the mod * plan, so no plugin activated before a rematch. What can leak today is the * Fit and fixed aspect apply_netplay_local_viewport_aspect() set, into an * offline rematch. The rest is defensive, for the state listed here only; see * docs/MOD_PACKAGES.md "Session starts" for what is exempt and why (the * renderer and fixed aspect are launcher controls; guest/GPU-DMA memory, * texture-packet arenas and defined texture banks live for the process). * * Function-entry hooks need nothing here: the commit or netplay clear empties * upstream's hook table, and only activation rebuilds it. * * The first call captures the pre-activation scalars (mod_session_baseline.h) * and changes nothing that is not already at its initial value, so the first * session -- every run that never soft-returns -- behaves exactly as before. * Later calls restore those scalars and also clear the 8 MiB RAM request * (memory_init() at session_reboot re-latches it) and the texture-bank * resolver and batching flag, which a plugin sets in activation. */ static PSXModSessionBaseline g_mod_owned_baseline; static void reset_mod_owned_presentation(void) { psx_local_mouse_clear(); PSXModSessionScalars live; live.video_vsync = g_video_vsync; live.frame_interpolation = g_frame_interpolation; live.frame_interpolation_fps = g_frame_interpolation_fps; live.auto_skip_fmv = g_auto_skip_fmv; live.guest_frame_period_ms = g_guest_frame_period_ms; live.frame_period_ms = g_frame_period_ms; /* psx_mod_set_frame_interpolation / _native_vblank_rate force vsync off; * _auto_skip_fmv changes guest-visible FMV timing. */ const int first = psx_mod_session_baseline_apply( &g_mod_owned_baseline, &live, g_mod_native_vblank_rate ? 1 : 0); g_video_vsync = live.video_vsync; g_frame_interpolation = live.frame_interpolation; g_frame_interpolation_fps = live.frame_interpolation_fps; g_auto_skip_fmv = live.auto_skip_fmv; g_guest_frame_period_ms = live.guest_frame_period_ms; g_frame_period_ms = live.frame_period_ms; g_mod_native_vblank_rate = false; g_mod_native_vblank_fps = 0; g_mod_texfilter = -1; gr_set_texture_filter(g_video_texfilter); if (!first) { psx_ram_reset_size_request(); psx_mod_set_texture_bank_resolver(nullptr); psx_mod_set_texture_bank_batching(0); } /* Fit / capped resize-driven aspect. The fixed aspect is the launcher's * (and netplay's) to set, so it is left alone here. */ g_ws_adaptive_view = false; g_ws_adaptive_max_num = 16; g_ws_adaptive_max_den = 9; psx_mod_set_world_scene_predicate(nullptr); gpu_ws_set_native_scene_predicate(nullptr); psx_mod_set_retained_scene_predicate(nullptr); psx_mod_set_adaptive_backdrop_preload(0); (void)psx_mod_set_draw_distance_clamp(0); g_bezel_path.clear(); g_frame_interpolation_blend = g_frame_interpolation_blend_default; g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK; /* The PGXP mod's request: only this session's activation may set it; the * renderer setup takes it and combines it with the [video] baseline * (pgxp_session.h). */ psx_pgxp_session_reset(); /* Render-pass counters, the disabled-after-faults latch and any open * plan generation belong to the session that made them. */ render_pass_reset_session(); } /* The disc a session mounts: a disc-patching mod's private patched image when * the committed plan built one, else the stock disc, which stays untouched * (master behaviour). Every session start calls it after its commit. */ static std::string session_disc_path(const std::filesystem::path& stock_disc) { const std::filesystem::path& mod_disc = PSXRecompV4::mod_runtime_effective_disc_path(); if (mod_disc.empty()) return stock_disc.string(); std::fprintf(stdout, "psxrecomp: stock disc remains %s; mounting private mod cache %s\n", stock_disc.string().c_str(), mod_disc.string().c_str()); return mod_disc.string(); } extern "C" int psx_mod_set_native_vblank_rate( uint32_t frames_per_second) { if (frames_per_second != 0 && (frames_per_second < 60 || frames_per_second > 1000)) { std::fprintf(stderr, "psxrecomp: mod rejected invalid native VBlank rate %u FPS\n", (unsigned)frames_per_second); return 0; } g_mod_native_vblank_rate = true; g_mod_native_vblank_fps = frames_per_second; g_guest_frame_period_ms = frames_per_second ? 1000.0 / (double)frames_per_second : 0.0; g_frame_period_ms = g_guest_frame_period_ms; /* * Above the physical panel rate (and in uncapped mode), swap-interval * blocking would silently replace the requested guest cadence with the * display cadence. The frame pacer owns fixed-rate timing here. */ if (frames_per_second == 0 || frames_per_second > 60) g_video_vsync = 0; if (frames_per_second) { std::fprintf(stdout, "psxrecomp: mod selected native guest VBlank pacing at %u FPS " "(%.4f ms/frame)\n", (unsigned)frames_per_second, g_frame_period_ms); } else { std::fprintf(stdout, "psxrecomp: mod selected uncapped native guest VBlank pacing\n"); } return 1; } extern "C" int psx_mod_set_frame_interpolation( uint32_t frames_per_second) { if (frames_per_second != 0 && (frames_per_second < 60 || frames_per_second > 1000)) { std::fprintf(stderr, "psxrecomp: mod rejected invalid interpolated frame rate %u FPS\n", (unsigned)frames_per_second); return 0; } /* * Interpolation is a presentation feature of the OpenGL renderer. Mods are * activated before the game window and renderer are created, so selecting * it here is deterministic and does not require a live backend switch. */ g_video_renderer = 1; g_video_vsync = 0; g_frame_interpolation = 1; g_frame_interpolation_fps = frames_per_second ? (int)frames_per_second : 0; if (frames_per_second) { std::fprintf(stdout, "psxrecomp: mod selected presentation-only temporal blending at " "%u presents/s; guest timing remains stock (no motion vectors)\n", (unsigned)frames_per_second); } else { std::fprintf(stdout, "psxrecomp: mod selected display-refresh presentation-only temporal " "blending; guest timing remains stock (no motion vectors)\n"); } return 1; } extern "C" int psx_mod_set_frame_interpolation_blend( uint32_t blend_mode) { if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE && blend_mode != PSX_MOD_FRAME_INTERPOLATION_HOLD) { std::fprintf(stderr, "psxrecomp: mod rejected invalid frame-interpolation blend %u\n", (unsigned)blend_mode); return 0; } g_frame_interpolation_blend = (int)blend_mode; /* Live when the presenter is already configured (a later call from a * hook); before that, session start hands it over with the rates. */ gl_renderer_set_interpolation_blend(g_frame_interpolation_blend); std::fprintf(stdout, "psxrecomp: frame-interpolation blend = %s\n", blend_mode == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE ? "motion-adaptive clarity" : blend_mode == PSX_MOD_FRAME_INTERPOLATION_HOLD ? "hold (render passes supply in-between frames)" : "linear crossfade"); return 1; } extern "C" int psx_mod_set_frame_interpolation_source(uint32_t source) { if (source != PSX_MOD_FRAME_SOURCE_VBLANK && source != PSX_MOD_FRAME_SOURCE_FLIP) { std::fprintf(stderr, "psxrecomp: mod rejected invalid frame-interpolation source %u\n", (unsigned)source); return 0; } g_frame_interpolation_source = (int)source; std::fprintf(stdout, "psxrecomp: frame-interpolation source = %s\n", source == PSX_MOD_FRAME_SOURCE_FLIP ? "guest frame flips" : "every guest VBlank"); return 1; } extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { if (enabled != 0 && enabled != 1) { std::fprintf(stderr, "psxrecomp: mod rejected invalid auto-skip-FMV value %d\n", enabled); return 0; } g_auto_skip_fmv = enabled; std::fprintf(stdout, "psxrecomp: mod %s automatic FMV skipping\n", enabled ? "enabled" : "disabled"); return 1; } extern "C" int psx_mod_set_bezel_artwork(const char* path) { if (!path || !path[0]) { std::fprintf(stderr, "psxrecomp: mod rejected empty bezel artwork path\n"); return 0; } g_bezel_path = path; g_video_renderer = 1; std::fprintf(stdout, "psxrecomp: mod selected bezel artwork %s\n", g_bezel_path.c_str()); return 1; } extern "C" int psx_mod_set_load_acceleration( uint32_t wall_clock_multiplier, uint32_t release_frames) { /* Host pacing only changes how fast wall-clock time is fed to a load; every * guest frame, CD deadline, interrupt and callback still happens, so a high * multiplier cannot desync the guest -- it just approaches "as fast as the * host can". Ceiling raised from 16 for the same reason as disc speed: * players set this as an integer and want to find their own limit. */ if (wall_clock_multiplier > PSX_MOD_LOAD_ACCEL_MAX || release_frames > 60) { std::fprintf(stderr, "psxrecomp: mod rejected load acceleration " "(multiplier %u, release frames %u)\n", (unsigned)wall_clock_multiplier, (unsigned)release_frames); return 0; } g_mod_load_wall_multiplier = (int)wall_clock_multiplier; g_mod_load_release_frames = (int)release_frames; return 1; } extern "C" int psx_mod_set_disc_speed( uint32_t divisor, uint32_t instant_max_per_frame) { /* Any positive divisor is arithmetically safe: cdrom.c divides the sector * delay by it and floors the result at CDROM_MIN_DELAY, and XA streaming * keeps authentic timing regardless. The old 2-or-4 allowlist was policy, * not a hardware constraint, and it blocked players from finding the * highest speed their game tolerates (GH TombaRecomp#5). Games expose the * value as a player-set integer, so accept the full sane range and let the * mod's own description carry the risk warning. */ if (divisor > PSX_MOD_DISC_SPEED_MAX || (divisor == 0 && (instant_max_per_frame < 1 || instant_max_per_frame > 256))) { std::fprintf(stderr, "psxrecomp: mod rejected disc speed " "(divisor %u, instant budget %u)\n", (unsigned)divisor, (unsigned)instant_max_per_frame); return 0; } g_mod_disc_speed_divisor = (int)divisor; g_mod_disc_instant_rate = divisor == 0 ? (int)instant_max_per_frame : -1; return 1; } extern "C" int psx_mod_set_controller_mode_override( uint32_t player, uint32_t controller_mode) { if (player >= PSX_MAX_PLAYERS || (controller_mode != (uint32_t)PSXRecompV4::PAD_MODE_ANALOG && controller_mode != (uint32_t)PSXRecompV4::PAD_MODE_DIGITAL)) { std::fprintf(stderr, "psxrecomp: mod rejected controller-mode override " "(player %u, mode %u)\n", (unsigned)player, (unsigned)controller_mode); return 0; } g_mod_controller_mode_override[player] = (int)controller_mode; return 1; } extern "C" int psx_mod_set_controller_presentation_policy( uint32_t player, PSXModControllerPresentationCallback callback, uint32_t initial_mode, int config_capable) { if (player >= PSX_MAX_PLAYERS || !callback || (initial_mode != (uint32_t)PSXRecompV4::PAD_MODE_ANALOG && initial_mode != (uint32_t)PSXRecompV4::PAD_MODE_DIGITAL)) { std::fprintf(stderr, "psxrecomp: mod rejected controller-presentation policy " "(player %u, initial mode %u)\n", (unsigned)player, (unsigned)initial_mode); return 0; } g_mod_controller_policy[player].callback = callback; g_mod_controller_policy[player].initial_mode = (int)initial_mode; g_mod_controller_policy[player].config_capable = config_capable ? 1 : 0; return 1; } /* [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_precise_nclip(int on); extern "C" void gpu_ws_set_netplay_local_viewport(int enabled, int slot); extern "C" int gpu_ws_netplay_local_viewport_base_x(void); extern "C" int gpu_ws_netplay_local_viewport_width(void); 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); extern "C" void gpu_ws_set_masked_reject_sites(const uint32_t*, 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; /* Opening client size for the game window, from the primary display's usable * area (see window_size.h). An explicit width is clamped to fit; no width * opens a normal window at a whole multiple of 240 lines, about two thirds of * the usable height. Sizing from the panel rather than a fixed 1280 keeps a * 4K or 8K display from getting a small image far below the internal render * resolution. Keeps the given aspect: height = width*den/num. */ static void clamp_window_aspect(int* w, int* h, int num, int den) { SDL_Rect bounds; const int have_bounds = (SDL_GetDisplayUsableBounds(0, &bounds) == 0 && bounds.w > 0 && bounds.h > 0); psx_window_size(w, h, num, den, have_bounds, have_bounds ? bounds.w : 0, have_bounds ? bounds.h : 0); } static int aspect_gcd(int a, int b) { while (b) { int t = a % b; a = b; b = t; } return a > 0 ? a : 1; } static int64_t aspect_gcd64(int64_t a, int64_t b) { if (a < 0) a = -a; if (b < 0) b = -b; while (b) { int64_t t = a % b; a = b; b = t; } return a > 0 ? a : 1; } static void netplay_local_viewport_projection_aspect( int present_num, int present_den, int* proj_num, int* proj_den) { if (!proj_num || !proj_den) return; *proj_num = present_num; *proj_den = present_den; if (g_netplay_local_viewport != 1 || !psx_netplay_active() || !gpu_last_frame_vertical_split_screen() || present_num * 3 <= present_den * 4) { return; } GpuDisplayInfo di; gpu_get_display_info(&di); if (di.disabled || di.depth24 || di.width < 2 || di.height == 0) return; /* The normal widescreen squash assumes a 4:3 source. A split-screen peer * source is half the display, which spans 4:3 at whatever pixel aspect * its mode has, so the half is a 2:3 view and the equivalent aspect that * produces source_aspect / target_aspect as the X squash factor is * * effective = (4:3) * target / (2:3) = 2 * target * * (The earlier (display_w/2)/display_h source assumed square pixels and * under-widened 512-wide splits.) */ int64_t n = (int64_t)present_num * 2; int64_t d = (int64_t)present_den; if (n <= 0 || d <= 0) return; int64_t gcd = aspect_gcd64(n, d); n /= gcd; d /= gcd; if (n <= 0 || d <= 0 || n > INT32_MAX || d > INT32_MAX) return; *proj_num = (int)n; *proj_den = (int)d; } static int g_ws_projection_num = 4; static int g_ws_projection_den = 3; static int g_ws_projection_mode = -1; extern "C" void gte_set_fov_scale(int num, int den); static void refresh_widescreen_projection() { if (g_ws_engaged && !fntrace_is_game_started()) { g_ws_engaged = false; g_ws_projection_mode = -1; gte_set_display_aspect(4, 3); gpu_ws_configure(4, 3, g_ws_anchor_addr, 0, 0); } /* VR FOV: scale the GTE projection distance H (fov = 2*atan(w/(2H))). * Independent of the widescreen squash; identity by default. */ if (g_fov_scale > 0.0 && g_fov_scale != 1.0) gte_set_fov_scale(1000, psx_projection_scale_denominator(g_fov_scale)); else gte_set_fov_scale(1, 1); if (!g_ws_engaged) return; const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; const bool local_native_wide = g_netplay_local_viewport == 1 && !g_netplay_local_viewport_projection && psx_netplay_active() && gpu_last_frame_vertical_split_screen(); /* Native-wide local views follow the title's own renderer offline; * projection local views keep their per-camera projection throughout. */ const bool native_wide = (g_netplay_local_viewport == 1 && (g_netplay_local_viewport_projection || psx_netplay_active())) ? local_native_wide : (g_ws_native_wide != 0); /* Squash changes the GTE projection, which the guest sees: never while * widescreen is a netplay peer's own-view presentation. */ const int mode = wide ? (native_wide ? 2 : (gpu_ws_local_view_only() ? 0 : 1)) : 0; int proj_num = g_video_aspect_num; int proj_den = g_video_aspect_den; if (mode == 1) { netplay_local_viewport_projection_aspect( g_video_aspect_num, g_video_aspect_den, &proj_num, &proj_den); } if (mode == g_ws_projection_mode && proj_num == g_ws_projection_num && proj_den == g_ws_projection_den) { return; } g_ws_projection_mode = mode; g_ws_projection_num = proj_num; g_ws_projection_den = proj_den; gte_set_display_aspect(mode == 1 ? proj_num : 4, mode == 1 ? proj_den : 3); gpu_ws_configure(proj_num, proj_den, g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); } /* 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; /* A nominal 4:3 client can be one physical pixel wider after fractional-DPI * conversion (for example 960x720 -> 2161x1620 at 225%). Keep that * rounding pixel in the identity fallback instead of engaging an almost- * identity widescreen squash and its cull guard. */ if ((int64_t)(width - 1) * 3 <= (int64_t)height * 4) { num = 4; den = 3; } else if (g_ws_adaptive_max_num > 0 && g_ws_adaptive_max_den > 0 && (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; } /* Native-wide margins live in a GL surface g_wide_w*S wide, which the * driver limit bounds (on a 16384 limit, 1024 native columns at 16x and * 910 at 8K's 18x: about 38:9 and 34:9 at a 320-px display). The backend * refuses a wider one and that frame drops to a 1x CPU present without * the margins, so narrow the aspect to the widest the surface holds; the * window pillarboxes the rest. A no-op unless the surface would be * refused. */ if (g_ws_native_wide && (int64_t)num * 3 > (int64_t)den * 4) gl_renderer_fit_wide_aspect(gpu_ws_display_width(), &num, &den); 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); vk_renderer_set_display_aspect(num, den); if (sdl_renderer) { g_logical_w = (int)((int64_t)480 * num * g_video_scale / den); SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale); } refresh_widescreen_projection(); } /* 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); int major = 3, minor = 3; #if defined(PSX_OPENXR) /* Opt-in headset context (docs/OPENXR_RENDERING.md). Ordinary GL launches * keep the historic 3.3. The environment variable is PSX_OPENXR_ENABLE; * the bare PSX_OPENXR spelling collides with the CMake option of the same * name, so it is deprecated (still honoured, with a note). */ const char *xr = std::getenv("PSX_OPENXR_ENABLE"); const char *xr_old = std::getenv("PSX_OPENXR"); const bool xr_on = xr && xr[0] && xr[0] != '0'; const bool xr_old_on = xr_old && xr_old[0] && xr_old[0] != '0'; static bool s_xr_ctx_logged = false; if (xr_on || xr_old_on) { major = 4; minor = 6; if (!s_xr_ctx_logged) { s_xr_ctx_logged = true; std::fprintf(stderr, "psxrecomp: OpenXR build: %s requests an OpenGL 4.6 core context " "(default is 3.3)\n", xr_on ? "PSX_OPENXR_ENABLE" : "PSX_OPENXR"); if (!xr_on) std::fprintf(stderr, "psxrecomp: environment variable PSX_OPENXR is deprecated " "(it shares its name with the CMake option); use PSX_OPENXR_ENABLE\n"); } } #endif SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, major); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, minor); 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; g_ws_projection_mode = -1; refresh_widescreen_projection(); } extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } /* TCP diagnostics: change the rendered view without moving, resizing, raising * or focusing the user's window. Transient; never writes settings.toml. */ extern "C" int psx_debug_display_aspect(int num, int den, int adaptive) { if (adaptive) return psx_mod_set_adaptive_display_aspect(num, den); if (!psx_mod_set_fixed_display_aspect(num, den)) return 0; gl_renderer_set_display_aspect(num, den); vk_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); } g_ws_projection_mode = -1; refresh_widescreen_projection(); return 1; } /* Resize the game window as a player would (Fit/adaptive widescreen follow the * resulting window events). For TCP-driven checks of window-shaped behaviour * in --hidden-window runs. */ extern "C" int psx_debug_window_size(int w, int h) { if (!sdl_window || w < 64 || h < 64 || w > 16384 || h > 16384) return 0; SDL_SetWindowSize(sdl_window, w, h); return 1; } 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_shot — capture the COMPOSED present surface, i.e. the frame after the * backend has fitted the display buffer to the window, so the PNG carries the * aspect the player is actually looking at. * * screenshot / screenshot_file / screenshot_hires all resolve the display * buffer BEFORE that fit: on a 508x256 display in a 4:3 window they answer * 508x256 while the window shows 640x480. Correct for faithfulness work, wrong * for anything aspect-shaped — a widescreen change moves the GTE squash and the * present fit, which is exactly the stage those captures skip. * * Staged here and fulfilled by whichever backend owns the present: the SDL * software path reads back via SDL_RenderReadPixels, the GL path via * glReadPixels before SwapWindow (gpu_gl_renderer.c). * * THREADING: the request is written from a debug-server command handler on the * emu thread (debug_server_poll safe point), but GL fulfilment can run on the * frame-interpolation thread (gpu_gl_renderer.c interp_present -> gl_swap_with_osd), * so the path buffer and the pending flag are mutex-guarded and the counters are * atomic. present_shot_take() CLAIMS the request under the lock, so two present * paths can never fulfil the same shot. * * A staged shot is OVERWRITTEN by a later request rather than refused (the same * choice savestate's request_save_inner makes). A present that never arrives — * a static frame, a backend that stops presenting — therefore cannot wedge the * command: the next request simply replaces it. */ static std::mutex s_present_shot_mtx; static char s_present_shot_path[512]; static bool s_present_shot_pending = false; static std::atomic s_present_shot_seq{0}; /* bumps on every completion */ static std::atomic s_present_shot_ok{0}; /* 1 = last completion wrote a PNG */ /* video_info (debug server): the Internal resolution state as main.cpp sees * it, plus the game window's size in points and in pixels. */ extern "C" void psx_video_resolution_info(int *preset, int *ref_lines, int *requested, int *hidpi, int *win_w, int *win_h, int *px_w, int *px_h) { if (preset) *preset = effective_internal_resolution(); if (ref_lines) *ref_lines = g_video_ref_lines; if (requested) *requested = g_video_requested_scale; if (hidpi) *hidpi = g_video_hidpi_window ? 1 : 0; int ww = 0, wh = 0, pw = 0, ph = 0; #ifndef PSX_SDL_NO_RENDER if (sdl_window) { SDL_GetWindowSize(sdl_window, &ww, &wh); SDL_GL_GetDrawableSize(sdl_window, &pw, &ph); } #endif if (win_w) *win_w = ww; if (win_h) *win_h = wh; if (px_w) *px_w = pw; if (px_h) *px_h = ph; } extern "C" int present_shot_request(const char *path) { if (!path || !*path) return 0; if (g_headless) return 0; /* no present surface to read back */ /* Vulkan owns its own swapchain present (see the g_vk_active branch in * sdl_vblank_present_body) and has no readback hook, so nothing would ever * fulfil the request. Refuse honestly instead of accepting a shot that can * never complete — CLAUDE.md rule 15. */ if (g_vk_active) return 0; { std::lock_guard lk(s_present_shot_mtx); std::snprintf(s_present_shot_path, sizeof(s_present_shot_path), "%s", path); s_present_shot_pending = true; } return 1; } /* Backend hook: atomically claim a staged shot (returns 1 and fills `out`), * then call present_shot_done(ok) once the PNG is written — or not written. */ extern "C" int present_shot_take(char *out, int n) { if (!out || n <= 0) return 0; std::lock_guard lk(s_present_shot_mtx); if (!s_present_shot_pending) return 0; std::snprintf(out, (size_t)n, "%s", s_present_shot_path); s_present_shot_pending = false; /* claimed */ return 1; } /* ok = 1 only when a PNG actually landed on disk. seq advances either way so a * client polling it always terminates; ok tells it whether the file exists. */ extern "C" void present_shot_done(int ok) { s_present_shot_ok.store(ok ? 1 : 0, std::memory_order_release); s_present_shot_seq.fetch_add(1, std::memory_order_release); } extern "C" int present_shot_seq(void) { return s_present_shot_seq.load(std::memory_order_acquire); } extern "C" int present_shot_ok(void) { return s_present_shot_ok.load(std::memory_order_acquire); } /* 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; /* Cleared on lobby soft-return so rematch can re-arm CPU-auth GPU lock. */ static int s_netplay_sw_gpu_locked; /* Netplay + user OpenGL: dual-raster (SW@1× authority + GL@Nx present) or * fallback SW-only + CPU→GL present when dual could not arm. */ static int s_netplay_gl_present; /* Netplay SW-only path: sim at scale 1 (no SW SSAA hi-res mirror). * Dual-raster OpenGL uses g_video_scale for the hr FBO; SW stays 1 via * glb_set_scale. Cleared on lobby soft-return. */ static int s_netplay_sim_native_scale; /* True while netplay owns the GPU path: CPU VRAM is digest/snap authority. */ static int netplay_cpu_auth_gpu(void) { return s_netplay_sw_gpu_locked || s_netplay_gl_present || s_netplay_sim_native_scale; } /* Dual-raster present quality: GL FBO at settings SSAA, SW@1× for snaps. */ static int netplay_gl_dual_quality(void) { #ifndef PSX_SDL_NO_RENDER return s_netplay_gl_present && g_gl_active && g_gl_fbo_present && gl_renderer_cpu_auth_dual(); #else return 0; #endif } #ifndef PSX_SDL_NO_RENDER static int ensure_sdl_present_texture(int min_width, int min_height) { if (sdl_texture && min_width <= sdl_texture_width && min_height <= sdl_texture_height) return 0; const int width = std::max(min_width, sdl_texture_width); const int height = std::max(min_height, sdl_texture_height); SDL_Texture* next = SDL_CreateTexture(sdl_renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, width, height); if (!next) { std::fprintf(stderr, "psxrecomp: SDL_CreateTexture %dx%d failed: %s\n", width, height, SDL_GetError()); return -1; } SDL_SetTextureScaleMode(next, g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); if (sdl_texture) SDL_DestroyTexture(sdl_texture); sdl_texture = next; sdl_texture_width = width; sdl_texture_height = height; return 0; } /* Create SDL_Renderer + streaming texture for software present. Used when * netplay runs without a live GL context (software renderer selected, or GL * init failed). CPU-auth + OpenGL present does not need this. */ static int ensure_sw_sdl_present(void) { if (!sdl_window) return -1; if (!sdl_renderer) { #ifdef _WIN32 SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl"); #endif Uint32 rflags = SDL_RENDERER_ACCELERATED | (present_effective_swap_interval() != 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, "psxrecomp: netplay SW present: SDL_CreateRenderer failed: %s\n", SDL_GetError()); return -1; } /* Netplay CPU-auth: size logical/texture at 1× (sim has no hi-res * mirror). Offline SSAA preference stays in g_video_scale. */ const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; g_logical_w = 480 * g_video_aspect_num * tex_scale / g_video_aspect_den; if (g_logical_w < 1) g_logical_w = 640; SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * tex_scale); } if (!sdl_texture) { const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; if (ensure_sdl_present_texture( 640 * tex_scale, (int)PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale) != 0) { std::fprintf(stderr, "psxrecomp: netplay SW present: SDL_CreateTexture failed: %s\n", SDL_GetError()); return -1; } } if (!sdl_pixel_buf) { int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; if (tex_scale > SW_MAX_INTERNAL_SCALE) tex_scale = SW_MAX_INTERNAL_SCALE; if (!ensure_sdl_pixel_buf_capacity( (size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale)) { std::fprintf(stderr, "psxrecomp: netplay SW present: staging alloc failed\n"); return -1; } } return 0; } #endif /* Rollback/delay netplay: guest VRAM must be CPU-authoritative. Prefer * dual-raster OpenGL: SW@1× writes snaps/digests/GPUREAD; GL@settings-scale * hr FBO is present-only (g_gl_fbo_present=1, never glReadPixels). Fallback * when no GL context: pure software SDL present at scale 1. */ extern "C" void psx_frontend_netplay_force_sw_gpu(void) { /* Every branch below makes the CPU rasterizer the guest's VRAM authority, * so it draws the hardware image: no PGXP override, no texture filter * (sw_set_faithful_authority). Presentation keeps them where GL presents. */ sw_set_faithful_authority(1); #ifndef PSX_SDL_NO_RENDER /* Already locked on dual-raster GL quality present. */ if (s_netplay_sw_gpu_locked && netplay_gl_dual_quality()) { /* The GL present surface is not bound by the software mirror's 4x * (dual-raster keeps SW at 1x); the backend clamps to its own limits. */ int want = g_video_scale < 1 ? 1 : g_video_scale; if (want > GL_MAX_INTERNAL_SCALE) want = GL_MAX_INTERNAL_SCALE; gr_set_backend(GR_BACKEND_OPENGL); gl_renderer_set_cpu_auth_dual(1); /* FBO scale is fixed at context init — keep request in sync. */ gr_set_scale(want); s_netplay_sim_native_scale = 1; return; } /* Already locked on legacy CPU→GL present (no dual). */ if (s_netplay_sw_gpu_locked && g_gl_active && !g_gl_fbo_present) { gr_set_scale(1); s_netplay_sim_native_scale = 1; return; } /* Already locked on pure software SDL present. */ if (s_netplay_sw_gpu_locked && !g_gl_active && !g_vk_active && sdl_renderer && sdl_texture && sdl_pixel_buf) { gr_set_scale(1); s_netplay_sim_native_scale = 1; return; } #endif #ifndef PSX_SDL_NO_RENDER /* If we were on FBO-auth GL (offline), pull VRAM to CPU once before * arming dual. Caller must invoke this before g_np.active (ensure_cpu * no-ops while netplay is active). */ if (g_gl_active && gr_backend() == GR_BACKEND_OPENGL && !gl_renderer_cpu_auth_dual()) gl_renderer_sync_cpu(); if (g_vk_active) { /* Vulkan CPU-auth present not wired yet — tear down to SW SDL. */ vk_renderer_sync_cpu(); vk_renderer_shutdown(); g_vk_active = false; s_netplay_gl_present = 0; } #endif #ifndef PSX_SDL_NO_RENDER if (g_gl_active) { /* Dual-raster: OPENGL backend, SW@1× + GPU@Nx, FBO present. */ /* The GL present surface is not bound by the software mirror's 4x * (dual-raster keeps SW at 1x); the backend clamps to its own limits. */ int want = g_video_scale < 1 ? 1 : g_video_scale; if (want > GL_MAX_INTERNAL_SCALE) want = GL_MAX_INTERNAL_SCALE; gr_set_backend(GR_BACKEND_OPENGL); gl_renderer_set_cpu_auth_dual(1); gr_set_scale(want); g_gl_fbo_present = true; s_netplay_gl_present = 1; s_netplay_sim_native_scale = 1; gl_renderer_restage_vram_after_savestate(); gl_renderer_invalidate_present(); s_netplay_sw_gpu_locked = 1; latency_ring_set_backend("opengl"); fprintf(stderr, "psxrecomp: netplay dual-raster " "(SW@1x CPU-auth + OpenGL@%dx present; no FBO readback)\n", want); fflush(stderr); return; } /* Software present (user picked software, GL init failed, or Vulkan). * Do not clear g_video_renderer — that preference must survive rematch / * first settings.toml write so OpenGL stays the user's default. */ g_gl_fbo_present = false; gl_renderer_set_cpu_auth_dual(0); gr_set_backend(GR_BACKEND_SOFTWARE); gr_set_scale(1); s_netplay_sim_native_scale = 1; s_netplay_gl_present = 0; if (ensure_sw_sdl_present() != 0) { s_netplay_sw_gpu_locked = 0; std::fprintf(stderr, "psxrecomp: netplay SW GPU forced but present path failed " "(expect black/empty window — will retry)\n"); fflush(stderr); return; } s_netplay_sw_gpu_locked = 1; #else gr_set_backend(GR_BACKEND_SOFTWARE); gr_set_scale(1); s_netplay_sim_native_scale = 1; s_netplay_sw_gpu_locked = 1; #endif latency_ring_set_backend("software"); fprintf(stderr, "psxrecomp: netplay software GPU " "(CPU-authoritative VRAM; sim scale 1)\n"); fflush(stderr); } /* 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 */ } static int g_turbo_audio_sink_config_enabled = 0; /* 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 /* Zero multiplier retains the historical uncapped turbo behavior. */ static int g_turbo_load_wall_multiplier = 0; static int g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; 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(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(stream), frames); } static std::filesystem::path find_upward(std::filesystem::path start, const std::filesystem::path& marker) { std::error_code ec; start = PSXRecompV4::host_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 = PSXRecompV4::host_absolute(appimg, ec).parent_path(); if (ec) exe_dir.clear(); } } if (exe_dir.empty() && argv0 && argv0[0]) { exe_dir = PSXRecompV4::host_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 PSXRecompV4::host_absolute(p, ec); if (PSXRecompV4::host_path_is_absolute(p)) 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 PSXRecompV4::host_absolute(direct, ec); fs::path found = find_upward(root, p); if (!found.empty()) return PSXRecompV4::host_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; } /* Relative paths in settings.toml and the bios.cfg / disc.cfg sidecars are * relative to the game folder. Resolve them against the exe directory, never * the working directory: a shortcut or frontend that starts the game from * elsewhere must still find its own memory cards and BIOS. Absolute paths are * returned unchanged; empty stays empty. */ static std::filesystem::path anchor_on_exe_dir(const char* argv0, const std::filesystem::path& p) { if (p.empty()) return p; return PSXRecompV4::host_resolve(exe_dir_from_argv(argv0), p); } 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{} : anchor_on_exe_dir(argv0, 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); // Relative inside the game folder so a moved portable folder still works; // read_cached_path anchors relative paths on the exe directory. if (f.is_open()) f << PSXRecompV4::host_path_forward_slashes( PSXRecompV4::relative_to_folder(path, exe_dir_from_argv(argv0))) << "\n"; } /* Nobody is at the screen: never block on a modal dialog or a file picker. */ static bool frontend_unattended() { return g_headless || g_hidden_window; } static void launcher_warning(const char* title, const std::string& msg) { std::fprintf(stderr, "%s: %s\n", title, msg.c_str()); // Unattended (--headless / PSX_HEADLESS / --hidden-window): NEVER pop a blocking modal — it would // hang an unattended/CI/scripted run forever waiting for a click. // SDL_ShowSimpleMessageBox is cross-platform; the Win32-only MessageBoxA left // macOS and Linux users with a silent exit (v0.4.0 macOS report, 2026-09-15). if (!frontend_unattended()) SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_WARNING, title, msg.c_str(), NULL); } static void launcher_info(const char* title, const std::string& msg) { std::fprintf(stderr, "%s: %s\n", title, msg.c_str()); if (!frontend_unattended()) SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_INFORMATION, title, msg.c_str(), NULL); } /* 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, const char* cli_flag) { // Headless: never open an interactive file dialog (it blocks). Fail the // resolve so boot aborts cleanly with the stderr message the caller printed. // Also when unattended in a hidden window (--hidden-window). if (frontend_unattended()) { std::fprintf(stderr, "psxrecomp: headless — cannot prompt for '%s'.\n" " Supply it on the command line: %s (or set it in game.toml).\n", title, cli_flag); 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; // No native GUI file picker on this platform (macOS/Linux): the file must be // named on the command line. Tell the user the exact flag + a full example // instead of a dead-end "requires a command-line path". std::fprintf(stderr, "psxrecomp: no graphical file picker on this platform — '%s'\n" " must be supplied on the command line: %s \n" " Example: ./ --game game.toml " "--bios /path/to/SCPH1001.BIN --disc /path/to/game.cue\n" " (or set the path in game.toml). See the game's README, \"Building From Source\".\n", title, cli_flag); return false; #endif } /* Per-disc expected serials for a multi-disc set, keyed by the disc image's * UPPERCASED file-name stem (game.toml [game] disc_serials, in the order of * [game] discs). Every disc of a set carries its own serial, so checking disc * 2 against the BOOT disc's serial reports the player's correct disc as the * wrong game -- which is what the launcher's disc dropdown would trip on * every time, and what the launch-time check warned about even when the * player picked exactly the right image. A disc absent from this map is not * serial-gated at all; the ISO-header check still applies. Keyed by stem * rather than full path because a .cue and the .bin it owns are one disc and * either may be what the player picked. Populated from game.toml; empty for * every single-disc title, which therefore behaves exactly as before. */ static std::unordered_map g_disc_serials; /* Per-disc netplay TOC fingerprints, same keying and same reason as * g_disc_serials: every disc of a set has its own TOC, so a set gated on the * BOOT disc's fingerprint refuses online play on every other disc the player * can now select. Populated from game.toml [netplay] required_disc_fps; empty * for every single-disc title and every port that predates the key, which * therefore keep the flat [netplay] required_disc_fp behaviour exactly. */ static std::unordered_map g_disc_netplay_fps; static std::string uppercase_ascii(std::string s); /* The serial THIS image is expected to carry: the set's per-disc value when * the game declared one, the game's own id otherwise. An image that belongs * to a declared set but has no serial listed is returned ungated (""), never * gated against another disc's number. */ static std::string expected_serial_for_disc(const std::filesystem::path& disc, const std::string& fallback) { if (g_disc_serials.empty()) return fallback; const auto it = g_disc_serials.find(uppercase_ascii(disc.stem().string())); return it != g_disc_serials.end() ? it->second : std::string(); } 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(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(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. /* A multi-disc set is checked against the SELECTED disc's serial, not the * boot disc's -- see expected_serial_for_disc(). */ const std::string expect = expected_serial_for_disc(selected_path, game_id); const PSXRecompV4::DiscIdentity id = PSXRecompV4::identify_disc(selected_path, expect, /*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 = expect.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(expect) + " 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."); } const auto resolved = PSXRecompV4::resolve_disc_path(path); const auto companion = PSXRecompV4::check_sbi_setup(resolved.data, resolved.mount); if (!companion.ready) { launcher_warning("SBI file required", companion.message); return false; } return true; } static std::filesystem::path normalize_disc_path_for_launch(const std::filesystem::path& path) { // Keep the resolver's mount path so a usable CUE retains its track map. return PSXRecompV4::resolve_disc_path(path).mount; } static std::filesystem::path resolve_persisted_disc_path( const std::filesystem::path& path, const std::filesystem::path& exe_dir) { if (path.empty()) return {}; return normalize_disc_path_for_launch(PSXRecompV4::host_resolve(exe_dir, path)); } /* Which image of a MULTI-DISC set to mount, given the roster this build was * made from (game.toml [game] discs), the player's persisted [disc] selected * index, and the [disc] path the launcher last wrote. * * The index names the disc; the path only survives when it IS that disc. * That ordering is the whole point of storing the index: an external launcher * (or a hand edit) changes discs by writing one integer, and it takes effect * even though settings.toml still carries the previous disc's path. A player * who browsed for their own copy of the selected disc is still honoured, * because a relocated or container-swapped image keeps its stem -- a Redump * dump names the disc in the file name and only the extension moves * (".. (Disc 2).cue" and ".. (Disc 2).bin" identify the same disc). * normalize_disc_path_for_launch preserves the resolver's mount path. * * Single-disc titles (roster of 0 or 1) are returned unchanged: the persisted * path wins, exactly as it did before any of this existed. */ /* Roster position of `disc`, or -1. Compare stems so the roster can identify * equivalent CUE and raw-image selections after disc-path resolution. */ static int roster_index_for_disc( const std::vector& roster, const std::filesystem::path& disc) { if (disc.empty()) return -1; const std::string want = uppercase_ascii(disc.stem().string()); for (size_t i = 0; i < roster.size(); ++i) if (uppercase_ascii(roster[i].stem().string()) == want) return (int)i; return -1; } static std::filesystem::path resolve_selected_disc( const std::vector& roster, int selected_1based, const std::filesystem::path& persisted) { if (roster.size() < 2) return persisted; const int idx = selected_1based - 1; if (idx < 0 || idx >= (int)roster.size()) return persisted; if (!persisted.empty() && uppercase_ascii(persisted.stem().string()) == uppercase_ascii(roster[(size_t)idx].stem().string())) return persisted; return roster[(size_t)idx]; } /* BIOS selection state (docs/BIOS_SELECTION.md). s_openbios_allowed is the * game's [runtime] openbios; s_bundled_bios_rel is where the shipped * redistributable image lives relative to the executable. */ static bool s_openbios_allowed = true; static std::string s_bundled_bios_rel = PSX_BUNDLED_BIOS_PATH; /* Identity-match a file against a linked backend. Size+CRC must agree — * bytes is a guaranteed wild jump. Identity therefore decides WHICH backend * may run, not merely whether to warn. Null = no linked image matches. */ static const PsxBiosBackend* bios_backend_for_file(const std::filesystem::path& path, uint32_t* out_crc, uint64_t* out_size) { std::ifstream f(path, std::ios::binary | std::ios::ate); if (!f.is_open()) return nullptr; const std::streamoff size = f.tellg(); if (out_size) *out_size = (uint64_t)size; if (size <= 0) return nullptr; std::vector data((size_t)size); if (!read_at(f, 0, data.data(), data.size())) return nullptr; const uint32_t crc = crc32_compute(data.data(), data.size()); if (out_crc) *out_crc = crc; for (uint32_t i = 0; i < psx_bios_registry_count; i++) { const PsxBiosBackend* b = psx_bios_registry[i]; if (!b || !b->image) continue; if ((uint64_t)size == (uint64_t)b->image->image_size && crc == b->image->image_crc32) return b; } return nullptr; } /* What a player may supply, for mismatch and picker copy. The bundled image is * excluded: it is never something to go and find. */ /* Directory of the running executable, for the BIOS module cache and * toolchain (psx_bios_module.h). Set by resolve_bios_for_runtime before any * selection; empty means "unknown", which disables module building. */ static std::string s_bios_exe_dir; static bool bios_module_supported_here() { return !s_bios_exe_dir.empty() && psx_bios_module_supported(s_bios_exe_dir.c_str()) != 0; } static std::string bios_accepted_images() { std::string s; for (uint32_t i = 0; i < psx_bios_registry_count; i++) { const PsxBiosBackend* b = psx_bios_registry[i]; if (!b || !b->image || b->image->image_bundled) continue; if (!s.empty()) s += ", "; s += b->image->image_id; s += " (" + std::to_string(b->image->image_size / 1024u) + " KB)"; } /* A bundled build can also BUILD a backend for any image it ships a * profile for (psx_bios_module.h), so those count as accepted too. */ if (bios_module_supported_here()) { for (const PsxKnownBiosImage& k : psx_known_bios_images) { if (s.find(k.id) != std::string::npos) continue; if (!s.empty()) s += ", "; s += k.id; s += " (" + std::to_string(k.size / 1024u) + " KB, built on first use)"; } } return s.empty() ? std::string("(this build ships its own BIOS)") : s; } /* Identity-gate a player-chosen BIOS and activate its backend on success. */ static bool validate_bios_for_launch(const std::filesystem::path& path) { uint32_t crc = 0; uint64_t size = 0; const PsxBiosBackend* b = bios_backend_for_file(path, &crc, &size); if (b) return psx_bios_activate(b) != 0; /* Not linked, but this build can build it from the player's dump * (docs/BIOS_SELECTION.md, "player-side backend build"). A cached * module loads silently; a first build is announced, since it can take * a minute with gcc, and its failure explained before falling through * to the ordinary mismatch/fallback path. */ if (bios_module_supported_here() && psx_bios_module_known_id(crc, (uint32_t)size)) { const std::string dump = path.string(); char err[256] = {0}; if (!psx_bios_module_is_cached(dump.c_str(), s_bios_exe_dir.c_str())) { launcher_info("Preparing your BIOS", std::string(psx_bios_module_known_id(crc, (uint32_t)size)) + " is not part of this build, so it will be compiled from your " "own image now. This happens once and can take a minute; the " "game starts when it finishes."); } const PsxBiosBackend* m = psx_bios_module_acquire( dump.c_str(), s_bios_exe_dir.c_str(), /*allow_build=*/1, err, sizeof(err)); if (m) return psx_bios_activate(m) != 0; launcher_warning("BIOS Not Prepared", std::string("Could not build a backend for this BIOS: ") + err + "\n\nSee the log beside the executable. Clear the BIOS field to " "play on the bundled OpenBIOS."); return false; } char buf[512]; std::snprintf(buf, sizeof(buf), "That BIOS (%llu bytes, CRC32 %08X) is not an image this build was " "compiled from.\n\nThis build accepts: %s\n\nThe compiled-in code " "would execute against mismatched data and crash.", (unsigned long long)size, crc, bios_accepted_images().c_str()); launcher_warning("BIOS Mismatch", buf); return false; } static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0); /* Resolve which BIOS image file to load, and activate its backend. * * Policy (docs/BIOS_SELECTION.md): * no explicit player choice -> bundled OpenBIOS, silently * explicit choice, matching -> that image * explicit choice, mismatched-> explained; falls back to OpenBIOS if allowed * openbios disabled for this title -> a retail image is required * * "Explicit" means --bios or a remembered launcher/settings/`bios.cfg` pick. * First-run setup may seed bios.cfg from a validated SCPH1001 beside the * install; after that, Play never invents a BIOS from random on-disk files. */ static std::filesystem::path resolve_bios_for_runtime(const char* requested, const char* argv0, bool requested_is_explicit) { const bool openbios_allowed = s_openbios_allowed; const PsxBiosBackend* bundled = psx_bios_bundled(); s_bios_exe_dir = exe_dir_from_argv(argv0).string(); const bool player_bios_selectable = psx_bios_has_selectable() != 0 || bios_module_supported_here(); const bool bundled_only = openbios_allowed && bundled && !player_bios_selectable; /* 0. Setup host (CI zip root): no BIOS backends are linked yet, so there * is nothing an image could be validated against — bios_backend_for_file() * rejects every file, including the correct one, and a title with * openbios = false has no fallback to land on. This MUST precede the * explicit-choice branch below: a remembered bios.cfg pick reached * validate_bios_for_launch() first and deadlocked first-run setup — the * player was told their good SCPH-1001 was "not an image this build was * compiled from", and could never supply the BIOS that Generate needs in * order to emit the backend. Play belongs to the product binary under * build-release/ after Generate & rebuild. */ if (psx_bios_registry_count == 0) { launcher_warning("Setup host — finish Generate & rebuild", "This executable is the first-run setup host (no game/BIOS code " "linked).\n\n" "Use Generate & rebuild in the launcher. After that succeeds, open " "this same shortcut again — it starts the game from build-release/ " "(where bios/, mods/, and settings live).\n\n" "Or run build-release/.exe directly."); return {}; } /* 1. An explicit choice: --bios, else a remembered pick. A product build * with only its bundled backend has no meaningful player choice: ignore * stale settings/bios.cfg paths instead of validating an image the hidden * launcher row cannot clear. Setup hosts (registry_count == 0) already * returned above. */ std::filesystem::path chosen; if (!bundled_only) { if (requested_is_explicit && requested && requested[0]) { chosen = resolve_bios_path(requested, argv0); } else { std::filesystem::path cached = read_cached_path(argv0, "bios.cfg"); if (!cached.empty() && std::filesystem::exists(cached)) chosen = cached; } } if (!chosen.empty() && std::filesystem::exists(chosen)) { if (validate_bios_for_launch(chosen)) return chosen; /* activates it */ if (!(openbios_allowed && bundled)) return {}; } /* 2. No usable explicit choice -> bundled OpenBIOS, if this title allows it. */ if (openbios_allowed && bundled) { std::filesystem::path img = resolve_bios_path( s_bundled_bios_rel.empty() ? nullptr : s_bundled_bios_rel.c_str(), argv0); if (!img.empty() && std::filesystem::exists(img) && bios_backend_for_file(img, nullptr, nullptr) == bundled && psx_bios_activate(bundled)) { return img; } launcher_warning("Bundled BIOS Missing", std::string("This build ships its own BIOS (") + bundled->image->image_id + "), but the bundled image is missing " "or does not match.\n\nExpected next to the executable:\n" + (s_bundled_bios_rel.empty() ? "(default path)" : s_bundled_bios_rel) + "\n\nReinstall or rebuild."); return {}; } /* 3. This title requires a retail BIOS: ask for one. */ const std::string accepted = bios_accepted_images(); launcher_info((s_picker_game_name + " — PlayStation BIOS needed").c_str(), s_picker_game_name + " requires a PlayStation BIOS.\n\n" "Step 1 of 2 — PlayStation BIOS\n\n" "In the next window, select your PlayStation BIOS dump. This build " "requires the exact image it was compiled from: " + accepted + ". " "Usually named " + std::string(psx_expected_bios_label()) + " and exactly 512 KB. Dump 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 (" + accepted + ")"; for (;;) { std::filesystem::path picked; if (!pick_runtime_file( bios_title.c_str(), "PlayStation BIOS (*.bin)\0*.bin\0All Files (*.*)\0*.*\0", picked, "--bios")) { 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 = resolve_persisted_disc_path(cached, exe_dir_from_argv(argv0)); } if (!cached.empty() && std::filesystem::exists(cached) && validate_disc_for_launch(cached, game_id)) { return cached; } // Go straight to the native picker. A preliminary modal was redundant, // visually inconsistent with the picker, and was easy to encounter on // ordinary direct launches with no remembered disc path. std::string disc_title = "Select " + s_picker_game_name + " disc image (.cue / .bin / .img / .iso / .car / .chd)"; for (;;) { std::filesystem::path picked; if (!pick_runtime_file( disc_title.c_str(), "PS1 Disc Images (*.cue;*.bin;*.img;*.iso;*.car;*.chd)\0*.cue;*.bin;*.img;*.iso;*.car;*.chd\0All Files (*.*)\0*.*\0", picked, "--disc")) { 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; if (!requested || !requested[0]) return {}; fs::path p(requested); if (fs::exists(p, ec)) { fs::path abs = PSXRecompV4::host_absolute(p, ec); return ec ? p : abs; } // Either BIOS filename convention is acceptable: a dump folder holding // "US-PSX-SCPH1001.BIN" satisfies a request for "SCPH1001.BIN" and vice // versa (see recompiler/include/bios_rom_alias.h). if (fs::path aliased = PSXRecompV4::resolve_bios_rom(p); aliased != p) { fs::path abs = PSXRecompV4::host_absolute(aliased, ec); return ec ? aliased : abs; } if (PSXRecompV4::host_path_is_absolute(p)) 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; // Same walk, accepting the other naming convention at each rung: the // literal name is absent but a region-qualified sibling may be present. for (fs::path dir = PSXRecompV4::host_absolute(exe_dir_from_argv(argv0), ec); !dir.empty(); dir = dir.parent_path()) { const fs::path aliased = PSXRecompV4::resolve_bios_rom(dir / p); if (aliased != dir / p && fs::exists(aliased, ec)) return aliased; if (!dir.has_parent_path() || dir.parent_path() == dir) break; } // Dev-checkout rung: game projects keep the framework at // /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; } /* First-run / setup discovery of a retail BIOS the player already dumped next * to the game (docs/BIOS_SELECTION.md). Only size+CRC identity counts — wrong * dumps are skipped silently. Empty result → keep OpenBIOS (no prompt). */ static bool retail_bios_file_ok(const std::filesystem::path& path) { std::error_code ec; if (!std::filesystem::is_regular_file(path, ec)) return false; if (psx_bios_registry_count > 0) { uint32_t crc = 0; uint64_t size = 0; const PsxBiosBackend* b = bios_backend_for_file(path, &crc, &size); if (b) return b->image && !b->image->image_bundled; /* Bundled build: a dump this build can compile a backend from. */ return bios_module_supported_here() && psx_bios_module_known_id(crc, (uint32_t)size) != nullptr; } /* Setup host (no backends linked yet): accept the retail image THIS build * pins, from psx_bios_known_images.h. This used to hardcode SCPH-1001, so * a kit pinning anything else refused to seed from a correct dump. An * unknown pinned stem seeds nothing and the player is asked instead. */ const PsxKnownBiosImage* want = psx_expected_bios(); if (!want) return false; std::ifstream f(path, std::ios::binary | std::ios::ate); if (!f.is_open()) return false; const auto size = static_cast(f.tellg()); if (size != static_cast(want->size)) return false; std::vector data(static_cast(size)); if (!read_at(f, 0, data.data(), data.size())) return false; return crc32_compute(data.data(), data.size()) == want->crc32; } static std::filesystem::path discover_retail_bios_near(const char* argv0) { namespace fs = std::filesystem; char name_buf[8][32]; const int name_count = psx_known_bios_filenames(psx_expected_bios(), name_buf, 8); if (name_count <= 0) return {}; static const char* kSubdirs[] = { "bios", "", "system", "firmware", "psxrecomp/bios", "psxrecomp-v4/bios", }; const fs::path exe_dir = exe_dir_from_argv(argv0); std::error_code ec; for (fs::path root = exe_dir; !root.empty(); root = root.parent_path()) { for (const char* sub : kSubdirs) { const fs::path dir = (sub && sub[0]) ? (root / sub) : root; for (int ni = 0; ni < name_count; ++ni) { const fs::path cand = dir / name_buf[ni]; if (retail_bios_file_ok(cand)) { auto abs = fs::weakly_canonical(cand, ec); if (ec) abs = PSXRecompV4::host_absolute(cand, ec); return abs; } } } if (!root.has_parent_path() || root == root.root_path()) break; } return {}; } /* Match-only BIOS from lobby `session_bios`. Never writes bios.cfg / settings. * Returns true when session_bios is a known settle token. * *out_path empty ⇒ OpenBIOS for this match; otherwise a validated retail dump. * A retail match boots only the image named by retail_crc, so every * peer runs the same BIOS; retail_crc 0 (an older host) accepts any retail * dump, as before. *out_image_id, when given, names the image found. */ static bool resolve_match_session_bios_path( const char* session_bios, uint32_t retail_crc, const std::filesystem::path& preferred_hint, const char* launcher_bios_path, const char* argv0, std::filesystem::path* out_path, const char** out_image_id = nullptr) { if (out_image_id) *out_image_id = nullptr; if (!out_path || !session_bios || !session_bios[0]) return false; if (std::strcmp(session_bios, "openbios") == 0) { out_path->clear(); return true; } if (std::strcmp(session_bios, "scph1001") != 0) return false; std::filesystem::path retail; std::error_code ec; auto try_retail = [&](const std::filesystem::path& p) { if (p.empty() || !std::filesystem::exists(p, ec)) return; const PsxBiosBackend* b = bios_backend_for_file(p, nullptr, nullptr); if (b && b->image && !b->image->image_bundled && (!retail_crc || b->image->image_crc32 == retail_crc)) { retail = p; if (out_image_id) *out_image_id = b->image->image_id; } }; try_retail(preferred_hint); if (retail.empty() && launcher_bios_path && launcher_bios_path[0]) try_retail(resolve_bios_path(launcher_bios_path, argv0)); if (retail.empty()) try_retail(read_cached_path(argv0, "bios.cfg")); if (retail.empty()) try_retail(discover_retail_bios_near(argv0)); *out_path = std::move(retail); /* empty ⇒ caller falls back to OpenBIOS */ return true; } // 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; static void apply_netplay_local_viewport_aspect(bool netplay_enabled) { if (!netplay_enabled || g_netplay_local_viewport != 1 || g_netplay_local_viewport_aspect == 0) { gpu_ws_set_netplay_local_viewport(0, 0); return; } gpu_ws_set_netplay_local_viewport(1, psx_netplay_local_port()); switch (g_netplay_local_viewport_aspect) { case 1: (void)psx_mod_set_fixed_display_aspect(16u, 9u); break; case 2: (void)psx_mod_set_fixed_display_aspect(21u, 9u); break; case 3: (void)psx_mod_set_fixed_display_aspect(16u, 9u); (void)psx_mod_set_adaptive_display_aspect(21u, 9u); break; case 4: // The admitted mod plan selects a fixed ratio before play. Window // resizing cannot change it; other offline choices default to 16:9. (void)psx_mod_set_fixed_display_aspect( g_video_aspect_num * 9 == g_video_aspect_den * 21 ? 21u : 16u, 9u); break; default: break; } } /* 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 int host_refresh_matches_guest_cadence(void) { if (g_host_refresh_hz <= 0.0 || g_guest_frame_period_ms <= 0.0) return 0; const double guest_hz = 1000.0 / g_guest_frame_period_ms; return std::fabs(g_host_refresh_hz - guest_hz) <= guest_hz * 0.02; } static void refresh_host_display_cadence(int force_log, int force_probe) { #ifndef PSX_SDL_NO_RENDER if (!sdl_window) return; const uint64_t now_ms = SDL_GetTicks64(); const int disp_idx = SDL_GetWindowDisplayIndex(sdl_window); if (!force_probe && disp_idx == g_host_refresh_display_idx && g_host_refresh_last_probe_ms != 0 && now_ms >= g_host_refresh_last_probe_ms && now_ms - g_host_refresh_last_probe_ms < 1000ull) { return; } g_host_refresh_last_probe_ms = now_ms ? now_ms : 1ull; double host_hz = 0.0; if (disp_idx >= 0) { SDL_DisplayMode dm; if (SDL_GetCurrentDisplayMode(disp_idx, &dm) == 0 && dm.refresh_rate > 0) { host_hz = (double)dm.refresh_rate; } } const int display_changed = (disp_idx != g_host_refresh_display_idx); const int refresh_changed = std::fabs(host_hz - g_host_refresh_hz) > 0.05; if (!force_log && !display_changed && !refresh_changed) return; g_host_refresh_display_idx = disp_idx; g_host_refresh_hz = host_hz; g_frame_period_ms = g_guest_frame_period_ms; if (host_refresh_matches_guest_cadence()) { g_frame_period_ms = 1000.0 / host_hz; std::printf("psxrecomp: sync-to-host-refresh: pacing to %.1f Hz panel " "(%.4f ms/frame)\n", host_hz, g_frame_period_ms); } else if (host_hz > 0.0) { std::printf("psxrecomp: host panel %.1f Hz does not match guest " "cadence; keeping %.2f Hz pacing\n", host_hz, g_frame_period_ms > 0.0 ? 1000.0 / g_frame_period_ms : 0.0); } else { std::printf("psxrecomp: host refresh unknown; keeping %.2f Hz pacing\n", g_frame_period_ms > 0.0 ? 1000.0 / g_frame_period_ms : 0.0); } apply_present_cadence(); #else (void)force_log; (void)force_probe; #endif } /* Host pacing follows the guest's live video standard (GP1(08h) bit 3, * psx_video_timing.h). A PAL disc that switches to NTSC, or the reverse, * changes the guest VBlank rate; the wall-clock cadence, the host-refresh * vsync match and the frame-blend source rate must change with it or the * game runs at the wrong speed. A mod that owns native VBlank pacing keeps * its own rate. Called once per guest VBlank; acts only on a change. */ static uint32_t g_video_timing_generation_seen = 0; static double guest_video_standard_hz(void) { return psx_video_timing_is_pal() ? 50.0 : 1000.0 / PSX_FRAME_PERIOD_MS; } static void sync_guest_cadence_to_video_standard(void) { const uint32_t gen = psx_video_timing_generation(); if (gen == g_video_timing_generation_seen) return; g_video_timing_generation_seen = gen; if (g_mod_native_vblank_rate) return; const double period_ms = psx_video_timing_is_pal() ? 1000.0 / 50.0 : PSX_FRAME_PERIOD_MS; if (period_ms == g_guest_frame_period_ms) return; g_guest_frame_period_ms = period_ms; g_frame_period_ms = period_ms; std::printf("psxrecomp: guest video standard %s: pacing %.2f Hz\n", psx_video_timing_is_pal() ? "PAL" : "NTSC", 1000.0 / period_ms); #ifndef PSX_SDL_NO_RENDER if (sdl_window) refresh_host_display_cadence(1, 0); if (g_frame_interpolation && g_gl_active) gl_renderer_set_interpolation(g_frame_interpolation, g_host_refresh_hz, (double)g_frame_interpolation_fps, 1000.0 / g_frame_period_ms, g_frame_interpolation_blend); #endif } static int host_driver_vsync_unreliable(void) { #ifdef _WIN32 return 0; #else static int cached = -1; if (cached >= 0) return cached; if (const char *e = std::getenv("PSX_TRUST_DRIVER_VSYNC"); e && e[0] && e[0] != '0') { cached = 0; return cached; } if (std::getenv("WSL_INTEROP") || std::getenv("WSL_DISTRO_NAME")) { cached = 1; return cached; } cached = 0; if (FILE *f = std::fopen("/proc/version", "rb")) { char buf[512]; size_t n = std::fread(buf, 1, sizeof(buf) - 1, f); std::fclose(f); buf[n] = '\0'; if (std::strstr(buf, "Microsoft") || std::strstr(buf, "microsoft") || std::strstr(buf, "WSL") || std::strstr(buf, "wsl")) cached = 1; } return cached; #endif } static int present_vsync_owns_cadence(void) { if (g_video_vsync == 0 || g_present_vsync_disabled) return 0; if (host_driver_vsync_unreliable()) return 0; if (g_frame_period_ms <= 0.0) return 0; if (g_frame_interpolation) return 0; if (g_netplay_vsync_forced_off || psx_netplay_active()) return 0; return host_refresh_matches_guest_cadence(); } /* C accessor for the renderers (frame_pacing.h). The GL present-skip * optimisation must consult this before eliding a swap. */ extern "C" int psx_present_vsync_owns_cadence(void) { return present_vsync_owns_cadence(); } static int present_effective_swap_interval(void) { if (g_netplay_vsync_forced_off || psx_netplay_active()) return 0; if (g_frame_interpolation) return 0; if (g_frame_period_ms <= 0.0) return 0; if (present_vsync_owns_cadence()) return g_video_vsync; return 0; } static int present_should_wall_pace(void) { /* The single-context temporal-blend path subdivides this same stock frame * interval. Do not run the outer pacer as well or guest timing doubles. */ if (g_frame_interpolation && g_gl_active && gl_renderer_interpolation_owns_cadence()) return 0; /* Run the pacer even when driver vsync is SUPPOSED to own the cadence. * * Skipping it here trusts the swap to block, and a swap that does not * block leaves the guest with NO speed limit at all -- the game simply * free-runs. Measured on NVIDIA GL under a compositing WM: 45 s of * gameplay produced 4771 guest frames (~106 fps, 1.77x) with vsync * "owning" cadence, against 2651 (~58.9 fps, 1.00x) with PSX_VSYNC=0 * forcing this pacer on. Nothing was held; it looked exactly like a stuck * fast-forward. * * This is safe to run alongside a vsync that DOES block, because * frame_pacer_wait() is deadline-based rather than a fixed sleep: if the * swap already consumed the period, `now >= next_deadline` and it returns * immediately after advancing the deadline. So it costs nothing where * vsync works and supplies the cap where it does not, which also means it * cannot add latency to the healthy path. */ return g_frame_period_ms > 0.0; } static void apply_present_cadence(void) { #ifndef PSX_SDL_NO_RENDER const int interval = present_effective_swap_interval(); if (g_gl_active) gl_renderer_set_swap_interval(interval); if (g_vk_active) vk_renderer_set_present_mode(interval); if (sdl_renderer) (void)SDL_RenderSetVSync(sdl_renderer, interval != 0 ? 1 : 0); latency_ring_set_present_mode(interval); #endif } static void log_present_cadence(void) { if (present_vsync_owns_cadence()) { /* The pacer still runs underneath as a deadline cap (see * present_should_wall_pace) -- a no-op whenever the swap actually * blocks, and the only speed limit when it does not. Say so: this * line is how a bring-up session decides whether pacing is accounted * for, and "skipped" sent this one hunting a phantom stuck * fast-forward instead of an unpaced present. */ std::printf("psxrecomp: present cadence: driver vsync (%.1f Hz panel; " "wall-clock cap %.4f ms/frame)\n", g_host_refresh_hz, g_frame_period_ms); } else if (g_frame_period_ms > 0.0) { if (g_video_vsync != 0 && !g_frame_interpolation && !g_netplay_vsync_forced_off) { if (host_driver_vsync_unreliable()) { std::printf("psxrecomp: present cadence: wall-clock pacer " "(%.4f ms/frame); driver vsync off under WSL\n", g_frame_period_ms); } else if (g_host_refresh_hz > 0.0) { std::printf("psxrecomp: present cadence: wall-clock pacer " "(%.4f ms/frame); driver vsync off on %.0f Hz panel\n", g_frame_period_ms, g_host_refresh_hz); } else { std::printf("psxrecomp: present cadence: wall-clock pacer " "(%.4f ms/frame); driver vsync off " "(host refresh unknown)\n", g_frame_period_ms); } } else { std::printf("psxrecomp: present cadence: wall-clock pacer " "(%.4f ms/frame)\n", g_frame_period_ms); } } else { std::printf("psxrecomp: present cadence: uncapped " "(no pacer, no vsync)\n"); } } static void netplay_host_present_uncap(void) { g_netplay_vsync_forced_off = 1; apply_present_cadence(); } static void netplay_host_present_restore(void) { if (!g_netplay_vsync_forced_off) return; g_netplay_vsync_forced_off = 0; apply_present_cadence(); } /* Why the last match ended, for the launcher's status line once the lobby is * back. Set by netplay_soft_exit; NULL when the player ended it. */ static const char *g_netplay_exit_reason_text = nullptr; static void netplay_soft_exit(const char *origin) { /* A peer that gave up on a boot-digest mismatch sends BYE, which reaches * this side as a disconnect. This side saw the same mismatch: say that, * not "the other player left". Read it before shutdown clears the latch. */ if (origin && std::strcmp(origin, "netplay_peer_disconnect") == 0 && psx_netplay_rb_boot_dig0_mismatch_since_ms() != 0u) origin = "netplay_boot_mismatch"; g_netplay_exit_reason_text = netplay_exit_reason_text(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; } if (g_netplay_exit_reason_text) std::fprintf(stderr, "psxrecomp: %s\n", g_netplay_exit_reason_text); shutdown_runtime(); std::exit(0); } /* "address:port" as the netplay library reads it: the port follows * the last colon and must be a number up to 65535, an empty address means * every address of this computer, and a name is looked up (IPv4). Returns 0 * and the address, or -1 for a text the library refuses too. */ static int netplay_hostport_addr(const char* hostport, sockaddr_in* out) { const char* colon = hostport ? std::strrchr(hostport, ':') : nullptr; char host[128]; if (!colon || (size_t)(colon - hostport) >= sizeof(host) || colon[1] == '\0') return -1; std::memcpy(host, hostport, (size_t)(colon - hostport)); host[colon - hostport] = '\0'; char* end = nullptr; const unsigned long port = std::strtoul(colon + 1, &end, 10); if (*end != '\0' || port > 65535ul) return -1; *out = sockaddr_in{}; out->sin_family = AF_INET; out->sin_port = htons((uint16_t)port); if (!host[0] || std::strcmp(host, "0.0.0.0") == 0) { out->sin_addr.s_addr = htonl(INADDR_ANY); return 0; } if (inet_pton(AF_INET, host, &out->sin_addr) == 1) return 0; addrinfo hints{}; hints.ai_family = AF_INET; hints.ai_socktype = SOCK_DGRAM; hints.ai_protocol = IPPROTO_UDP; addrinfo* found = nullptr; if (getaddrinfo(host, nullptr, &hints, &found) != 0) return -1; int rc = -1; for (const addrinfo* r = found; r; r = r->ai_next) { if (r->ai_family == AF_INET && r->ai_addr && (size_t)r->ai_addrlen >= sizeof(*out)) { out->sin_addr = ((const sockaddr_in*)r->ai_addr)->sin_addr; rc = 0; break; } } if (found) freeaddrinfo(found); return rc; } /* Why a LAN netplay start failed. The netplay library answers a * refused bind, a listen address it cannot read and a peer address it cannot * use with the same -1 and no error code, so do its steps again here and keep * the system's answer: the error number and its text. The listen address is * read and a name looked up as the library does, then bound. No SO_REUSEADDR, * so a port another program holds is reported; the library's own socket is * closed by now. Only when the listen address could be opened is the peer * address read, so the peer is named as the cause only when it is one. * `tried` receives the address that was bound, as numbers. */ static int netplay_bind_probe(const char* bind_hostport, const char* peer_hostport, char* tried, size_t tried_cap, int* sys_error, char* sys_text, size_t sys_cap) { if (sys_error) *sys_error = 0; if (sys_text && sys_cap) sys_text[0] = '\0'; if (tried && tried_cap) tried[0] = '\0'; #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); #endif sockaddr_in addr{}; if (netplay_hostport_addr(bind_hostport, &addr) != 0) return NETPLAY_BIND_BAD_ADDRESS; if (tried && tried_cap) { char ip[INET_ADDRSTRLEN] = ""; inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof(ip)); std::snprintf(tried, tried_cap, "%s:%u", ip, (unsigned)ntohs(addr.sin_port)); } int err = 0; int result = NETPLAY_BIND_OK; #ifdef _WIN32 const SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (s == INVALID_SOCKET) { err = WSAGetLastError(); result = NETPLAY_BIND_NO_SOCKET; } else { if (bind(s, (const sockaddr*)&addr, sizeof(addr)) != 0) { err = WSAGetLastError(); result = NETPLAY_BIND_FAILED; } closesocket(s); } if (err && sys_text && sys_cap) { FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, (DWORD)err, 0, sys_text, (DWORD)sys_cap, nullptr); for (size_t n = std::strlen(sys_text); n && (sys_text[n - 1] == '\n' || sys_text[n - 1] == '\r' || sys_text[n - 1] == ' ' || sys_text[n - 1] == '.'); --n) sys_text[n - 1] = '\0'; } #else const int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (s < 0) { err = errno; result = NETPLAY_BIND_NO_SOCKET; } else { if (bind(s, (const sockaddr*)&addr, sizeof(addr)) != 0) { err = errno; result = NETPLAY_BIND_FAILED; } close(s); } if (err && sys_text && sys_cap) std::snprintf(sys_text, sys_cap, "%s", std::strerror(err)); #endif if (sys_error) *sys_error = err; if (result != NETPLAY_BIND_OK) return result; /* The library refuses a peer address it cannot read, one with port 0 and * a name it cannot look up. */ if (peer_hostport && peer_hostport[0]) { sockaddr_in peer{}; if (netplay_hostport_addr(peer_hostport, &peer) != 0 || peer.sin_port == 0) return NETPLAY_BIND_PEER_BAD; } return NETPLAY_BIND_OK; } /* A netplay start failed: print what failed and return the sentence for the * player. The old line named the build first ("built without * recomp-net, or bind/peer invalid") for every failure; a player whose system * held the port read that the build had no netplay. For a LAN start the * library's steps are done again here and the system's answer is reported. */ static const char* netplay_start_failure(int nrc, const PsxNetplayConfig& cfg) { #if defined(PSX_HAS_RECOMP_NET) const int netplay_built = 1; #else const int netplay_built = 0; #endif static char why[NETPLAY_START_FAILURE_CAP]; char sys_text[160] = ""; char tried[32] = ""; int sys_error = 0; const int bind_probe = (netplay_built && nrc == -3) ? netplay_bind_probe(cfg.bind_hostport, cfg.peer_hostport, tried, sizeof(tried), &sys_error, sys_text, sizeof(sys_text)) : NETPLAY_BIND_NOT_TRIED; /* The sentence names the address that was bound: a name is shown as the * address it stands for. */ netplay_start_failure_text(nrc, netplay_built, tried[0] ? tried : cfg.bind_hostport, cfg.peer_hostport, bind_probe, sys_error, sys_text, why, sizeof(why)); std::fprintf(stderr, "psxrecomp: netplay start failed (%d) — %s%s%s%s (slot=%d bind=%s peer=%s)\n", nrc, why, sys_text[0] ? " [system: " : "", sys_text, sys_text[0] ? "]" : "", cfg.local_slot, cfg.bind_hostport, cfg.peer_hostport); return why; } static void shutdown_runtime(void) { psx_local_mouse_clear(); /* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the * off-thread JIT worker here; the worker no longer exists.) */ psx_netplay_shutdown(); psx_rewind_shutdown(); memcard_flush_all(); /* Stop and join the active external compiler before capture/debug teardown. * Otherwise closing the window can leave cmd/python/gcc running against * cache and capture files while the main thread performs synchronous * shutdown work, making the window appear frozen until that tree exits. */ autocompile_shutdown(); overlay_autocapture_shutdown(); overlay_capture_wait_pending(); overlay_capture_write_json(); if (sdl_audio_device) { psx_sdl_audio_clear(sdl_audio_device); psx_sdl_audio_close(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(); psx_rewind_shutdown(); memcard_flush_all(); if (sdl_audio_device) { psx_sdl_audio_clear(sdl_audio_device); psx_sdl_audio_close(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; } sdl_texture_width = sdl_texture_height = 0; if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; } if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; } if (s_headless_gl_window) { SDL_DestroyWindow(s_headless_gl_window); s_headless_gl_window = nullptr; } if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; } sdl_pixel_buf_capacity = 0; /* Rematch must re-run force_sw (and prefer CPU-auth GL present again). */ s_netplay_sw_gpu_locked = 0; s_netplay_gl_present = 0; s_netplay_sim_native_scale = 0; gl_renderer_set_cpu_auth_dual(0); sw_set_faithful_authority(0); psx_lobby_set_ready(0); psx_lobby_clear_launch_pending(); #if defined(PSX_HAS_LOBBY_CLIENT) /* Drop prior-match ICE SDP/candidates; ignore new ones until next launch. */ psx_lobby_clear_signals(); psx_lobby_set_ice_signal_accept(0); #endif 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. * sdl_audio_fadein_left is declared with present_session_reset. */ static const int sdl_audio_fade_samples = 44100 * 40 / 1000; /* 40 ms */ static void sdl_audio_pump(bool discard_output = false) { /* Guest-cycle SPU advance must not depend on host audio device/backpressure. * Win↔Linux rollback forked on aux/spu when one peer skipped spu_render * (queue full / !drc / no device) while the other kept advancing. */ const uint32_t bytes_per_frame = sizeof(int16_t) * 2u; const bool legacy = audio_legacy_mode(); const int netplay = psx_netplay_active(); static int had_audio = 0; uint32_t queued = 0; /* RENDER event b: bytes (legacy) / fill ms (bridge) */ int host_queue_ok = 0; 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 (sdl_audio_device && 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 = psx_sdl_audio_queued_size(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); /* Still advance SPU below; only skip host enqueue. */ } else { host_queue_ok = 1; } } else if (sdl_audio_device && !legacy && s_drc_ready) { /* 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; host_queue_ok = 1; } /* 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 (g_audio_cycle_resync) { last_cycles = now_cycles; cycle_carry = 0; g_audio_cycle_resync = 0; if (legacy && sdl_audio_device) psx_sdl_audio_clear(sdl_audio_device); else g_audio_unmute_resync = 1; /* skip mute-drain underrun reports */ return; } 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 && !netplay) { /* Offline: a burst beyond one buffer (e.g. right after an unmute or a * long stall) renders one full buffer and DROPs the remainder of the * debt — mute-model freeze rather than time-compressing a backlog. * Netplay never drops: both peers must consume the same guest debt. */ frames = 2048; cycle_carry = 0; } audio_trace_event(AUDIO_EV_RENDER, (uint32_t)frames, queued); /* Catch up in ≤2048-frame chunks (sdl_audio_buf capacity). Only the last * chunk may be handed to the host queue — earlier chunks advance state * only (avoids dumping seconds of catch-up into the device ring). */ int remaining = frames; int host_frames = 0; while (remaining > 0) { const int chunk = remaining > 2048 ? 2048 : remaining; spu_render(sdl_audio_buf, chunk); remaining -= chunk; host_frames = chunk; if (discard_output) { g_turbo_audio_sink_frames += (uint64_t)chunk; audio_trace_event(AUDIO_EV_SINK_DROP, (uint32_t)chunk, 0); } } if (discard_output) return; if (!host_queue_ok || !sdl_audio_device) return; frames = host_frames; if (sdl_audio_fadein_left > 0) { const float g0 = 1.0f - (float)sdl_audio_fadein_left / (float)sdl_audio_fade_samples; int ramp = sdl_audio_fadein_left < frames ? sdl_audio_fadein_left : frames; const float g1 = 1.0f - (float)(sdl_audio_fadein_left - ramp) / (float)sdl_audio_fade_samples; sdl_audio_gain_ramp(sdl_audio_buf, ramp, g0, g1); sdl_audio_fadein_left -= ramp; } /* Host master volume (launcher / numpad +/-). Applied after fade so mute * edges stay continuous and volume steps take effect immediately. */ { const int vol = host_volume_get(); if (vol < 100) { const float g = (float)vol / 100.0f; sdl_audio_gain_ramp(sdl_audio_buf, frames, g, g); } } 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); psx_sdl_audio_queue(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. */ psx_sdl_audio_lock(sdl_audio_device); rab_push(&s_drc, sdl_audio_buf, frames); psx_sdl_audio_unlock(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, double *target_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)psx_sdl_audio_queued_size(sdl_audio_device) / (44100.0 * 4.0) * 1000.0 : 0.0; *target_ms = 0.0; /* push queue — no DRC fill target */ *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; *target_ms = s_drc.cfg.target_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; } /* Frame-rate readout available in EVERY product, release included. * * The debug server and the freeze heartbeat are compiled out under * PSX_NO_DEBUG_TOOLS, which left shipped binaries unable to report their own * speed: a throughput regression could only be measured on the diagnostic * build, which carries instrumentation of its own. s_frame_count is already * maintained in production, so this only needs to expose it. * * Opt-in via PSX_FRAME_REPORT_MS (milliseconds between lines). When unset this * is one branch on a cached int per vblank. */ static void frame_report_tick(uint64_t frames) { static int interval_ms = -1; static uint64_t first_ticks = 0, last_ticks = 0, last_frames = 0; if (interval_ms < 0) { const char *e = std::getenv("PSX_FRAME_REPORT_MS"); interval_ms = (e && e[0]) ? std::atoi(e) : 0; if (interval_ms < 0) interval_ms = 0; first_ticks = last_ticks = SDL_GetTicks(); last_frames = frames; if (interval_ms) std::fprintf(stdout, "psxrecomp: frame report every %d ms\n", interval_ms); } if (!interval_ms) return; const uint64_t now = SDL_GetTicks(); if (now - last_ticks < (uint64_t)interval_ms) return; const double win_s = (double)(now - last_ticks) / 1000.0; const double all_s = (double)(now - first_ticks) / 1000.0; std::fprintf(stdout, "psxrecomp: frames=%llu elapsed_ms=%llu fps=%.1f avg_fps=%.1f\n", (unsigned long long)frames, (unsigned long long)(now - first_ticks), win_s > 0.0 ? (double)(frames - last_frames) / win_s : 0.0, all_s > 0.0 ? (double)frames / all_s : 0.0); std::fflush(stdout); last_ticks = now; last_frames = frames; } 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, target_ms = 0.0, correction = 0.0; uint64_t underruns = 0, overflows = 0; int legacy = 0, host_rate = 0; psx_audio_out_stats(&fill_ms, &target_ms, &underruns, &overflows, &correction, &legacy, &host_rate); (void)target_ms; 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_owner=0x%08X/activations>=+%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]; } /* Audio gate state, shared with the mid-frame (VBlank-edge) pump. * * sdl_audio_update() below is the sole authority on whether a pump should emit * audio, discard it, or not run at all. The mid-frame pump exists to keep SPU * time flowing across guest busy-waits that never present a frame, but it must * NOT bypass that authority: pumping unconditionally would push real audio * during a turbo-load hard mute, defeating the mute model (the queue is * supposed to drain and voice positions freeze in place, so music resumes where * it left off rather than replaying time-compressed garble), and would emit to * the device during the discard-only turbo sink. * * So the mid-frame pump mirrors whatever the last frame decided. */ enum AudioGate { AUDIO_GATE_NORMAL = 0, AUDIO_GATE_MUTED = 1, AUDIO_GATE_SINK = 2 }; static AudioGate s_audio_gate = AUDIO_GATE_NORMAL; /* Invoked from the guest-derived VBlank edge (interrupts.c). */ static void sdl_audio_pump_midframe(void) { /* Device optional: SPU still advances from guest cycles (netplay-safe). */ switch (s_audio_gate) { case AUDIO_GATE_MUTED: /* Deliberately nothing. This preserves the existing, user-validated * freeze-in-place mute semantics exactly. NOTE a real tension here: the * SPU is autonomous on hardware and never freezes, so a game that * busy-waits on an SPU-generated condition *during* a turbo load would * still stall. No title in our suite is known to do that; recording it * rather than guessing a fix that would change validated mute audio. */ return; case AUDIO_GATE_SINK: sdl_audio_pump(true); /* advance SPU time, discard output */ return; case AUDIO_GATE_NORMAL: default: sdl_audio_pump(false); return; } } static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) { { /* 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 vol = (float)host_volume_get() / 100.0f; const float g0 = (1.0f - (float)sdl_audio_fadein_left / (float)sdl_audio_fade_samples) * vol; 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 (sdl_audio_device && audio_legacy_mode()) { audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, tail); psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, (uint32_t)tail * sizeof(int16_t) * 2u); } else if (sdl_audio_device && s_drc_ready) { psx_sdl_audio_lock(sdl_audio_device); rab_push(&s_drc, sdl_audio_buf, tail); psx_sdl_audio_unlock(sdl_audio_device); } muted = 1; } s_audio_gate = AUDIO_GATE_MUTED; hangover = HANGOVER_FRAMES; return; } if (muted) { /* Still inside the post-mute hangover: the gate stays MUTED so the * mid-frame pump does not sneak audio out ahead of the unmute ramp. */ 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; s_audio_gate = AUDIO_GATE_SINK; 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; } s_audio_gate = AUDIO_GATE_NORMAL; 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; /* 0 = stick-direction slot (no digital bit alone) */ const char* ini_name; std::vector sources; /* When set, a pressed stick-direction source also contributes this d-pad * bit in digital mode (ls_* -> Up/Down/Left/Right). */ uint16_t fold_bit = 0; }; static int controller_device_index = 0; /* Default ~10% of SDL axis range (32767). Overridden per-player via settings. */ static int controller_deadzone = 3277; /* [controller] anti_deadzone (game.toml). 0 = off, the historical behaviour. */ static int controller_anti_deadzone = 0; static constexpr int kDefaultDeadzoneRaw = 3277; static constexpr int kControllerMapN = 24; using ControllerMap = std::array; static ControllerMap controller_map = {{ { PAD_UP, "up", {}, 0 }, { PAD_DOWN, "down", {}, 0 }, { PAD_LEFT, "left", {}, 0 }, { PAD_RIGHT, "right", {}, 0 }, { PAD_CROSS, "cross", {}, 0 }, { PAD_CIRCLE, "circle", {}, 0 }, { PAD_SQUARE, "square", {}, 0 }, { PAD_TRIANGLE, "triangle", {}, 0 }, { PAD_L1, "l1", {}, 0 }, { PAD_R1, "r1", {}, 0 }, { PAD_L2, "l2", {}, 0 }, { PAD_R2, "r2", {}, 0 }, { PAD_L3, "l3", {}, 0 }, { PAD_R3, "r3", {}, 0 }, { PAD_START, "start", {}, 0 }, { PAD_SELECT, "select", {}, 0 }, /* Stick directions (analog axes by default; fold onto d-pad digitally). */ { 0, "ls_up", {}, PAD_UP }, { 0, "ls_down", {}, PAD_DOWN }, { 0, "ls_left", {}, PAD_LEFT }, { 0, "ls_right", {}, PAD_RIGHT }, { 0, "rs_up", {}, 0 }, { 0, "rs_down", {}, 0 }, { 0, "rs_left", {}, 0 }, { 0, "rs_right", {}, 0 }, }}; /* Per-GUID overrides from input.ini [mapping.]. Absent GUID => global. */ static std::unordered_map controller_maps_by_guid; static const ControllerMap& controller_map_for(const PlayerInput& p) { if (p.guid[0]) { auto it = controller_maps_by_guid.find(p.guid); if (it != controller_maps_by_guid.end()) return it->second; } return controller_map; } 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 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 PSXRecompV4::host_path_is_absolute(p) ? 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; // recomp-ui pad capture persists axes as "name+" / "name-" (see // source_from_bind). Defaults historically omit the suffix for triggers // ("lefttrigger"). Accept both; strip the sign before name lookup. int dir = 0; // -1, 0 (unspecified), +1 if (s.size() >= 2) { const char last = s.back(); const char prev = s[s.size() - 2]; if ((last == '+' || last == '-') && (std::isalnum(static_cast(prev)) || prev == '_')) { dir = (last == '+') ? +1 : -1; s.pop_back(); } } auto as_button = [&](SDL_GameControllerButton b) -> ControllerSource { out.kind = ControllerSource::Kind::Button; out.id = b; return out; }; auto as_axis = [&](SDL_GameControllerAxis a, int d) -> ControllerSource { // Unspecified direction → positive (triggers / capture default). out.kind = (d < 0) ? ControllerSource::Kind::AxisNegative : ControllerSource::Kind::AxisPositive; out.id = a; return out; }; if (s == "a") return as_button(SDL_CONTROLLER_BUTTON_A); if (s == "b") return as_button(SDL_CONTROLLER_BUTTON_B); if (s == "x") return as_button(SDL_CONTROLLER_BUTTON_X); if (s == "y") return as_button(SDL_CONTROLLER_BUTTON_Y); if (s == "back" || s == "view" || s == "select") return as_button(SDL_CONTROLLER_BUTTON_BACK); if (s == "start" || s == "menu") return as_button(SDL_CONTROLLER_BUTTON_START); if (s == "guide") return as_button(SDL_CONTROLLER_BUTTON_GUIDE); if (s == "leftstick") return as_button(SDL_CONTROLLER_BUTTON_LEFTSTICK); if (s == "rightstick") return as_button(SDL_CONTROLLER_BUTTON_RIGHTSTICK); // Shoulders are digital buttons. A trailing +/- from axis-style capture is // ignored so "leftshoulder+" still maps to L1 instead of becoming unbound. if (s == "leftshoulder" || s == "lb" || s == "l1") return as_button(SDL_CONTROLLER_BUTTON_LEFTSHOULDER); if (s == "rightshoulder" || s == "rb" || s == "r1") return as_button(SDL_CONTROLLER_BUTTON_RIGHTSHOULDER); if (s == "dpup" || s == "dpadup") return as_button(SDL_CONTROLLER_BUTTON_DPAD_UP); if (s == "dpdown" || s == "dpaddown") return as_button(SDL_CONTROLLER_BUTTON_DPAD_DOWN); if (s == "dpleft" || s == "dpadleft") return as_button(SDL_CONTROLLER_BUTTON_DPAD_LEFT); if (s == "dpright" || s == "dpadright") return as_button(SDL_CONTROLLER_BUTTON_DPAD_RIGHT); // Triggers: default "lefttrigger" and capture "lefttrigger+" both work. if (s == "lefttrigger" || s == "lt" || s == "l2") return as_axis(SDL_CONTROLLER_AXIS_TRIGGERLEFT, dir == 0 ? +1 : dir); if (s == "righttrigger" || s == "rt" || s == "r2") return as_axis(SDL_CONTROLLER_AXIS_TRIGGERRIGHT, dir == 0 ? +1 : dir); // Stick axes (suffix required unless using a directional alias). if (s == "leftx") { if (dir == 0) return out; return as_axis(SDL_CONTROLLER_AXIS_LEFTX, dir); } if (s == "lefty") { if (dir == 0) return out; return as_axis(SDL_CONTROLLER_AXIS_LEFTY, dir); } if (s == "rightx") { if (dir == 0) return out; return as_axis(SDL_CONTROLLER_AXIS_RIGHTX, dir); } if (s == "righty") { if (dir == 0) return out; return as_axis(SDL_CONTROLLER_AXIS_RIGHTY, dir); } if (s == "lsright") return as_axis(SDL_CONTROLLER_AXIS_LEFTX, +1); if (s == "lsleft") return as_axis(SDL_CONTROLLER_AXIS_LEFTX, -1); if (s == "lsdown") return as_axis(SDL_CONTROLLER_AXIS_LEFTY, +1); if (s == "lsup") return as_axis(SDL_CONTROLLER_AXIS_LEFTY, -1); if (s == "rsright") return as_axis(SDL_CONTROLLER_AXIS_RIGHTX, +1); if (s == "rsleft") return as_axis(SDL_CONTROLLER_AXIS_RIGHTX, -1); if (s == "rsdown") return as_axis(SDL_CONTROLLER_AXIS_RIGHTY, +1); if (s == "rsup") return as_axis(SDL_CONTROLLER_AXIS_RIGHTY, -1); SDL_GameControllerButton button = SDL_GameControllerGetButtonFromString(s.c_str()); if (button != SDL_CONTROLLER_BUTTON_INVALID) return as_button(button); SDL_GameControllerAxis axis = SDL_GameControllerGetAxisFromString(s.c_str()); if (axis != SDL_CONTROLLER_AXIS_INVALID) return as_axis(axis, dir == 0 ? +1 : dir); return out; } static std::vector parse_source_list(const std::string& value) { std::vector 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 apply_sources_to_map(ControllerMap& map, const char* name, const char* sources) { for (auto& entry : map) { if (std::strcmp(entry.ini_name, name) == 0) { entry.sources = parse_source_list(sources); return; } } } static void set_default_controller_mapping_into(ControllerMap& map) { for (auto& entry : map) entry.sources.clear(); /* D-pad and sticks are separate (launcher Gamepad Bindings layout). Digital * mode still folds ls_* onto d-pad bits via fold_bit. */ apply_sources_to_map(map, "up", "dpup"); apply_sources_to_map(map, "down", "dpdown"); apply_sources_to_map(map, "left", "dpleft"); apply_sources_to_map(map, "right", "dpright"); apply_sources_to_map(map, "cross", "a"); apply_sources_to_map(map, "circle", "b"); apply_sources_to_map(map, "square", "x"); apply_sources_to_map(map, "triangle", "y"); apply_sources_to_map(map, "l1", "leftshoulder"); apply_sources_to_map(map, "r1", "rightshoulder"); apply_sources_to_map(map, "l2", "lefttrigger"); apply_sources_to_map(map, "r2", "righttrigger"); apply_sources_to_map(map, "l3", "leftstick"); apply_sources_to_map(map, "r3", "rightstick"); apply_sources_to_map(map, "start", "start"); apply_sources_to_map(map, "select", "back"); apply_sources_to_map(map, "ls_up", "lefty-"); apply_sources_to_map(map, "ls_down", "lefty+"); apply_sources_to_map(map, "ls_left", "leftx-"); apply_sources_to_map(map, "ls_right", "leftx+"); apply_sources_to_map(map, "rs_up", "righty-"); apply_sources_to_map(map, "rs_down", "righty+"); apply_sources_to_map(map, "rs_left", "rightx-"); apply_sources_to_map(map, "rs_right", "rightx+"); } static void set_default_controller_mapping(void) { set_default_controller_mapping_into(controller_map); controller_maps_by_guid.clear(); } 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" "; Axis capture may append +/−; both forms are accepted. PSX slots are digital.\n" "; Optional per-device overrides: [mapping.].\n" "\n" "[controller]\n" "enabled = true\n" "device = 0\n" "deadzone = 3277\n" "\n" "[mapping]\n" "up = dpup\n" "down = dpdown\n" "left = dpleft\n" "right = dpright\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" "ls_up = lefty-\n" "ls_down = lefty+\n" "ls_left = leftx-\n" "ls_right = leftx+\n" "rs_up = righty-\n" "rs_down = righty+\n" "rs_left = rightx-\n" "rs_right = rightx+\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(); psx_keybinds_init(argv0); return; } std::ifstream in(config_path); if (!in) { psx_keybinds_init(argv0); return; } bool controller_enabled = true; std::string section; std::string line; ControllerMap* guid_target = nullptr; 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))); guid_target = nullptr; if (section.rfind("mapping.", 0) == 0) { const std::string guid = section.substr(8); if (!guid.empty()) { /* Seed from the current global map (defaults + any * [mapping] keys already parsed), then overlay GUID keys. */ if (!controller_maps_by_guid.count(guid)) controller_maps_by_guid[guid] = controller_map; guid_target = &controller_maps_by_guid[guid]; } } 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; } } } else if (guid_target) { for (auto& entry : *guid_target) { if (key == entry.ini_name) { entry.sources = parse_source_list(value); break; } } } } if (!controller_enabled) { for (auto& entry : controller_map) entry.sources.clear(); for (auto& kv : controller_maps_by_guid) for (auto& entry : kv.second) 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) { if (p.rumble_small || p.rumble_large) (void)SDL_GameControllerRumble(p.handle, 0, 0, 0); SDL_GameControllerClose(p.handle); p.handle = nullptr; p.instance = -1; } p.rumble_small = 0; p.rumble_large = 0; p.rumble_known = false; p.rumble_warned = false; p.steering_wheel = false; } static void close_controller(void) { for (int s = 0; s < PSX_MAX_PLAYERS; s++) close_player(g_players[s]); } static int device_claimed_by_other(int self_slot, SDL_JoystickID inst) { for (int o = 0; o < PSX_MAX_PLAYERS; o++) { if (o == self_slot) continue; if (g_players[o].handle && g_players[o].instance == inst) return 1; } return 0; } /* 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 another player. */ static void open_player(PlayerInput& p, int self_slot) { 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 another player. */ SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); if (device_claimed_by_other(self_slot, 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; /* Persist the GUID of the pad we actually opened so per-device * [mapping.] lookups match the live hardware (including the * first-available fallback when the saved GUID is offline). */ { SDL_JoystickGUID g = SDL_JoystickGetDeviceGUID(chosen); SDL_JoystickGetGUIDString(g, p.guid, (int)sizeof(p.guid)); } const char* name = SDL_GameControllerName(p.handle); p.steering_wheel = psx_controller_name_is_wheel(name); std::fprintf(stdout, "psxrecomp runtime: opened controller for slot: %s\n", name ? name : "(unnamed)"); if (p.steering_wheel) std::fprintf(stdout, "psxrecomp runtime: steering wheel detected for slot %d; using JogCon SIO input\n", self_slot + 1); p.rumble_known = false; p.rumble_warned = false; } } /* Forward the emulated DualShock's two motors to the SDL controller assigned * to the same PSX port. The large motor is variable-strength/low-frequency; * the small motor is fixed-strength/high-frequency. Active effects are renewed * every VBlank with a short lifetime so a crash or unplug cannot leave a host * controller vibrating indefinitely. */ static void update_controller_rumble(void) { static const bool trace = [] { const char* e = std::getenv("PSX_RUMBLE_TRACE"); return e && e[0] && e[0] != '0'; }(); for (int s = 0; s < PSX_MAX_PLAYERS; s++) { PlayerInput& p = g_players[s]; uint8_t small = 0, large = 0; sio_get_pad_rumble(s, &small, &large); if (!p.handle) { p.rumble_small = small; p.rumble_large = large; p.rumble_known = true; continue; } const bool changed = !p.rumble_known || p.rumble_small != small || p.rumble_large != large; if (small || large || (changed && p.rumble_known)) { const Uint16 low_frequency = (Uint16)((unsigned)large * 257u); const Uint16 high_frequency = small ? 0xFFFFu : 0u; #if defined(PSX_SDL3) const int rc = SDL_GameControllerRumble( p.handle, low_frequency, high_frequency, (small || large) ? 100u : 0u) ? 0 : -1; #else const int rc = SDL_GameControllerRumble( p.handle, low_frequency, high_frequency, (small || large) ? 100u : 0u); #endif if (rc != 0 && !p.rumble_warned) { std::fprintf(stderr, "psxrecomp runtime: controller for slot %d rejected rumble: %s\n", s + 1, SDL_GetError()); p.rumble_warned = true; } } if (trace && changed) { std::fprintf(stdout, "psxrecomp rumble: slot=%d small=%u large=%u\n", s + 1, (unsigned)small, (unsigned)large); } p.rumble_small = small; p.rumble_large = large; p.rumble_known = true; } } /* The pad type a mode reports before any input has been sampled (boot / * hotplug). analog pins DualShock; digital starts as a plain digital pad. */ static int pad_mode_boot_analog(int mode) { return mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; } static int pad_type_boot(const PlayerInput& p, int mode) { return p.steering_wheel ? SIO_PAD_JOGCON : (pad_mode_boot_analog(mode) ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL); } /* Boot/hotplug type for slot s: a registered title pad transform names the * type it presents before its first frame; otherwise the device/mode type. */ static int pad_type_boot_for_slot(int s, const PlayerInput& p, int mode) { const int initial = mod_pad_transform_initial_type((uint32_t)s); return initial >= 0 ? initial : pad_type_boot(p, mode); } /* Keyboard mappings can drive both DualShock sticks. Honor the configured * mode, including analog-only title locks; multitap policy belongs to the * SIO seat below and applies equally to keyboards and physical controllers. */ static int effective_player_mode(const PlayerInput& p) { return p.mode; } /* Pad mode for the SIO seat this sample will drive. Multitap taps stay digital * unless the DualShock-on-tap hack (sio_get_multitap_analog) is armed. */ static int effective_player_mode_for_sio(const PlayerInput& p, int sio_slot) { if (sio_pad_on_multitap(sio_slot) && !sio_get_multitap_analog()) return (int)PSXRecompV4::PAD_MODE_DIGITAL; return effective_player_mode(p); } /* Assert one slot's real SIO profile (connection, config capability) from its * device/mode/policy and return the pad mode to present at boot. Used by * refresh_player_devices and to restore a port once a mod controller source * detaches. dev_here: dev-any-input keeps P1 connected without a device. */ static int assert_sio_pad_profile(int s, bool dev_here) { const PlayerInput& p = g_players[s]; const int mode = effective_player_mode_for_sio(p, s); const ModControllerPresentationPolicy& policy = g_mod_controller_policy[s]; const int boot_mode = policy.callback ? policy.initial_mode : mode; sio_set_pad_connected(s, (p.kind != 0 || dev_here) ? 1 : 0); /* DIGITAL mode == a plain digital controller that ignores the DualShock * config-mode commands (real SCPH-1080 behaviour); ANALOG or an * explicitly config-capable mod policy == a config-capable DualShock. * A digital pad that wrongly answered 0x43 sent Tomba 2's pad driver * down the config path -> phantom 0x00 reads. * Multitap taps are always digital (see sio_pad_on_multitap). */ sio_set_pad_config_capable( s, policy.callback ? policy.config_capable : (p.steering_wheel || mode != PSXRecompV4::PAD_MODE_DIGITAL)); return boot_mode; } /* 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) { psx_local_mouse_reset(); const int netplay = psx_netplay_active(); for (int s = 0; s < PSX_MAX_PLAYERS; s++) { PlayerInput& p = g_players[s]; char active_guid[40] = {0}; const bool attached = p.handle && SDL_GameControllerGetAttached(p.handle); if (p.handle) { SDL_Joystick* joy = SDL_GameControllerGetJoystick(p.handle); if (joy) SDL_JoystickGetGUIDString(SDL_JoystickGetGUID(joy), active_guid, (int)sizeof(active_guid)); } /* The launcher may change GUID while the old handle remains attached; * pure policy covers that and unplug paths with mockable inputs. */ if (psx_controller_handle_needs_close( p.kind == 2, p.handle != nullptr, attached, p.guid, active_guid)) close_player(p); if (p.kind == 2) open_player(p, s); if (netplay) continue; const int boot_mode = assert_sio_pad_profile(s, false); const int boot_type = pad_type_boot_for_slot(s, p, boot_mode); if (boot_type == SIO_PAD_DUALSHOCK || boot_type == SIO_PAD_JOGCON) sio_set_pad_config_capable(s, 1); sio_set_pad_type(s, boot_type, 0x80, 0x80, 0x80, 0x80); } } /* Parse a [controller] device string into a player slot: * "none" -> no pad; "keyboard" -> keyboard map; otherwise an SDL GUID. */ static void set_player_device(PlayerInput& p, const std::string& dev, int mode) { p.mode = mode; 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, int deadzone_raw) { if (!h) return false; const int dz = deadzone_raw > 0 ? deadzone_raw : controller_deadzone; 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 > dz; return v < -dz; } 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 ls_* stick * directions still fold onto the D-pad via fold_bit. */ static uint16_t controller_pad_buttons(const ControllerMap& map, SDL_GameController* h, bool suppress_stick_axes, int deadzone_raw, uint32_t claimed_buttons = 0) { uint16_t buttons = 0xFFFF; /* all released */ if (!h) return buttons; for (const auto& entry : map) { for (const auto& source : entry.sources) { if (suppress_stick_axes && source_is_stick_axis(source)) continue; /* A host button claimed by a direct shortcut never reaches the * guest (psx_hotkey_pad.h). */ if (claimed_buttons && source.kind == ControllerSource::Kind::Button && source.id >= 0 && source.id < 32 && (claimed_buttons & (1u << source.id))) continue; if (!controller_source_pressed_h(h, source, deadzone_raw)) continue; if (entry.bit) buttons &= (uint16_t)~entry.bit; if (entry.fold_bit && !suppress_stick_axes) buttons &= (uint16_t)~entry.fold_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. */ /* Radial deadzone + anti-deadzone in ONE place: psx_stick_to_dualshock * (runtime/src/psx_stick.c), the shared implementation master calls. The * per-player deadzone is PR #110's; anti_deadzone is master's [controller] * setting, which an inlined copy of the maths here silently dropped. */ static void axes_to_pad_pair(int16_t vx, int16_t vy, uint8_t* obx, uint8_t* oby, int deadzone_raw) { psx_stick_to_dualshock(vx, vy, deadzone_raw > 0 ? deadzone_raw : controller_deadzone, controller_anti_deadzone, obx, oby); } /* Buttons for a player's selected device (0xFFFF = none pressed). `player` is * 1..5 — 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(controller_map_for(p), p.handle, suppress_stick_axes, p.deadzone, player == 1 ? g_p1_claimed_buttons : 0u); 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). The physical * D-pad is deliberately NOT folded onto those axes: a real DualShock holds * both sticks at 0x80 while the D-pad is pressed, so presenting a direction * as a button bit AND a full stick deflection at once is a state no hardware * produces. An analog-aware game that reads both sources then acts on one * press twice — measured on Legend of Mana's land-placement cursor, which * stepped two entries per tap in pinned-ANALOG and exactly one in DIGITAL. * * A game whose movement reads only the stick magnitude (e.g. Tomba) can opt * into a game-owned presentation policy. That policy may flip the pad to * digital on D-pad input so the game runs its own D-pad path, without ever * presenting the impossible both-at-once state. * * The keyboard branch is unaffected: psx_keybinds_sticks maps that player's * bound stick-direction keys onto the axes, which is their only stick * source. */ static void pad_sticks_for(const PlayerInput& p, int player, uint8_t out[4]) { 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) { const ControllerMap& map = controller_map_for(p); auto find_entry = [&](const char* name) -> const PsxButtonMap* { for (const auto& e : map) if (std::strcmp(e.ini_name, name) == 0) return &e; return nullptr; }; /* Default left/right stick axis layout → smooth radial deadzone path. * Any remapped stick direction falls back to discrete full deflection. */ auto sticks_default_axes = [&](const char* up, const char* down, const char* left, const char* right, SDL_GameControllerAxis ax, SDL_GameControllerAxis ay) -> bool { auto is_axis = [&](const char* n, int axis_id, ControllerSource::Kind k) { const PsxButtonMap* e = find_entry(n); if (!e || e->sources.size() != 1) return false; const auto& s = e->sources[0]; return s.kind == k && s.id == axis_id; }; return is_axis(up, ay, ControllerSource::Kind::AxisNegative) && is_axis(down, ay, ControllerSource::Kind::AxisPositive) && is_axis(left, ax, ControllerSource::Kind::AxisNegative) && is_axis(right, ax, ControllerSource::Kind::AxisPositive); }; auto apply_discrete_stick = [&](const char* up, const char* down, const char* left, const char* right, uint8_t* bx, uint8_t* by) { *bx = *by = 0x80; auto pressed = [&](const char* n) { const PsxButtonMap* e = find_entry(n); if (!e) return false; for (const auto& s : e->sources) if (controller_source_pressed_h(p.handle, s, p.deadzone)) return true; return false; }; if (pressed(left)) *bx = 0x00; if (pressed(right)) *bx = 0xFF; if (pressed(up)) *by = 0x00; if (pressed(down)) *by = 0xFF; }; if (sticks_default_axes("ls_up", "ls_down", "ls_left", "ls_right", SDL_CONTROLLER_AXIS_LEFTX, SDL_CONTROLLER_AXIS_LEFTY)) { axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTX), SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY), &out[0], &out[1], p.deadzone); } else { apply_discrete_stick("ls_up", "ls_down", "ls_left", "ls_right", &out[0], &out[1]); } if (sticks_default_axes("rs_up", "rs_down", "rs_left", "rs_right", SDL_CONTROLLER_AXIS_RIGHTX, SDL_CONTROLLER_AXIS_RIGHTY)) { axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTX), SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTY), &out[2], &out[3], p.deadzone); } else { apply_discrete_stick("rs_up", "rs_down", "rs_left", "rs_right", &out[2], &out[3]); } } } /* Controller-policy facts exposed to trusted game-owned plugins. The runtime * samples host devices and SIO delivery; the plugin decides only whether that * sample should present as analog or digital. */ static bool controller_stick_active(SDL_GameController* handle, int deadzone) { if (!handle) return false; const double lx = SDL_GameControllerGetAxis(handle, SDL_CONTROLLER_AXIS_LEFTX); const double ly = SDL_GameControllerGetAxis(handle, SDL_CONTROLLER_AXIS_LEFTY); const double dz = (double)(deadzone > 0 ? deadzone : controller_deadzone); return std::sqrt(lx * lx + ly * ly) > dz; } static bool controller_mapped_dpad_active(const PlayerInput& p, SDL_GameController* handle, int deadzone) { if (!handle) return false; const uint16_t buttons = controller_pad_buttons(controller_map_for(p), handle, true, deadzone); return ((uint16_t)~buttons & 0x00F0u) != 0; } /* Which input sources may drive ONE pad slot this frame. * * This exists because the answer is consumed in three places — the button * merge, the mod presentation policy, and the analog stick fold — * and those three used to compute it independently. Whenever they disagreed, * a source could assert a button without the policy detector seeing it, * leaving the pad reporting D-pad presses while still presenting ANALOG: the * exact failure a policy callback may exist to prevent, and silent when it * happens. Deriving all three from one predicate makes that desync * structurally impossible rather than merely fixed once. * * Changing input-routing POLICY is therefore a change to this function alone * (e.g. whether keybinds stay live alongside a routed gamepad), not a change * to three separate conditions that must be kept in step by hand. */ struct PadSources { bool device; /* the slot's own assigned device (keyboard or controller) */ bool keybinds; /* keybinds.ini keyboard/mouse binds for this player */ bool all_pads; /* every connected controller (dev-any-input) */ }; static PadSources pad_sources_for(const PlayerInput& p, bool dev_here) { PadSources s; s.device = (p.kind != 0); /* Keybinds are ALWAYS live, including alongside a routed gamepad: that is * the whole point of binding a mouse button for aiming while holding a * pad. Routing a player to a controller used to discard every * keybinds.ini/mouse bind silently. * * Widening this ONE line is safe precisely because every consumer reads * it — the button merge, mod policy detectors and the stick * fold all learn about the keyboard in the same instant. Widening the * button merge alone (the original shape of this change) let a keyboard * D-pad press assert the D-pad bits while policy detection never saw * them, leaving a policy-driven pad reporting D-pad input while still * presenting ANALOG. * * The PSX pad word is active-low and the merge is an AND, so an unpressed * source is a no-op: a pad-only player is unaffected. kind==1 already * consumes the binds through pad_buttons_for/pad_sticks_for, and applying * them twice is idempotent. */ (void)dev_here; s.keybinds = true; s.all_pads = dev_here; return s; } static bool controller_policy_stick_active(const PlayerInput& p, const PadSources& src) { if (src.device && p.kind == 2 && controller_stick_active(p.handle, p.deadzone)) return true; if (src.all_pads) { const int n = SDL_NumJoysticks(); for (int i = 0; i < n; i++) { if (!SDL_IsGameController(i)) continue; const SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); SDL_GameController* handle = SDL_GameControllerFromInstanceID(inst); if (!handle) handle = SDL_GameControllerOpen(i); if (controller_stick_active(handle, controller_deadzone)) return true; } } return false; } static bool controller_policy_dpad_active(const PlayerInput& p, int player, const PadSources& src) { if (src.device && p.kind == 2 && controller_mapped_dpad_active(p, p.handle, p.deadzone)) return true; if (src.all_pads) { const int n = SDL_NumJoysticks(); for (int i = 0; i < n; i++) { if (!SDL_IsGameController(i)) continue; const SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); SDL_GameController* handle = SDL_GameControllerFromInstanceID(inst); if (!handle) handle = SDL_GameControllerOpen(i); if (!handle) continue; PlayerInput tmp; tmp.kind = 2; SDL_JoystickGUID g = SDL_JoystickGetDeviceGUID(i); SDL_JoystickGetGUIDString(g, tmp.guid, (int)sizeof(tmp.guid)); if (controller_mapped_dpad_active(tmp, handle, controller_deadzone)) return true; } } if (src.keybinds) { const Uint8* keys = SDL_GetKeyboardState(NULL); if (psx_keybinds_dpad_active(keys, player)) return true; } return false; } static int controller_policy_resolve_mode( int slot, int player, int configured_mode, const PadSources& src, const PlayerInput& p, uint16_t buttons, const uint8_t st[4]) { ModControllerPresentationPolicy& policy = g_mod_controller_policy[slot]; if (!policy.callback) return configured_mode; const uint32_t current_mode = configured_mode == PSXRecompV4::PAD_MODE_DIGITAL ? (uint32_t)PSX_MOD_CONTROLLER_DIGITAL : (uint32_t)PSX_MOD_CONTROLLER_ANALOG; PSXModControllerInput input{}; input.struct_size = sizeof(input); input.player = (uint32_t)player; input.sio_slot = (uint32_t)slot; input.configured_mode = current_mode; input.current_mode = current_mode; input.stick_active = controller_policy_stick_active(p, src) ? 1u : 0u; input.dpad_active = controller_policy_dpad_active(p, player, src) ? 1u : 0u; input.buttons = buttons; input.lx = st[0]; input.ly = st[1]; input.rx = st[2]; input.ry = st[3]; const uint32_t requested = policy.callback(&input); if (requested == (uint32_t)PSX_MOD_CONTROLLER_ANALOG) return PSXRecompV4::PAD_MODE_ANALOG; if (requested == (uint32_t)PSX_MOD_CONTROLLER_DIGITAL) return PSXRecompV4::PAD_MODE_DIGITAL; std::fprintf(stderr, "psxrecomp: mod controller policy returned invalid mode %u " "(player %u)\n", (unsigned)requested, (unsigned)player); return configured_mode; } static int controller_policy_resolve_override_mode( int slot, int player, int configured_mode, uint16_t buttons, const uint8_t st[4], bool stick_live, bool dpad_live) { ModControllerPresentationPolicy& policy = g_mod_controller_policy[slot]; if (!policy.callback) return configured_mode; const uint32_t current_mode = configured_mode == PSXRecompV4::PAD_MODE_DIGITAL ? (uint32_t)PSX_MOD_CONTROLLER_DIGITAL : (uint32_t)PSX_MOD_CONTROLLER_ANALOG; PSXModControllerInput input{}; input.struct_size = sizeof(input); input.player = (uint32_t)player; input.sio_slot = (uint32_t)slot; input.configured_mode = current_mode; input.current_mode = current_mode; input.stick_active = stick_live ? 1u : 0u; input.dpad_active = dpad_live ? 1u : 0u; input.buttons = buttons; input.lx = st[0]; input.ly = st[1]; input.rx = st[2]; input.ry = st[3]; const uint32_t requested = policy.callback(&input); if (requested == (uint32_t)PSX_MOD_CONTROLLER_ANALOG) return PSXRecompV4::PAD_MODE_ANALOG; if (requested == (uint32_t)PSX_MOD_CONTROLLER_DIGITAL) return PSXRecompV4::PAD_MODE_DIGITAL; std::fprintf(stderr, "psxrecomp: mod controller policy returned invalid mode %u " "(player %u)\n", (unsigned)requested, (unsigned)player); return configured_mode; } /* 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. * * A game-owned presentation policy may auto-switch DualShock<->digital from * the most-recent input so the game runs its own analog or digital path. */ /* 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. This diagnostic convenience must never alter normal player input: * strict single-device-per-port routing is the default, and PSX_DEV_INPUT must * be explicitly set to 1/true/yes/on to enable the merge. */ static bool dev_any_input_enabled() { static int cached = -1; if (cached < 0) { const char* e = std::getenv("PSX_DEV_INPUT"); const std::string value = lower_copy(trim_copy(e ? e : "")); cached = (value == "1" || value == "true" || value == "yes" || value == "on") ? 1 : 0; } 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) continue; PlayerInput tmp; tmp.kind = 2; { SDL_JoystickGUID g = SDL_JoystickGetDeviceGUID(i); SDL_JoystickGetGUIDString(g, tmp.guid, (int)sizeof(tmp.guid)); } btn &= controller_pad_buttons(controller_map_for(tmp), h, suppress_stick_axes, controller_deadzone); } 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, controller_deadzone); axes_to_pad_pair(SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTX), SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTY), &rx, &ry, controller_deadzone); if (lx != 0x80 || ly != 0x80) { st[0] = lx; st[1] = ly; } if (rx != 0x80 || ry != 0x80) { st[2] = rx; st[3] = ry; } } } static void savestate_input_guard_arm(void) { psx_local_mouse_reset(); uint32_t now = (uint32_t)SDL_GetTicks(); g_savestate_input_guard_min_until = now + 90u; g_savestate_input_guard_max_until = now + 700u; } static int any_controller_button_down(void) { 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; for (int b = 0; b < SDL_CONTROLLER_BUTTON_MAX; b++) { if (SDL_GameControllerGetButton(h, (SDL_GameControllerButton)b)) return 1; } } return 0; } static int savestate_resume_inputs_held(void) { const Uint8* keys = SDL_GetKeyboardState(NULL); if (keys) { if (keys[SDL_SCANCODE_RETURN] || keys[SDL_SCANCODE_KP_ENTER] || keys[SDL_SCANCODE_SPACE] || keys[SDL_SCANCODE_L]) return 1; if (pad_from_keyboard(1) != 0xFFFFu) return 1; } return any_controller_button_down(); } static int savestate_input_guard_active(void) { uint32_t now; if (g_savestate_input_guard_max_until == 0) return 0; now = (uint32_t)SDL_GetTicks(); if ((int32_t)(now - g_savestate_input_guard_max_until) >= 0) { g_savestate_input_guard_min_until = 0; g_savestate_input_guard_max_until = 0; return 0; } if ((int32_t)(now - g_savestate_input_guard_min_until) >= 0 && !savestate_resume_inputs_held()) { g_savestate_input_guard_min_until = 0; g_savestate_input_guard_max_until = 0; return 0; } return 1; } /* 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 policy-driven P1 * could stay 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, an injected stick override reads as stick activity, 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 */ /* Prefer the configured seat mode (incl. game.toml lock_mode / settings * p1_mode). Falling back to DIGITAL when kind==0 made DualShock-locked * titles (Ape Escape) ignore debug-server injection in headless runs. */ int mode; if (p.kind != 0) mode = effective_player_mode(p); else if (dev_any_input_enabled()) mode = (int)PSXRecompV4::PAD_MODE_ANALOG; else mode = p.mode; const int effective_mode = controller_policy_resolve_override_mode( 0, 1, mode, w, st, stick_live, dpad_live); int eff_analog = effective_mode == (int)PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; #ifndef PSX_NO_DEBUG_TOOLS const int debug_type = debug_server_get_pad_type_override(); if (debug_type == SIO_PAD_DUALSHOCK || debug_type == SIO_PAD_JOGCON) eff_analog = 1; #endif /* Injected input only (set_input / dev routing): fold the injected D-pad * word onto the left stick so stick-only menu/move paths respond to a * button-bit injection that has no physical stick behind it. * * This is NOT hardware behaviour -- a real DualShock keeps its sticks at * 0x80 while the D-pad is held -- and a game reading both sources will * act on one injected press twice. It is kept only because injection has * no other way to steer such a game; the equivalent fold for physical * controllers was removed (see pad_sticks_for) after it was measured * double-stepping Legend of Mana's land-placement cursor. */ if (eff_analog && effective_mode == (int)PSXRecompV4::PAD_MODE_ANALOG && !stick_live) { if ((uint16_t)(~w & 0x0010u)) st[1] = 0x00; /* Up */ if ((uint16_t)(~w & 0x0040u)) st[1] = 0xFF; /* Down */ if ((uint16_t)(~w & 0x0080u)) st[0] = 0x00; /* Left */ if ((uint16_t)(~w & 0x0020u)) st[0] = 0xFF; /* Right */ } 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]); int injected_type = p.steering_wheel ? SIO_PAD_JOGCON : (eff_analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL); #ifndef PSX_NO_DEBUG_TOOLS if (debug_type >= SIO_PAD_DIGITAL && debug_type <= SIO_PAD_JOGCON) injected_type = debug_type; #endif sio_request_pad_type(0, injected_type); psx_selfcheck_note_pad(0, w, st[0], st[1], st[2], st[3], (uint8_t)injected_type); } /* Local host sticks for presentation-only mods (mod_plugins.h). */ extern "C" int psx_mod_read_local_pad_sticks(uint32_t player, uint8_t out[4]) { if (!out) return 0; out[0] = out[1] = out[2] = out[3] = 0x80; const bool player_valid = player < PSX_MAX_PLAYERS; const bool netplay = psx_netplay_active() != 0; const bool resimulating = psx_netplay_is_resimulating() != 0; if (!player_valid || netplay || resimulating) return 0; PlayerInput& p = g_players[player]; const bool dev_here = player == 0 && dev_any_input_enabled(); const bool attached = p.kind == 1 || (p.kind == 2 && p.handle); if (!psx_mod_local_input_available(player_valid, attached || dev_here, netplay, resimulating)) return 0; #ifndef PSX_NO_DEBUG_TOOLS /* The local debug injector supplies a deterministic host-side sample for * camera/input tests. It is never sourced from rollback or a peer. */ if (player == 0) { uint8_t injected[4]; if (debug_server_get_axis_override(injected)) { std::memcpy(out, injected, sizeof(injected)); return 1; } } #endif if (attached) { /* Read the host pad mapping directly. The SIO-facing mode can be * digital because the game selected a digital pad; presentation mods * still need local axes without changing that simulation state. */ pad_sticks_for(p, (int)player + 1, out); return 1; } const Uint8* keys = SDL_GetKeyboardState(NULL); psx_keybinds_sticks(keys, (int)player + 1, out); dev_any_controller_sticks(out); return 1; } /* Capture one SIO slot's PHYSICAL host pad into a netplay/local blob. Returns * 1 if a device (or dev-any P1) is present; 0 leaves *out as released/ * disconnected. `guarded` (savestate input guard) delivers neutral buttons and * sticks but still resolves presence/type. No mouse or source side effects: * those are layered on top by pad_ext_resolve(). */ /* Debug host-pad layer (set_input {"layer":"host"}): a virtual P1 gamepad. */ static int host_pad_layer(uint16_t* buttons, uint8_t st[4], uint8_t* lt, uint8_t* rt) { #ifndef PSX_NO_DEBUG_TOOLS return debug_server_get_host_pad(buttons, st, lt, rt); #else (void)buttons; (void)st; (void)lt; (void)rt; return 0; #endif } /* The host layer's PSX word expressed as the Xbox-layout SDL buttons a * default-mapped gamepad would press (L2/R2 are trigger axes). */ static const struct { int sdl; uint16_t psx; } kHostLayerButtons[] = { { SDL_CONTROLLER_BUTTON_A, PAD_CROSS }, { SDL_CONTROLLER_BUTTON_B, PAD_CIRCLE }, { SDL_CONTROLLER_BUTTON_X, PAD_SQUARE }, { SDL_CONTROLLER_BUTTON_Y, PAD_TRIANGLE }, { SDL_CONTROLLER_BUTTON_BACK, PAD_SELECT }, { SDL_CONTROLLER_BUTTON_START, PAD_START }, { SDL_CONTROLLER_BUTTON_LEFTSTICK, PAD_L3 }, { SDL_CONTROLLER_BUTTON_RIGHTSTICK, PAD_R3 }, { SDL_CONTROLLER_BUTTON_LEFTSHOULDER, PAD_L1 }, { SDL_CONTROLLER_BUTTON_RIGHTSHOULDER, PAD_R1 }, { SDL_CONTROLLER_BUTTON_DPAD_UP, PAD_UP }, { SDL_CONTROLLER_BUTTON_DPAD_DOWN, PAD_DOWN }, { SDL_CONTROLLER_BUTTON_DPAD_LEFT, PAD_LEFT }, { SDL_CONTROLLER_BUTTON_DPAD_RIGHT, PAD_RIGHT }, }; static uint32_t host_layer_sdl_held(uint16_t word) { uint32_t held = 0; for (const auto& b : kHostLayerButtons) if ((word & b.psx) == 0) held |= 1u << b.sdl; return held; } static uint16_t host_layer_release_claimed(uint16_t word, uint32_t claimed) { for (const auto& b : kHostLayerButtons) if (claimed & (1u << b.sdl)) word |= b.psx; return word; } /* Stage 1 for P1 while the host layer is armed: buttons less any claimed * shortcut button; DualShock with the layer's sticks unless P1 is digital * (host_view, the transform's host pad: the sticks in either mode). */ static int capture_host_layer_pad(PsxNetPad* out, uint16_t word, const uint8_t st[4], bool guarded, bool host_view) { const int mode = effective_player_mode_for_sio(g_players[0], 0); const bool analog = mode != PSXRecompV4::PAD_MODE_DIGITAL; const bool sticks = analog || host_view; out->buttons = host_layer_release_claimed(word, g_p1_claimed_buttons); out->lx = sticks ? st[0] : 0x80; out->ly = sticks ? st[1] : 0x80; out->rx = sticks ? st[2] : 0x80; out->ry = sticks ? st[3] : 0x80; out->analog = analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL; out->connected = 1; if (guarded) { out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; } return 1; } /* host_view: the port's host pad before presentation, for a title pad * transform (PadExtHooks.host_pad): real sticks and buttons without the * digital stick->D-pad fold, whatever the configured mode; type unchanged. */ static int capture_pad_slot_view(int s, PsxNetPad* out, bool guarded, bool host_view) { if (!out) return 0; out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; out->analog = 0; out->connected = 0; if (s == 0) { uint16_t host_word; uint8_t host_st[4], host_lt, host_rt; if (host_pad_layer(&host_word, host_st, &host_lt, &host_rt)) return capture_host_layer_pad(out, host_word, host_st, guarded, host_view); } PlayerInput& p = g_players[s]; const int player = s + 1; /* keybinds.ini section (1..5) */ /* Opt-in dev merge: P1 is driven by the keyboard AND every connected * controller (PSX_DEV_INPUT=1). Default is strict per-slot 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 * keeps the game's resolved mode while dev-any-input merges the keyboard and * all connected controllers. */ /* One source set, consumed by the presentation policy, the button merge and the * stick fold below — see pad_sources_for(). */ const PadSources src = pad_sources_for(p, dev_here); const int mode = effective_player_mode_for_sio(p, s); uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; if (mode == PSXRecompV4::PAD_MODE_ANALOG || p.steering_wheel || g_mod_controller_policy[s].callback || host_view) { pad_sticks_for(p, player, st); } const uint16_t policy_buttons = src.device ? pad_buttons_for(p, player, true) : (uint16_t)0xFFFF; const int effective_mode = controller_policy_resolve_mode( s, player, mode, src, p, policy_buttons, st); const int eff_analog = effective_mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; int frame_type = p.steering_wheel ? SIO_PAD_JOGCON : (eff_analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL); #ifndef PSX_NO_DEBUG_TOOLS const int debug_type = debug_server_get_pad_type_override(); if (debug_type >= SIO_PAD_DIGITAL && debug_type <= SIO_PAD_JOGCON) frame_type = debug_type; #endif if (guarded) { out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; out->analog = (uint8_t)frame_type; out->connected = 1; return 1; } /* 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) || p.steering_wheel || host_view; uint16_t btn = src.device ? pad_buttons_for(p, player, suppress_stick) : (uint16_t)0xFFFF; /* kind==1 already consumed the binds inside pad_buttons_for — ANDing the * same word twice is idempotent, so this stays a plain source check. */ if (src.keybinds) btn &= pad_from_keyboard(player); if (src.all_pads) btn &= dev_all_controllers_buttons(suppress_stick); /* Analog axes. ANALOG and plugin-selected analog feed the raw stick only; * the D-pad is never folded in, matching a real DualShock, which keeps * its sticks centred while the D-pad is held. DIGITAL leaves the axes * centred. */ /* Stick fold, driven by the SAME source set as the buttons above: whatever * may press a button may also steer. kind==1 already folded its binds * inside pad_sticks_for; psx_keybinds_sticks only widens a deflection, so * applying it twice is idempotent. */ if (eff_analog || host_view) { if (src.keybinds) { const Uint8* keys = SDL_GetKeyboardState(NULL); psx_keybinds_sticks(keys, player, st); } if (src.all_pads) dev_any_controller_sticks(st); } if (!eff_analog && !p.steering_wheel && !host_view) { st[0] = st[1] = st[2] = st[3] = 0x80; } out->buttons = btn; out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; out->analog = (uint8_t)frame_type; out->connected = 1; return 1; } static int capture_pad_slot(int s, PsxNetPad* out, bool guarded) { return capture_pad_slot_view(s, out, guarded, false); } /* 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. present_sio_slot is the delay-sync seat this * blob will publish to (may differ from host card `s` on guests). */ static int capture_pad_slot_exclusive(int s, PsxNetPad* out, int present_sio_slot) { 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..5) */ const bool dev_here = false; if (p.kind == 0) return 0; /* no device in this port */ /* Same predicate as capture_pad_slot, with dev-any-input disabled: * netplay must stay exclusive so peers hash-agree. */ const PadSources src = pad_sources_for(p, dev_here); const int sio_slot = (present_sio_slot >= 0) ? present_sio_slot : s; int mode = effective_player_mode_for_sio(p, sio_slot); uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; if (mode == PSXRecompV4::PAD_MODE_ANALOG || p.steering_wheel || g_mod_controller_policy[s].callback) { pad_sticks_for(p, player, st); } const uint16_t policy_buttons = pad_buttons_for(p, player, true); const int effective_mode = controller_policy_resolve_mode( s, player, mode, src, p, policy_buttons, st); const int eff_analog = effective_mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; const int frame_type = p.steering_wheel ? SIO_PAD_JOGCON : (eff_analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL); const bool suppress_stick = (eff_analog != 0) || p.steering_wheel; uint16_t btn = pad_buttons_for(p, player, suppress_stick); if (!eff_analog && !p.steering_wheel) { st[0] = st[1] = st[2] = st[3] = 0x80; } out->buttons = btn; out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; out->analog = (uint8_t)frame_type; out->connected = 1; return 1; } static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { psx_pad_apply_to_sio(s, &pad); /* Solo resim self-check records exactly what was applied this boundary. */ psx_selfcheck_note_pad(s, pad.buttons, pad.lx, pad.ly, pad.rx, pad.ry, pad.analog); } /* Raw SDL Start face-button (ignores remaps) — pad-trace only. */ static int netplay_sdl_start_held(int card) { if (card < 0 || card >= PSX_MAX_PLAYERS) return 0; const PlayerInput& p = g_players[card]; if (p.kind == 2 && p.handle) return SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_START) != 0; if (p.kind == 1) { /* Keyboard: Start bit in keybinds pad word (active-low clear = pressed). */ return (pad_from_keyboard(card + 1) & 0x0008u) == 0; } return 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 (lobby seat → sim P1/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 >= PSX_MAX_PLAYERS) idx = 0; /* Present as the lobby seat (multitap taps → digital), not the host card. */ const int seat = psx_netplay_local_slot(); const int present = (seat >= 0) ? seat : idx; int card = idx; int fallback = 0; auto bisect_cap = [&](int c, int fb) { if (!psx_start_bisect_enabled()) return; const int sdl = netplay_sdl_start_held(c); const int cap = ((uint16_t)(~out->buttons) & 0x0008u) != 0; psx_start_bisect_log("cap", psx_netplay_sim_tick(), sdl, cap, -1, -1, 0, psx_netplay_rb_tip_holding(), psx_netplay_is_resimulating()); (void)fb; }; if (capture_pad_slot_exclusive(idx, out, present)) { out->connected = 1; psx_netplay_normalize_pad(out); psx_netplay_pad_trace_dev(card, fallback, netplay_sdl_start_held(card), out->buttons); bisect_cap(card, fallback); return; } /* Fallbacks: NETPLAY/P1 card, lobby seat card, then any assigned device. */ if (idx != 0 && capture_pad_slot_exclusive(0, out, present)) { out->connected = 1; psx_netplay_normalize_pad(out); card = 0; fallback = 1; psx_netplay_pad_trace_dev(card, fallback, netplay_sdl_start_held(card), out->buttons); bisect_cap(card, fallback); return; } if (seat >= 0 && seat < PSX_MAX_PLAYERS && seat != idx && seat != 0 && capture_pad_slot_exclusive(seat, out, present)) { out->connected = 1; psx_netplay_normalize_pad(out); card = seat; fallback = 1; psx_netplay_pad_trace_dev(card, fallback, netplay_sdl_start_held(card), out->buttons); bisect_cap(card, fallback); return; } for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { if (s == idx || s == 0 || s == seat) continue; if (capture_pad_slot_exclusive(s, out, present)) { out->connected = 1; psx_netplay_normalize_pad(out); card = s; fallback = 1; psx_netplay_pad_trace_dev(card, fallback, netplay_sdl_start_held(card), out->buttons); bisect_cap(card, fallback); return; } } out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; out->analog = 1; out->connected = 1; psx_netplay_normalize_pad(out); psx_netplay_pad_trace_dev(idx, 1, netplay_sdl_start_held(idx), out->buttons); bisect_cap(idx, 1); } /* 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 = effective_player_mode(p); else if (dev_any_input_enabled()) mode = (int)PSXRecompV4::PAD_MODE_ANALOG; else mode = p.mode; const int effective_mode = controller_policy_resolve_override_mode( 0, 1, mode, w, st, stick_live, dpad_live); int eff_analog = effective_mode == (int)PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; #ifndef PSX_NO_DEBUG_TOOLS const int debug_type = debug_server_get_pad_type_override(); if (debug_type == SIO_PAD_DUALSHOCK || debug_type == SIO_PAD_JOGCON) eff_analog = 1; #endif int frame_type = p.steering_wheel ? SIO_PAD_JOGCON : (eff_analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL); #ifndef PSX_NO_DEBUG_TOOLS if (debug_type >= SIO_PAD_DIGITAL && debug_type <= SIO_PAD_JOGCON) frame_type = debug_type; #endif /* Injected input only; see the note on the sibling fold above. Not * hardware behaviour, retained solely so injection can steer stick-only * games. */ if (eff_analog && effective_mode == (int)PSXRecompV4::PAD_MODE_ANALOG && !stick_live) { if ((uint16_t)(~w & 0x0010u)) st[1] = 0x00; if ((uint16_t)(~w & 0x0040u)) st[1] = 0xFF; if ((uint16_t)(~w & 0x0080u)) st[0] = 0x00; if ((uint16_t)(~w & 0x0020u)) st[0] = 0xFF; } 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 = (uint8_t)frame_type; 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; /* §27: wall-clock gaps between successful presents (player-visible starvation). */ static uint64_t s_present_last_ms = 0; static uint32_t s_present_gaps_ms[128]; static unsigned s_present_gaps_n = 0; /* §74: highest sim tick ever shown on screen. Resim frames above it are new * forward progress (treadmill chain) and present live; at/below is a rewound * re-play and keeps §63 hold-last. Reset when a netplay session starts. */ static uint32_t s_netplay_present_sim_watermark = 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; } static void netplay_note_present(void); static void netplay_hold_last_present_tick(void); static void netplay_note_present(void) { uint64_t now; uint32_t gap; if (!psx_netplay_active() || !netplay_timing_on()) return; now = SDL_GetTicks64(); if (s_present_last_ms != 0ull && now >= s_present_last_ms) { gap = (uint32_t)(now - s_present_last_ms); if (s_present_gaps_n < (unsigned)(sizeof(s_present_gaps_ms) / sizeof(s_present_gaps_ms[0]))) s_present_gaps_ms[s_present_gaps_n++] = gap; } s_present_last_ms = now ? now : 1ull; } static int netplay_local_viewport_slot(void) { if (g_netplay_local_viewport != 1 || !psx_netplay_active()) return -1; if (!gpu_last_frame_vertical_split_screen()) return -1; int slot = psx_netplay_local_port(); return (slot == 0 || slot == 1) ? slot : -1; } static int crop_present_to_netplay_local_viewport(uint32_t* pixels, int* width, int height) { if (!pixels || !width || *width < 2 || height <= 0) return 0; const int slot = netplay_local_viewport_slot(); if (slot < 0) return 0; const int src_w = *width; const int crop_w = src_w / 2; const int crop_x = slot == 0 ? 0 : (src_w - crop_w); for (int y = 0; y < height; ++y) { uint32_t* dst = pixels + (size_t)y * (size_t)crop_w; uint32_t* src = pixels + (size_t)y * (size_t)src_w + crop_x; memmove(dst, src, (size_t)crop_w * sizeof(uint32_t)); } *width = crop_w; return 1; } /* Title-requested local view (psx_netplay_present_local_view): a seat * rectangle of the display this peer presents alone, at 4:3. Wins over the * game.toml vertical-split local viewport. */ static bool netplay_game_local_view(const GpuDisplayInfo& di, uint32_t* x, uint32_t* y, uint32_t* w, uint32_t* h) { if (di.disabled || di.depth24 || di.width == 0 || di.height == 0) return false; return psx_netplay_local_view(di.width, di.height, x, y, w, h) != 0; } /* Crop a staged ARGB frame (pitch src_w) to the scaled local-view rectangle * in place; the result has pitch w * scale. */ static void crop_present_to_game_local_view(uint32_t* pixels, int src_w, int scale, uint32_t x, uint32_t y, uint32_t w, uint32_t h) { const int cx = (int)x * scale, cy = (int)y * scale; const int cw = (int)w * scale, ch = (int)h * scale; for (int row = 0; row < ch; ++row) memmove(pixels + (size_t)row * (size_t)cw, pixels + (size_t)(cy + row) * (size_t)src_w + cx, (size_t)cw * sizeof(uint32_t)); } static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { unsigned n = s_present_gaps_n; unsigned idx; if (p95_out) *p95_out = 0; if (max_out) *max_out = 0; if (n == 0) return; std::sort(s_present_gaps_ms, s_present_gaps_ms + n); idx = (n > 1u) ? (unsigned)((95u * (n - 1u)) / 100u) : 0u; if (p95_out) *p95_out = s_present_gaps_ms[idx]; if (max_out) *max_out = s_present_gaps_ms[n - 1u]; s_present_gaps_n = 0; } /* 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 pad_ext_host_extras(void*, int s, uint32_t* flags, uint32_t* lt, uint32_t* rt); #ifndef PSX_NO_DEBUG_TOOLS extern "C" int debug_server_get_trigger_override(uint32_t* lt, uint32_t* rt); #endif /* Netplay input transform ([[plugin]] netplay = "input"): this player's own * title pad transform runs on its local pad before staging, so the * transformed pad (buttons, sticks, presented type, NeGcon I/II) is what every * peer receives and simulates. Each peer transforms only its own seat. */ static void netplay_transform_local_pad(PsxNetPad* pad, int override_active) { if (!PSXRecompV4::mod_runtime_netplay_input_active() || psx_netplay_is_spectator()) return; const int seat = psx_netplay_local_slot(); if (seat < 0 || seat >= PSX_MAX_PLAYERS) return; PSXModPadFrame f{}; f.struct_size = sizeof f; f.player = (uint32_t)seat; f.buttons = pad->buttons; f.lx = pad->lx; f.ly = pad->ly; f.rx = pad->rx; f.ry = pad->ry; f.type = pad->analog; if (override_active) { #ifndef PSX_NO_DEBUG_TOOLS uint32_t lt = 0, rt = 0; if (debug_server_get_trigger_override(<, &rt)) { f.host_flags = PSX_MOD_PAD_HOST_GAMEPAD | PSX_MOD_PAD_HOST_LT | PSX_MOD_PAD_HOST_RT; f.host_lt = lt; f.host_rt = rt; } #endif } else { const int card = psx_netplay_input_player(); pad_ext_host_extras(nullptr, card >= 0 ? card : 0, &f.host_flags, &f.host_lt, &f.host_rt); } PSXModPadOutput stock{}, o{}; stock.struct_size = sizeof stock; stock.buttons = f.buttons; stock.type = f.type; stock.lx = f.lx; stock.ly = f.ly; stock.rx = f.rx; stock.ry = f.ry; if (!mod_pad_transform_run((uint32_t)seat, &f, &stock, &o)) return; pad->buttons = (uint16_t)o.buttons; pad->analog = (uint8_t)o.type; if (o.type == PSX_MOD_PAD_NEGCON) { pad->lx = (uint8_t)o.lx; pad->ly = (uint8_t)o.negcon_l; pad->rx = (uint8_t)o.negcon_i; pad->ry = (uint8_t)o.negcon_ii; } else if (o.type == PSX_MOD_PAD_DIGITAL) { pad->lx = pad->ly = pad->rx = pad->ry = 0x80; } else { pad->lx = (uint8_t)o.lx; pad->ly = (uint8_t)o.ly; pad->rx = (uint8_t)o.rx; pad->ry = (uint8_t)o.ry; } } /* Admit-stall watchdog budget: PSX_NET_ADMIT_STALL_MS (default 20 s, at * least 2 s). The clock measures time without ANY progress, not time spent * waiting: a peer that is merely slow still moves its input tip forward, and * every advance restarts the clock, so only a match where nobody advances is * ended. */ static uint32_t netplay_admit_stall_ms(void) { static uint32_t ms = 0; if (!ms) { const char *e = std::getenv("PSX_NET_ADMIT_STALL_MS"); long v = e ? std::strtol(e, nullptr, 10) : 0; ms = v >= 2000 && v <= 600000 ? (uint32_t)v : 20000u; } return ms; } static void netplay_barrier_admit(int override) { if (!psx_netplay_active()) return; /* Launcher/game window teardown can leave a queued SDL_QUIT; draining it * prevents an instant soft-return before the first frame. */ SDL_PumpEvents(); SDL_FlushEvent(SDL_QUIT); static int desync_logged = 0; const Uint64 barrier_t0 = SDL_GetTicks64(); /* Rematch session_reboot is asymmetric: the faster peer reaches admit * while the slower is still in BIOS/GL bring-up. Do not arm the 20s * admit-stall / 1.5s silence clocks until RUNNING (HELLO exchanged). * progress_t0 is set on the first RUNNING sample. */ Uint64 progress_t0 = 0; Uint64 last_stall_log_ms = barrier_t0; 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++; } int liveness_rearamed = 0; int progress_lead = 0, progress_lead_valid = 0; freeze_heartbeat_set_paused(1); for (;;) { uint32_t dt = 0, lh = 0, rh = 0; const Uint64 now_ms = SDL_GetTicks64(); const int running = psx_netplay_is_running(); if (running && progress_t0 == 0) progress_t0 = now_ms; if (running) { /* A remote input tip that moved is progress: some peer is * simulating, however slowly (see netplay_admit_stall_ms). */ char tag[8]; uint32_t psim = 0; int plead = 0; psx_netplay_admit_wait_info(tag, sizeof(tag), &psim, &plead); if (progress_lead_valid && plead > progress_lead) progress_t0 = now_ms; if (!progress_lead_valid || plead > progress_lead) progress_lead = plead; progress_lead_valid = 1; } /* Pump before liveness: free-run between vblanks (and tick-0 dig CRCs) * does not call poll_admit, so last_peer_rx can age past 1.5s while * peer FRAME_COMMIT/INPUT sit in the UDP socket. Checking disconnect * first false-killed rematch right after enter-guest. */ psx_netplay_pump(); if (!liveness_rearamed) { psx_netplay_touch_peer_liveness(); liveness_rearamed = 1; } /* 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. Same BYE-only policy while LINKING so a rematch * peer still booting cannot be false-disconnected at 15s. */ if (psx_netplay_peer_disconnected( (psx_netplay_in_load_barrier() || !running) ? 0u : psx_netplay_running_liveness_timeout_ms())) { netplay_soft_exit("netplay_peer_disconnect"); if (psx_return_to_lobby_requested()) goto done; } /* Both boot digests known and different: the peers booted differently * (BIOS image, boot settings) and the dig0 gate would hold until the * 20 s admit-stall watchdog. End it after a short grace, with its own * reason. */ if (netplay_boot_mismatch_final(psx_netplay_rb_boot_dig0_mismatch_since_ms(), (uint32_t)psx_host_mono_ms(), NETPLAY_BOOT_MISMATCH_GRACE_MS)) { std::fprintf(stderr, "psxrecomp: netplay boot digest mismatch held %u ms — " "returning to lobby\n", (unsigned)NETPLAY_BOOT_MISMATCH_GRACE_MS); netplay_soft_exit("netplay_boot_mismatch"); if (psx_return_to_lobby_requested()) goto done; } /* Staged .pst rejected (stale codegen / BIOS / missing) — do not wait * out the 90s load barrier with stall=load_apply_done. */ if (psx_netplay_consume_load_apply_failed()) { netplay_soft_exit("netplay_load_failed"); if (psx_return_to_lobby_requested()) goto done; } /* Mutual INPUT/CONFIRM stall still refreshes last_peer_rx — detect * "no sim progress" separately (common rematch + TURN loss mode). * Save/load/memcard probe+chunk xfer uses a longer budget (TURN + * ~1.4MB .pst). The old 20s admit timeout killed SAVE mid-transfer. * Link phase (not RUNNING yet) uses its own 90s backstop so a dead * peer still returns to lobby without blaming admit progress. */ if (psx_netplay_in_load_barrier() && now_ms - barrier_t0 >= 90000u) { char stall[96]; uint32_t sim = 0; int lead = 0; psx_netplay_admit_wait_info(stall, sizeof(stall), &sim, &lead); std::fprintf(stderr, "psxrecomp: netplay state barrier timeout sim=%u " "stall=%s lead=%d — returning to lobby\n", (unsigned)sim, stall[0] ? stall : "?", lead); netplay_soft_exit("netplay_load_stall"); if (psx_return_to_lobby_requested()) goto done; } else if (!psx_netplay_in_load_barrier() && !running && now_ms - barrier_t0 >= 90000u) { char stall[64]; uint32_t sim = 0; int lead = 0; psx_netplay_admit_wait_info(stall, sizeof(stall), &sim, &lead); std::fprintf(stderr, "psxrecomp: netplay link stall timeout sim=%u " "stall=%s lead=%d — returning to lobby\n", (unsigned)sim, stall[0] ? stall : "?", lead); netplay_soft_exit("netplay_link_stall"); if (psx_return_to_lobby_requested()) goto done; } else if (!psx_netplay_in_load_barrier() && running && progress_t0 != 0 && now_ms - progress_t0 >= netplay_admit_stall_ms()) { char stall[64]; uint32_t sim = 0; int lead = 0; psx_netplay_admit_wait_info(stall, sizeof(stall), &sim, &lead); std::fprintf(stderr, "psxrecomp: netplay admit stall timeout sim=%u " "stall=%s lead=%d — returning to lobby\n", (unsigned)sim, stall[0] ? stall : "?", lead); netplay_soft_exit("netplay_admit_stall"); if (psx_return_to_lobby_requested()) goto done; } else if (now_ms - last_stall_log_ms >= 2000u) { char stall[96]; uint32_t sim = 0; int lead = 0; const Uint64 clock0 = running && progress_t0 ? progress_t0 : barrier_t0; psx_netplay_admit_wait_info(stall, sizeof(stall), &sim, &lead); std::fprintf(stderr, "psxrecomp: netplay admit waiting sim=%u stall=%s " "lead=%d (%llums%s)\n", (unsigned)sim, stall[0] ? stall : "?", lead, (unsigned long long)(now_ms - clock0), running ? "" : ", linking"); last_stall_log_ms = now_ms; } 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; } /* §36: TipHold parks sim, so needs_local_sample stays 0 after latch. * Still capture every spin so live_pad_buttons can see a real release * (SAFETY deferred must not ride ABSOLUTE 2000ms on a frozen peek). * PSX_START_BISECT_NO_TIPHOLD_CAPTURE=1: only sample when latching a * new sim tip (Stage-3 isolate TipHold refresh). */ const int tip_hold = psx_netplay_rb_tip_holding(); const int need_sample = psx_netplay_needs_local_sample() || (tip_hold && !psx_start_bisect_no_tiphold_capture()); if (need_sample) { PsxNetPad local{}; /* An explicit debug-server override is the local player's input * windowed or headless: headless LAN peers are how netplay is * exercised without windows. With no override, headless has no * human device and plays a neutral pad -- unless the title opted * into content negotiation, whose headless fixtures use the same * negotiated pad mode and local-to-session routing as windowed * injected input. */ if (override >= 0 || (g_headless && g_netplay_content_negotiation)) { capture_override_pad(override >= 0 ? override : 0xFFFF, &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); } netplay_transform_local_pad(&local, override >= 0); psx_netplay_stage_local(&local); } else if (psx_start_bisect_spin_log() && !g_headless) { /* Dense SDL-only samples while admit waits without capture. */ const int card = psx_netplay_input_player(); const int sdl = netplay_sdl_start_held(card >= 0 ? card : 0); psx_start_bisect_log("spin", psx_netplay_sim_tick(), sdl, -1, -1, -1, tip_hold ? 0 : 1, tip_hold, psx_netplay_is_resimulating()); } 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; } goto done; } /* Episode snap may have been applied during pump/try_admit without * longjmp — flush here (no present-body C++ RAII) before spinning. * try_admit refuses to arm needs_advance while resume is pending. */ freeze_heartbeat_set_paused(0); psx_netplay_rb_flush_resume(); freeze_heartbeat_set_paused(1); #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()) goto done; } if (ev.type == SDL_KEYDOWN) { #if defined(PSX_SDL3) const SDL_Keycode key = ev.key.key; #else const SDL_Keycode key = ev.key.keysym.sym; #endif if (key == SDLK_ESCAPE) { netplay_soft_exit("netplay_barrier_escape"); if (psx_return_to_lobby_requested()) goto done; } } if (ev.type == SDL_CONTROLLERDEVICEADDED || ev.type == SDL_CONTROLLERDEVICEREMOVED) { refresh_player_devices(); } } /* Stage-3: PSX_START_BISECT_NO_GC_UPDATE_IN_ADMIT skips the dense * Update that advances SDL pad state between sim publishes. */ if (!psx_start_bisect_no_gc_update_in_admit()) SDL_GameControllerUpdate(); } if (psx_return_to_lobby_requested()) goto done; /* §35: TipHold invent-cap stall freezes guest (no vblank present). * Keep Swap alive on the last Live frame so SAFETY/held waits do not * open a ~250ms present gap. * §100: MEDIA-KF probe/xfer (~3.7 MB) also parks sim in admit — hold * last FMV frame so intro does not freeze for the transfer window. */ if (psx_netplay_rb_tip_holding() || psx_netplay_rb_media_kf_busy() || (psx_netplay_rb_fmv_media_active() && psx_netplay_rb_active() && !psx_netplay_rb_is_resimulating())) netplay_hold_last_present_tick(); /* 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(); } done: freeze_heartbeat_set_paused(0); } /* Main-thread implementation of the PadExtHooks seams (see * pad_external_input.h for the resolution order and precedence rule). */ static int pad_ext_guard_active(void*) { return savestate_input_guard_active(); } static int pad_ext_capture_local(void*, int s, PsxNetPad* out, int guarded) { return capture_pad_slot(s, out, guarded != 0); } static int pad_ext_source_sample(void*, int s, PSXModControllerState* st, int* released) { return mod_controller_source_sample((uint32_t)s, st, released); } static void pad_ext_source_release(void*, int s, PsxNetPad* pad) { /* Source detached/reset: deliver one neutral frame (even on a port with no * device) and give the port back its real SIO connection/config state; a * device resumes next frame. */ const bool dev_here = (dev_any_input_enabled() && s == 0); const int boot_mode = assert_sio_pad_profile(s, dev_here); pad->buttons = 0xffff; pad->lx = pad->ly = pad->rx = pad->ry = 128; pad->analog = pad_mode_boot_analog(boot_mode) ? 1 : 0; pad->connected = 1; } static void pad_ext_source_resolve(void*, int s, const PSXModControllerState* source, int guarded, PsxNetPad* pad) { const bool dev_here = (dev_any_input_enabled() && s == 0); pad->lx = (uint8_t)source->lx; pad->ly = (uint8_t)source->ly; pad->rx = (uint8_t)source->rx; pad->ry = (uint8_t)source->ry; pad->connected = 1; /* Presentation goes through the same resolution as a physical pad: the * source's `analog` is the device capability, then mod mode override, * multitap rule and presentation policy. */ const PlayerInput& p = g_players[s]; int mode = effective_player_mode_for_sio(p, s); if (g_mod_controller_mode_override[s] >= 0 && !(sio_pad_on_multitap(s) && !sio_get_multitap_analog())) mode = g_mod_controller_mode_override[s]; if (!source->analog) mode = PSXRecompV4::PAD_MODE_DIGITAL; uint8_t st[4] = { pad->lx, pad->ly, pad->rx, pad->ry }; int eff_mode = controller_policy_resolve_mode( s, s + 1, mode, pad_sources_for(p, dev_here), p, pad->buttons, st); if (!source->analog || (sio_pad_on_multitap(s) && !sio_get_multitap_analog())) eff_mode = PSXRecompV4::PAD_MODE_DIGITAL; pad->analog = eff_mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; if (!pad->analog) pad->lx = pad->ly = pad->rx = pad->ry = 128; if (guarded) { pad->buttons = 0xffff; pad->lx = pad->ly = pad->rx = pad->ry = 128; } /* The source occupies the port; config capability follows the same rule * as a physical pad (apply_pad_slot_to_sio and the SIO layer keep multitap * taps digital). */ const ModControllerPresentationPolicy& policy = g_mod_controller_policy[s]; sio_set_pad_connected(s, 1); sio_set_pad_config_capable( s, policy.callback ? policy.config_capable : mode != PSXRecompV4::PAD_MODE_DIGITAL); } static void pad_ext_mouse_reset(void*) { psx_local_mouse_reset(); } /* Host extras of port s for the title transform: the assigned gamepad and its * analog triggers (SDL 0..32767 -> 0..255, unthresholded). */ static void pad_ext_host_extras(void*, int s, uint32_t* flags, uint32_t* lt, uint32_t* rt) { *flags = *lt = *rt = 0; if (s < 0 || s >= PSX_MAX_PLAYERS) return; if (s == 0) { uint16_t word; uint8_t st[4], hl, hr; if (host_pad_layer(&word, st, &hl, &hr)) { *flags = PSX_MOD_PAD_HOST_GAMEPAD | PSX_MOD_PAD_HOST_LT | PSX_MOD_PAD_HOST_RT; *lt = hl; *rt = hr; return; } } SDL_GameController* handle = g_players[s].handle; if (g_players[s].kind != 2 || !handle) return; *flags = PSX_MOD_PAD_HOST_GAMEPAD; if (SDL_GameControllerHasAxis(handle, SDL_CONTROLLER_AXIS_TRIGGERLEFT)) { *flags |= PSX_MOD_PAD_HOST_LT; *lt = psx_trigger_axis_to_u8( SDL_GameControllerGetAxis(handle, SDL_CONTROLLER_AXIS_TRIGGERLEFT)); } if (SDL_GameControllerHasAxis(handle, SDL_CONTROLLER_AXIS_TRIGGERRIGHT)) { *flags |= PSX_MOD_PAD_HOST_RT; *rt = psx_trigger_axis_to_u8( SDL_GameControllerGetAxis(handle, SDL_CONTROLLER_AXIS_TRIGGERRIGHT)); } } /* (1) Host pad of port s before presentation, for the transform frame. */ static int pad_ext_host_pad(void*, int s, uint16_t* buttons, uint8_t st[4]) { PsxNetPad pad; if (s < 0 || s >= PSX_MAX_PLAYERS) return 0; if (!capture_pad_slot_view(s, &pad, false, true)) return 0; *buttons = pad.buttons; st[0] = pad.lx; st[1] = pad.ly; st[2] = pad.rx; st[3] = pad.ry; return 1; } static int pad_ext_pad_transform(void*, int s, const PSXModPadFrame* frame, const PSXModPadOutput* stock, PSXModPadOutput* out) { return mod_pad_transform_run((uint32_t)s, frame, stock, out); } static void pad_ext_mouse_fold(void*, int connected, int analog, uint16_t buttons, uint8_t* rx, uint8_t* ry) { psx_local_mouse_pad(connected != 0, analog != 0, buttons, *rx, *ry); } static PadExtHooks pad_ext_main_hooks(void) { PadExtHooks h{}; h.guard_active = pad_ext_guard_active; h.capture_local = pad_ext_capture_local; h.source_sample = pad_ext_source_sample; h.source_release = pad_ext_source_release; h.source_resolve = pad_ext_source_resolve; h.mouse_reset = pad_ext_mouse_reset; h.mouse_fold = pad_ext_mouse_fold; h.host_extras = pad_ext_host_extras; h.pad_transform = pad_ext_pad_transform; h.host_pad = pad_ext_host_pad; return h; } static void sample_pad_into_sio(int override) { /* Selfcheck fighter mash owns P1 when enabled (headless-safe). */ if (override < 0) { uint16_t mash = 0xFFFFu; if (psx_selfcheck_mash_override(&mash)) override = (int)mash; } if (override >= 0) { psx_local_mouse_reset(); apply_input_override_to_sio(override); return; } int n = g_offline_pad_count; if (n < 1) n = 1; if (n > PSX_MAX_PLAYERS) n = PSX_MAX_PLAYERS; const uint32_t consumer_sim = psx_start_consumer_enabled() ? psx_start_consumer_offline_frame() : 0u; PadExtHooks hooks = pad_ext_main_hooks(); for (int s = 0; s < n; s++) { PsxNetPad pad; if (!pad_ext_resolve(&hooks, s, &pad)) continue; /* no device in this port */ /* Push sticks every frame; request the pad type (digital/analog) through * the coherent channel so a policy switch 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 reads. */ apply_pad_slot_to_sio(s, pad); if (psx_start_consumer_enabled()) psx_start_consumer_note(s, consumer_sim, pad.buttons); if (psx_start_bisect_enabled() && s == 0) { const int sdl = netplay_sdl_start_held(s); const int cap = ((uint16_t)(~pad.buttons) & 0x0008u) != 0; const int sio = ((uint16_t)(~sio_get_pad_buttons_slot(s)) & 0x0008u) != 0; psx_start_bisect_log("offline", consumer_sim, sdl, cap, cap, sio, 1, 0, 0); } } } static void sample_headless_pad_into_sio(int override) { if (override < 0) { uint16_t mash = 0xFFFFu; if (psx_selfcheck_mash_override(&mash)) override = (int)mash; } if (override >= 0) { apply_input_override_to_sio(override); return; } if (host_pad_layer(nullptr, nullptr, nullptr, nullptr)) { /* Debug host layer: P1 goes through the full offline resolution * (source, title transform) exactly as a windowed gamepad would. */ PadExtHooks hooks = pad_ext_main_hooks(); PsxNetPad pad; if (pad_ext_resolve(&hooks, 0, &pad)) apply_pad_slot_to_sio(0, pad); sio_set_pad_state_slot(1, 0xFFFFu); 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 when the panel is ~60 Hz. Driver vsync and this pacer * are XOR (see present_vsync_owns_cadence): both at once double-blocks on * Linux compositors to 30 Hz. See g_frame_period_ms. */ /* Live pacer period (ms). Defaults to the PSX rate; set to the host refresh * period when the panel is within ~2% of 60 Hz. Left at the PSX rate on * non-~60Hz / unknown-refresh panels so vsync cannot run the sim fast. * Declared with the early video/mod globals. */ /* §33/§35/§47: re-present last Live frame on a wall-clock cadence while guest * sim is frozen (short resim) or TipHold invent-cap stall (admit spin). */ static void netplay_hold_last_present_tick(void) { static uint64_t s_hold_last_ms; uint64_t now = SDL_GetTicks64(); uint32_t period = (uint32_t)(g_frame_period_ms + 0.5); int did = 0; if (period < 8u) period = 8u; if (period > 33u) period = 33u; #ifndef PSX_SDL_NO_RENDER if (g_gl_active) gl_renderer_set_interpolation_suspended(1); #endif if (s_hold_last_ms != 0ull && now >= s_hold_last_ms && (uint32_t)(now - s_hold_last_ms) < period) return; #ifndef PSX_SDL_NO_RENDER if (g_gl_active) { did = gl_renderer_present_hold_last(); } else if (!g_vk_active && sdl_renderer && sdl_texture && s_sw_hold_valid) { SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); SDL_RenderClear(sdl_renderer); SDL_RenderCopy(sdl_renderer, sdl_texture, &s_sw_hold_src, &s_sw_hold_dst); host_osd_draw_sdl(sdl_renderer); SDL_RenderPresent(sdl_renderer); did = 1; } #endif if (did) { netplay_note_present(); s_hold_last_ms = now ? now : 1ull; } } /* §47/§51/§63 Layer 4: long Replay catch-up — wall-clock gate for a real VRAM * present. Independent of SPAN chunk boundaries (those are bookkeeping, not * present events). Returns 1 if this guest vblank should fall through. * * §63: tip episodes are ≤ tip_runway (24). Live-presenting those frames * re-showed edge-trigger menu inputs that Live had already displayed before * the rewind (pad-log DUP_SIO Start/dpad). Hold-last through a full SPAN; * only ownership-chain catch-up deeper than that shows live progress. */ static int netplay_replay_catchup_should_live_present(uint32_t remaining) { static uint64_t s_catchup_present_ms; uint64_t now = SDL_GetTicks64(); /* ~6–7 Hz: enough to show progress without flashing every resim frame. */ uint32_t period = 150u; if (remaining <= 24u) return 0; if (s_catchup_present_ms != 0ull && now >= s_catchup_present_ms && (uint32_t)(now - s_catchup_present_ms) < period) return 0; s_catchup_present_ms = now ? now : 1ull; { static uint32_t s_log_sim; uint32_t sim = psx_netplay_sim_tick(); if (s_log_sim != sim) { fprintf(stderr, "psxrecomp: rb catchup frontier_rem=%u sim=%u target=%u " "present=live\n", (unsigned)remaining, (unsigned)sim, (unsigned)psx_netplay_rb_episode_target()); fflush(stderr); s_log_sim = sim; } } return 1; } /* ── 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 /* __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; } /* Tick depth24 cutover state once per present. Arm a short full-frame blank * when MDEC returns after leaving depth24, or after a *long* intra-depth24 * idle (real inter-movie gap) — not after every short gap between ~15fps * MDEC frames. With SIMD IDCT a movie frame often finishes in one vblank; * treating !mdec_recently_active(3) as a cutover re-armed a 2-present full * black blank on every decode burst (BPE FMV flicker). Do not regress to * short-gap arming for MotK merges — MotK cutovers still arm via depth24 * exit and long idle. */ static void depth24_cutover_tick(int depth24) { const int mdec_on = depth24 && mdec_recently_active(3); /* ~0.5s @60Hz — longer than inter-frame MDEC idle in a live movie. */ const int mdec_long_idle = depth24 && !mdec_recently_active(30); if (!depth24) { s_d24_prev_mdec = 0; s_d24_cutover_blank = 0; s_d24_saw_gap = 1; /* next depth24+MDEC is a fresh movie cutover */ return; } if (mdec_long_idle) s_d24_saw_gap = 1; if (s_d24_saw_gap && mdec_on && !s_d24_prev_mdec) { /* Hide the transitional present(s) that still show stale RGB888 junk. */ s_d24_cutover_blank = 2; s_d24_saw_gap = 0; } s_d24_prev_mdec = mdec_on; } /* Depth24 FMV: CRTC width stays full (e.g. MotK 512) while MDEC uploads may * not cover the right side yet — leftover VRAM read as RGB888 flashes junk. * Present width is never shrunk; black-fill only the trailing uncovered cols. * * Match origin/master's span policy: when upload span is unknown (lim==0), * only blank the last ~8 columns. Blanking [0..w) on lim==0 (an older tip * path) turned MotK FMV into a permanent black screen after GP1(07h) hold * reset the span — hold ticks skip Swap, then the next present saw lim=0 * and wiped the whole frame every vblank. * * Optional short cutover blank: full-frame black for 1–2 presents on movie * start / long idle resume only (see depth24_cutover_tick). */ 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; if (s_d24_cutover_blank > 0) { s_d24_cutover_blank--; const uint32_t n = w * h; for (uint32_t i = 0; i < n; i++) buf[i] = 0xFF000000u; return; } /* Default: last 8 columns. If the upload span is known and ends earlier * inside that margin, start blanking from the span edge instead. */ const uint32_t margin = (uint32_t)g_video_depth24_trailing_margin; if (margin == 0u || margin >= w) return; uint32_t start = w - margin; 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; } } /* Compose visible depth24 rows into the PAL/NTSC active canvas. Decode only * the GP1(07h) intersection with active video. This preserves every visible * source row and represents off-screen rows as clipping, not as an in-place * shift that discards additional FMV content. */ static void depth24_stage_scanout(const GpuDisplayInfo *di, uint32_t *buf, uint32_t w) { if (!di || !buf || w == 0u || di->screen_height == 0u) return; const size_t count = (size_t)w * di->screen_height; for (size_t i = 0; i < count; i++) buf[i] = 0xFF000000u; if (di->screen_origin_y >= di->screen_height) return; uint32_t rows = di->height; if (rows > di->screen_height - di->screen_origin_y) rows = di->screen_height - di->screen_origin_y; uint32_t *source = buf + (size_t)di->screen_origin_y * w; for (uint32_t y = 0; y < rows; y++) gpu_depth24_present_row(di, di->screen_source_skip_y + y, source + (size_t)y * w, w); depth24_fix_trailing_margin(source, w, rows, di->display_x); } enum { PSX_ASSIST_BIND_REWIND = 0, PSX_ASSIST_BIND_SAVE_STATE_MENU, PSX_ASSIST_BIND_FAST_FORWARD, /* hold-to-fast-forward; pad twin of [KeyMap] Turbo */ PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE, /* press-to-latch; pad twin of [KeyMap] TurboToggle */ PSX_ASSIST_BIND_COUNT }; #define PSX_HOTKEY_PAD_BUTTON(code) (1 + (int)(code)) #define PSX_HOTKEY_PAD_BUTTON_COMBO(mask) (1000 + (int)(mask)) #define PSX_HOTKEY_PAD_IS_BUTTON(value) ((value) > 0 && (value) < 100) #define PSX_HOTKEY_PAD_IS_AXIS(value) ((value) >= 100 && (value) < 1000) #define PSX_HOTKEY_PAD_IS_BUTTON_COMBO(value) ((value) >= 1000) #define PSX_HOTKEY_PAD_BUTTON_CODE(value) ((value) - 1) #define PSX_HOTKEY_PAD_AXIS_CODE(value) (((value) - 100) / 2) #define PSX_HOTKEY_PAD_AXIS_POSITIVE(value) (((value) - 100) & 1) #define PSX_HOTKEY_PAD_BUTTON_COMBO_MASK(value) ((value) - 1000) #define PSX_HOTKEY_PAD_SELECT_R3 \ PSX_HOTKEY_PAD_BUTTON_COMBO(((uint32_t)1u << SDL_CONTROLLER_BUTTON_BACK) | \ ((uint32_t)1u << SDL_CONTROLLER_BUTTON_RIGHTSTICK)) #define PSX_HOTKEY_PAD_SELECT_R1 \ PSX_HOTKEY_PAD_BUTTON_COMBO(((uint32_t)1u << SDL_CONTROLLER_BUTTON_BACK) | \ ((uint32_t)1u << SDL_CONTROLLER_BUTTON_RIGHTSHOULDER)) #define PSX_HOTKEY_PAD_SELECT_L1 \ PSX_HOTKEY_PAD_BUTTON_COMBO(((uint32_t)1u << SDL_CONTROLLER_BUTTON_BACK) | \ ((uint32_t)1u << SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) static int normalize_hotkey_pad_binding(int binding, int fallback) { if (PSX_HOTKEY_PAD_IS_BUTTON(binding)) { int code = PSX_HOTKEY_PAD_BUTTON_CODE(binding); if (code >= 0 && code < SDL_CONTROLLER_BUTTON_MAX) return binding; } else if (PSX_HOTKEY_PAD_IS_BUTTON_COMBO(binding)) { int mask = PSX_HOTKEY_PAD_BUTTON_COMBO_MASK(binding); if (mask > 0) return binding; } else if (PSX_HOTKEY_PAD_IS_AXIS(binding)) { int code = PSX_HOTKEY_PAD_AXIS_CODE(binding); if (code >= 0 && code < SDL_CONTROLLER_AXIS_MAX) return binding; } else if (binding == 0) { return 0; } return fallback; } /* P1 host controller state for shortcut polling: the debug host layer while * armed, else P1's assigned SDL gamepad. */ static int p1_host_button_down(void*, int code) { uint16_t word; uint8_t st[4], lt, rt; if (host_pad_layer(&word, st, <, &rt)) return (host_layer_sdl_held(word) >> code) & 1u; SDL_GameController* h = g_players[0].handle; if (!h || code < 0 || code >= SDL_CONTROLLER_BUTTON_MAX) return 0; return SDL_GameControllerGetButton(h, (SDL_GameControllerButton)code) != 0; } static int p1_host_axis_value(void*, int axis) { uint16_t word; uint8_t st[4], lt, rt; if (host_pad_layer(&word, st, <, &rt)) { switch (axis) { case SDL_CONTROLLER_AXIS_LEFTX: return ((int)st[0] - 0x80) * 256; case SDL_CONTROLLER_AXIS_LEFTY: return ((int)st[1] - 0x80) * 256; case SDL_CONTROLLER_AXIS_RIGHTX: return ((int)st[2] - 0x80) * 256; case SDL_CONTROLLER_AXIS_RIGHTY: return ((int)st[3] - 0x80) * 256; case SDL_CONTROLLER_AXIS_TRIGGERLEFT: return (int)lt * 128; case SDL_CONTROLLER_AXIS_TRIGGERRIGHT: return (int)rt * 128; default: return 0; } } SDL_GameController* h = g_players[0].handle; if (!h || axis < 0 || axis >= SDL_CONTROLLER_AXIS_MAX) return 0; return SDL_GameControllerGetAxis(h, (SDL_GameControllerAxis)axis); } static int p1_host_present(void) { uint16_t word; uint8_t st[4], lt, rt; return host_pad_layer(&word, st, <, &rt) || g_players[0].handle; } /* Whether the title's direct allowance for `shortcut` is in force. Rewind's * needs Rewind enabled and not blocked by the title; otherwise its button is * neither claimed nor a one-button shortcut and reaches the guest pad. */ static int direct_shortcut_active(int shortcut) { if (shortcut < 0 || shortcut >= 32) return 0; return psx_hotkey_direct_active( g_direct_shortcut_allowed, shortcut, shortcut == PSX_ASSIST_BIND_REWIND, g_rewind_enabled, psx_rewind_title_blocked()); } /* Whether host shortcut `shortcut` (PSX_ASSIST_BIND_*) bound to `binding` is * held. A one-button combination is direct only while the title allows that * shortcut; otherwise it is Select + button, like the legacy encoding. */ static int hotkey_shortcut_down(int shortcut, int binding) { if (!p1_host_present() || binding == 0) return 0; const int direct = direct_shortcut_active(shortcut); return psx_hotkey_pad_down(binding, direct, SDL_CONTROLLER_BUTTON_BACK, p1_host_button_down, p1_host_axis_value, nullptr); } /* Per frame, before P1 is sampled: the SDL buttons claimed by allowed direct * shortcuts leave the guest pad (claimed, and through release once claimed * while held). Rewind claims its button only while Rewind is enabled and the * title has not blocked it. */ static void direct_shortcut_claim_update(void) { const int bindings[PSX_ASSIST_BIND_COUNT] = { g_hotkey_pad_rewind, g_hotkey_pad_save_state_menu, g_hotkey_pad_fast_forward, g_hotkey_pad_fast_forward_toggle, }; uint32_t claimed = 0, held = 0; for (int i = 0; i < PSX_ASSIST_BIND_COUNT; ++i) { if (!direct_shortcut_active(i)) continue; const int b = psx_hotkey_pad_single_button(bindings[i]); if (b >= 0) claimed |= 1u << b; } for (int code = 0; code < SDL_CONTROLLER_BUTTON_MAX && code < 32; ++code) if (p1_host_button_down(nullptr, code)) held |= 1u << code; g_p1_claimed_buttons = psx_hotkey_claim_update(&g_p1_claim_latched, claimed, held); } static int* hotkey_pad_binding_slot(int shortcut) { switch (shortcut) { case PSX_ASSIST_BIND_REWIND: return &g_hotkey_pad_rewind; case PSX_ASSIST_BIND_SAVE_STATE_MENU: return &g_hotkey_pad_save_state_menu; case PSX_ASSIST_BIND_FAST_FORWARD: return &g_hotkey_pad_fast_forward; case PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE: return &g_hotkey_pad_fast_forward_toggle; default: return nullptr; } } /* game.toml [controller] direct_shortcut(_button): that shortcut's default * becomes the one-button combination (saved settings still win) and the * launcher captures it as one button. */ static void apply_title_direct_shortcut(const std::string& shortcut, const std::string& button) { static const char* const kNames[PSX_ASSIST_BIND_COUNT] = { "rewind", "save_state_menu", "fast_forward", "fast_forward_toggle", }; int index = -1; for (int i = 0; i < PSX_ASSIST_BIND_COUNT; ++i) if (shortcut == kNames[i]) index = i; const int code = (int)SDL_GameControllerGetButtonFromString(button.c_str()); if (index < 0 || code < 0 || code >= SDL_CONTROLLER_BUTTON_MAX || code >= 32) { std::fprintf(stderr, "psxrecomp: [controller] direct_shortcut '%s' / button '%s' " "not recognised; ignored\n", shortcut.c_str(), button.c_str()); return; } g_title_direct_shortcut = index; g_title_direct_button = code; *hotkey_pad_binding_slot(index) = PSX_HOTKEY_PAD_BUTTON_COMBO((uint32_t)1u << code); } static int savestate_menu_open = 0; static int savestate_menu_slot = 0; static int savestate_menu_ignore_toggle_release = 0; static SDL_Keycode savestate_menu_open_key = 0; static void savestate_menu_sync_overlay(void) { psx_savestate_menu_set_state(savestate_menu_open, savestate_menu_slot); } static void savestate_menu_close(void) { savestate_menu_open = 0; savestate_menu_sync_overlay(); host_osd_push("Save states closed", 800); } static void savestate_menu_toggle(SDL_Keycode opened_by_key) { psx_local_mouse_reset(); if (psx_rewind_is_open()) return; if (savestate_menu_open) { savestate_menu_close(); return; } savestate_menu_open = 1; savestate_menu_ignore_toggle_release = 1; savestate_menu_open_key = opened_by_key; savestate_menu_sync_overlay(); } static void savestate_menu_move(int delta) { savestate_menu_slot += delta; while (savestate_menu_slot < 0) savestate_menu_slot += 12; while (savestate_menu_slot >= 12) savestate_menu_slot -= 12; savestate_menu_sync_overlay(); } static int savestate_submit_slot(int slot, int save) { if (!save && !savestate_slot_exists(slot)) { char msg[32]; snprintf(msg, sizeof(msg), "Slot %d is empty", slot + 1); host_osd_push(msg, 1200); return 0; } if (!save) savestate_input_guard_arm(); if (psx_netplay_active()) { if (!psx_netplay_is_host()) { host_osd_push("Save states are host-only in netplay", 1500); return 0; } if (save) return psx_netplay_request_save(slot); else return psx_netplay_request_load(slot); } else if (save) { return savestate_request_save(slot); } else { return savestate_request_load(slot); } } static void savestate_menu_submit(int save) { if (savestate_submit_slot(savestate_menu_slot, save) && savestate_menu_open) { savestate_menu_open = 0; savestate_menu_sync_overlay(); } } static int savestate_menu_slot_from_key(SDL_Keycode key) { if (key >= SDLK_1 && key <= SDLK_9) return (int)(key - SDLK_1); if (key == SDLK_0) return 9; if (key == SDLK_MINUS) return 10; if (key == SDLK_EQUALS) return 11; return -1; } static void savestate_menu_handle_key(SDL_Keycode key, SDL_Scancode scancode, int mod, int repeat) { int slot; if (repeat) return; if (savestate_menu_open_key && key == savestate_menu_open_key) return; slot = savestate_menu_slot_from_key(key); if (slot >= 0) { savestate_menu_slot = slot; savestate_menu_sync_overlay(); return; } if (host_keymap_match_event(HOST_KEYMAP_SAVE_STATE_MENU, (int)key, (int)scancode, mod) || key == SDLK_ESCAPE || key == SDLK_BACKSPACE) { savestate_menu_close(); } else if (key == SDLK_LEFT || key == SDLK_UP) { savestate_menu_move(-1); } else if (key == SDLK_RIGHT || key == SDLK_DOWN) { savestate_menu_move(+1); } else if (key == SDLK_s) { savestate_menu_submit(1); } else if (key == SDLK_l) { savestate_menu_submit(0); } else if (key == SDLK_RETURN || key == SDLK_SPACE) { savestate_menu_submit((mod & KMOD_SHIFT) != 0); } } static void savestate_menu_poll_nav(uint32_t now_ms) { static int prev_load, prev_save, prev_cancel, prev_toggle; static int held_dir, last_step_ms; int prev = 0, next = 0, load = 0, save = 0, cancel = 0; int dir = 0; SDL_GameController *h = g_players[0].handle; if (h) { const Sint16 lx = SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTX); const int dz = 16000; if (SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_DPAD_LEFT) || SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_DPAD_UP) || lx < -dz) prev = 1; if (SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) || SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_DPAD_DOWN) || lx > dz) next = 1; if (SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_A)) load = 1; if (SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_X)) save = 1; if (SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_B)) cancel = 1; } const int toggle = hotkey_shortcut_down(PSX_ASSIST_BIND_SAVE_STATE_MENU, g_hotkey_pad_save_state_menu); if (savestate_menu_ignore_toggle_release) { if (!toggle) savestate_menu_ignore_toggle_release = 0; } else if (toggle && !prev_toggle) { cancel = 1; } prev_toggle = toggle; if (load && !prev_load) savestate_menu_submit(0); if (save && !prev_save) savestate_menu_submit(1); if (cancel && !prev_cancel) savestate_menu_close(); prev_load = load; prev_save = save; prev_cancel = cancel; if (!savestate_menu_open) return; dir = next ? +1 : prev ? -1 : 0; if (!dir) { held_dir = 0; return; } if (dir != held_dir || (int32_t)(now_ms - (uint32_t)last_step_ms) >= 160) { savestate_menu_move(dir); held_dir = dir; last_step_ms = (int)now_ms; } } static int rewind_toggle_buttons_down(void) { return hotkey_shortcut_down(PSX_ASSIST_BIND_REWIND, g_hotkey_pad_rewind); } static void rewind_poll_toggle_buttons(void) { static int was_down; int down = rewind_toggle_buttons_down(); if (down && !was_down && !psx_rewind_is_open()) { psx_local_mouse_reset(); psx_rewind_toggle(); } was_down = down; } static void savestate_menu_poll_toggle_buttons(void) { static int was_down; int down = hotkey_shortcut_down(PSX_ASSIST_BIND_SAVE_STATE_MENU, g_hotkey_pad_save_state_menu); if (down && !was_down && !psx_rewind_is_open()) savestate_menu_toggle(0); was_down = down; } /* Latched fast-forward: [KeyMap] TurboToggle (default F9) or the [hotkeys] * fast_forward_toggle_pad shortcut flips this; while set, the manual * fast-forward block runs exactly as if Turbo were held. Cleared by the next * press, so a hold-to-run Turbo release never cancels a latched run. */ static int g_manual_turbo_latched = 0; static void fast_forward_toggle_flip(void) { char msg[40]; g_manual_turbo_latched = !g_manual_turbo_latched; if (!g_manual_turbo_latched) { host_osd_push("Fast forward: off", 900); return; } const int mult = manual_fast_forward_multiplier(); if (mult < 0) snprintf(msg, sizeof(msg), "Fast forward: max (locked)"); else snprintf(msg, sizeof(msg), "Fast forward: %dx (locked)", mult); host_osd_push(msg, 900); } static void fast_forward_toggle_poll_buttons(void) { static int was_down; int down = hotkey_shortcut_down(PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE, g_hotkey_pad_fast_forward_toggle); if (down && !was_down) fast_forward_toggle_flip(); was_down = down; } static void rewind_poll_nav(uint32_t now_ms) { const Uint8 *keys = SDL_GetKeyboardState(NULL); int left = keys[SDL_SCANCODE_LEFT] ? 1 : 0; int right = keys[SDL_SCANCODE_RIGHT] ? 1 : 0; /* Direct menu keys supplement the configured pad bindings below. Letter * aliases conflict with remaps: X is Cross by default, so treating X as * Cancel sets both edges and silently cancels every keyboard load. */ int acc = (keys[SDL_SCANCODE_RETURN] || keys[SDL_SCANCODE_SPACE]) ? 1 : 0; int can = (keys[SDL_SCANCODE_ESCAPE] || keys[SDL_SCANCODE_BACKSPACE]) ? 1 : 0; /* Honor remapped Cross/Circle (and Select/R3) via the same pad path as * gameplay — GameController A/B alone miss keyboard-as-pad and remaps. */ uint16_t btn = pad_buttons_for(g_players[0], 1, true); { uint16_t host_word; uint8_t host_st[4], host_lt, host_rt; if (host_pad_layer(&host_word, host_st, &host_lt, &host_rt)) btn = host_word; } if ((btn & PAD_LEFT) == 0) left = 1; if ((btn & PAD_RIGHT) == 0) right = 1; if ((btn & PAD_CROSS) == 0) acc = 1; if ((btn & PAD_CIRCLE) == 0 || (btn & PAD_SELECT) == 0 || (btn & PAD_R3) == 0) can = 1; SDL_GameController *h = g_players[0].handle; if (h) { const Sint16 lx = SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTX); const int dz = 16000; if (SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_DPAD_LEFT) || lx < -dz) left = 1; if (SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) || lx > dz) right = 1; if (SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_A)) acc = 1; if (SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_B) || SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_BACK) || SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_RIGHTSTICK)) can = 1; } psx_rewind_nav_held(left, right, acc, can, now_ms); } static void rewind_pause_present(void) { psx_rewind_present_tick((uint32_t)SDL_GetTicks()); #ifndef PSX_SDL_NO_RENDER if (g_gl_active) { gl_renderer_set_interpolation_suspended(1); (void)gl_renderer_present_hold_last(); } else if (g_vk_active) { vk_renderer_present_blank(); } else if (sdl_renderer) { SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); SDL_RenderClear(sdl_renderer); if (sdl_texture && s_sw_hold_valid) SDL_RenderCopy(sdl_renderer, sdl_texture, &s_sw_hold_src, &s_sw_hold_dst); host_osd_draw_sdl(sdl_renderer); SDL_RenderPresent(sdl_renderer); } #endif } /* Freeze guest in vblank present while the rewind filmstrip is open. */ static void rewind_host_pause_loop(void) { psx_local_mouse_begin(sdl_window, false); psx_local_mouse_reset(); freeze_heartbeat_set_paused(1); while (psx_rewind_is_open()) { #ifndef PSX_NO_DEBUG_TOOLS /* The guest is frozen; keep the debug endpoint live so injected * input, screenshots and status reads can drive the filmstrip. */ debug_server_poll(); #endif if (g_headless) { rewind_poll_nav((uint32_t)SDL_GetTicks()); starvation_watchdog_heartbeat(); SDL_Delay(1); continue; } SDL_Event ev; while (SDL_PollEvent(&ev)) { (void)psx_local_mouse_event(ev); if (ev.type == SDL_QUIT) { psx_crash_trace_set_exit_origin("sdl_window_close"); shutdown_runtime(); std::exit(0); } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { refresh_player_devices(); } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { close_controller(); refresh_player_devices(); } else if (ev.type == SDL_KEYDOWN) { #if defined(PSX_SDL3) const SDL_Keymod mod = ev.key.mod; const SDL_Keycode key = ev.key.key; const SDL_Scancode scancode = ev.key.scancode; const int repeat = ev.key.repeat ? 1 : 0; #else const Uint16 mod = ev.key.keysym.mod; const SDL_Keycode key = ev.key.keysym.sym; const SDL_Scancode scancode = ev.key.keysym.scancode; const int repeat = ev.key.repeat ? 1 : 0; #endif if (!repeat && host_keymap_match_event(HOST_KEYMAP_REWIND, (int)key, (int)scancode, (int)mod)) { psx_rewind_toggle(); } } } rewind_poll_nav((uint32_t)SDL_GetTicks()); rewind_pause_present(); starvation_watchdog_heartbeat(); SDL_Delay(8); } freeze_heartbeat_set_paused(0); /* Swallow the still-held close press so it doesn't bleed into the game. */ savestate_input_guard_arm(); } /* Freeze guest in vblank present while the save-state slot menu is open. */ static void savestate_menu_host_pause_loop(void) { psx_local_mouse_begin(sdl_window, false); psx_local_mouse_reset(); freeze_heartbeat_set_paused(1); while (savestate_menu_open) { SDL_Event ev; while (SDL_PollEvent(&ev)) { (void)psx_local_mouse_event(ev); if (ev.type == SDL_QUIT) { psx_crash_trace_set_exit_origin("sdl_window_close"); shutdown_runtime(); std::exit(0); } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { refresh_player_devices(); } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { close_controller(); refresh_player_devices(); } else if (ev.type == SDL_KEYDOWN) { #if defined(PSX_SDL3) const SDL_Keymod mod = ev.key.mod; const SDL_Keycode key = ev.key.key; const SDL_Scancode scancode = ev.key.scancode; const int repeat = ev.key.repeat ? 1 : 0; #else const Uint16 mod = ev.key.keysym.mod; const SDL_Keycode key = ev.key.keysym.sym; const SDL_Scancode scancode = ev.key.keysym.scancode; const int repeat = ev.key.repeat ? 1 : 0; #endif savestate_menu_handle_key(key, scancode, (int)mod, repeat); } else if (ev.type == SDL_KEYUP) { #if defined(PSX_SDL3) const SDL_Keycode key = ev.key.key; #else const SDL_Keycode key = ev.key.keysym.sym; #endif if (savestate_menu_open_key == key) savestate_menu_open_key = 0; } } savestate_menu_poll_nav((uint32_t)SDL_GetTicks()); rewind_pause_present(); starvation_watchdog_heartbeat(); SDL_Delay(8); } freeze_heartbeat_set_paused(0); /* Swallow the close press; a just-queued save must not snapshot it. */ savestate_input_guard_arm(); } /* Epilogue for netplay admit/pace AFTER all C++ RAII in the present body * is destroyed — episode snap load longjmps via psx_netplay_rb_flush_resume and * must not cross non-trivial destructors (UB / guest crash). */ struct NetplayVblankEpilogue { int do_epilogue = 0; int skip_pace = 0; int override = -1; }; /* Called from gpu_vblank_tick() at each simulated vblank. */ #ifndef PSX_NO_DEBUG_TOOLS /* Headless has no present surface, but automated A/B runs still need the * frame-indexed record of what a windowed build would show: the displayed * band of the native-wide surface on a wide game frame, otherwise the 4:3 * display area. Same classification as the windowed present below. */ static void headless_present_image_ring_capture(void) { if (!present_image_ring_accepting()) return; GpuDisplayInfo di; gpu_get_display_info(&di); if (di.disabled || di.width == 0 || di.height == 0 || di.depth24) return; const bool fmv_frame = !g_ws_engaged || gpu_ws_present_native_43() != 0; #ifndef PSX_SDL_NO_RENDER /* Render thread: the same capture, queued at this point of the frame * instead of a per-frame sync point (gl_renderer_ring_capture). */ if (g_headless_opengl && gl_renderer_render_thread_active()) { int slot = netplay_local_viewport_slot(); int cw = slot >= 0 ? (int)di.width / 2 : (int)di.width; int cx = (int)di.display_x + (slot == 1 ? (int)di.width - cw : 0); gl_renderer_ring_capture((uint32_t)s_frame_count, (!fmv_frame && ws_native_wide_active() && gr_wide_supported()) ? 1 : 0, (int)di.display_x, (int)di.display_y, (int)di.height, cx, (int)di.display_y, cw, (int)di.height); return; } #endif static std::vector buf; int w = 0, h = 0; uint32_t vx = 0, vy = 0, vw = di.width, vh = di.height; const bool game_view = netplay_game_local_view(di, &vx, &vy, &vw, &vh); if (!game_view && !fmv_frame && ws_native_wide_active() && gr_wide_supported()) { buf.resize((size_t)1024 * 4 * 512 * 4); if (gr_wide_dump_full(buf.data(), (int)buf.size(), &w, &h, (int)di.display_x) > 0 && h >= 512) { const int s = h / 512; const int y0 = (int)di.display_y * s, rows = (int)di.height * s; if (y0 + rows <= h) { present_image_ring_push_argb((uint32_t)s_frame_count, buf.data() + (size_t)y0 * w, w, rows, w); return; } } } /* Capture GL's real FBO, including the selected projection half, without * feeding readback into canonical netplay VRAM or its state hashes. */ if (g_headless_opengl) { int slot = game_view ? -1 : netplay_local_viewport_slot(); int cw = slot >= 0 ? (int)di.width / 2 : (int)vw; int cx = (int)(di.display_x + vx) + (slot == 1 ? (int)di.width - cw : 0); int cy = (int)(di.display_y + vy); int scale = gr_scale(); w = cw * scale; h = (int)vh * scale; buf.resize((size_t)w * h); if (gl_renderer_capture_display_hires(buf.data(), w * 4, cx, cy, cw, (int)vh) != w * h) return; present_image_ring_push_argb((uint32_t)s_frame_count, buf.data(), w, h, w); return; } w = (int)vw; h = (int)vh; buf.resize((size_t)w * h); for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { const uint16_t p = gpu_vram_peek((int)(di.display_x + vx + x), (int)(di.display_y + vy + y)); buf[(size_t)y * w + x] = 0xFF000000u | ((uint32_t)(p & 31) << 19) | ((uint32_t)((p >> 5) & 31) << 11) | ((uint32_t)((p >> 10) & 31) << 3); } present_image_ring_push_argb((uint32_t)s_frame_count, buf.data(), w, h, w); } #endif // Shared by early and post-pacer sampling: do not PumpEvents without draining // ordered motion/control events and the existing hotkeys before folding binds. static bool drain_host_events() { SDL_Event ev; while (SDL_PollEvent(&ev)) { if (psx_local_mouse_event(ev)) continue; if (ev.type == SDL_QUIT) { if (psx_netplay_active()) { netplay_soft_exit("sdl_window_close"); return false; } 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) { bool ours = false; for (int s = 0; s < PSX_MAX_PLAYERS; s++) { #if defined(PSX_SDL3) if (ev.gdevice.which == g_players[s].instance) { ours = true; break; } #else if (ev.cdevice.which == g_players[s].instance) { ours = true; break; } #endif } if (ours) { close_controller(); refresh_player_devices(); } } else if (ev.type == SDL_KEYDOWN) { #if defined(PSX_SDL3) const SDL_Keymod mod = ev.key.mod; const SDL_Keycode key = ev.key.key; const SDL_Scancode scancode = ev.key.scancode; const int key_repeat = ev.key.repeat ? 1 : 0; #else const Uint16 mod = ev.key.keysym.mod; const SDL_Keycode key = ev.key.keysym.sym; const SDL_Scancode scancode = ev.key.keysym.scancode; const int key_repeat = ev.key.repeat ? 1 : 0; #endif if (key == SDLK_ESCAPE && psx_netplay_active()) { netplay_soft_exit("netplay_escape"); return false; } if (!key_repeat && host_keymap_match_event(HOST_KEYMAP_REWIND, (int)key, (int)scancode, (int)mod)) { psx_local_mouse_reset(); psx_rewind_toggle(); } else if (!key_repeat && host_keymap_match_event(HOST_KEYMAP_SAVE_STATE_MENU, (int)key, (int)scancode, (int)mod)) { psx_local_mouse_reset(); savestate_menu_toggle(key); } else if (key == SDLK_c && (mod & KMOD_CTRL)) { std::fprintf(stdout, "[DEBUG] Forzando reinserción de CD...\n"); debug_force_cd_reinsert(); host_osd_push("CD reinsert", 1500); } else if (!key_repeat && host_keymap_match_event(HOST_KEYMAP_TURBO_TOGGLE, (int)key, (int)scancode, (int)mod)) { fast_forward_toggle_flip(); } else if (!key_repeat && host_keymap_match_event(HOST_KEYMAP_CAPTURE_MARK, (int)key, (int)scancode, (int)mod)) { #ifndef PSX_NO_DEBUG_TOOLS debug_server_capture_mark(); host_osd_push("Capture marked (debug rings frozen)", 2000); #else host_osd_push("Capture mark needs a debug-tools build", 2000); #endif } else if (!key_repeat && host_keymap_match_event(HOST_KEYMAP_DISPLAY_PERF, (int)key, (int)scancode, (int)mod)) { fps_telemetry_toggle(); } else if (!key_repeat && host_keymap_match_event(HOST_KEYMAP_SCANLINES, (int)key, (int)scancode, (int)mod)) { psx_video_set_scanlines(g_video_scanlines ? 0 : 1, g_video_scanline_strength); char msg[48]; std::snprintf(msg, sizeof(msg), "Scanlines %s", g_video_scanlines ? "on" : "off"); host_osd_push(msg, 1200); } /* Host volume: config.ini [KeyMap] VolumeUp/VolumeDown * (defaults: keypad +/-). 5% steps; shows right-side bar. */ else if (host_keymap_match_event(HOST_KEYMAP_VOLUME_UP, (int)key, (int)scancode, (int)mod)) { host_volume_adjust(+5); } else if (host_keymap_match_event(HOST_KEYMAP_VOLUME_DOWN, (int)key, (int)scancode, (int)mod)) { host_volume_adjust(-5); } /* 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). Track the selected live mode: * borderless deliberately leaves SDL's fullscreen bit clear. */ else if (!key_repeat && host_keymap_match_event(HOST_KEYMAP_FULLSCREEN, (int)key, (int)scancode, (int)mod)) { const int target = s_window_fullscreen.mode ? 0 : (g_fullscreen ? g_fullscreen : 1); if (psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, target) == 0) host_osd_push(target ? "Fullscreen" : "Windowed", 1500); else host_osd_push("Fullscreen change failed", 1500); } } } return true; } static bool local_mouse_live(int override_word) { PadExtGate g{}; g.injected_input = override_word >= 0; #ifndef PSX_NO_DEBUG_TOOLS uint8_t axes[4]; g.injected_input = g.injected_input || debug_server_get_axis_override(axes) != 0; #endif g.headless = g_headless; g.netplay_active = psx_netplay_active(); g.netplay_resim = psx_netplay_is_resimulating(); g.selfcheck_locked = psx_selfcheck_input_locked(); g.selfcheck_resim = psx_selfcheck_resim_active(); g.render_pass = g_psx_render_pass_active; g.savestate_menu_open = savestate_menu_open; g.rewind_open = psx_rewind_is_open(); g.input_guard = savestate_input_guard_active(); return pad_ext_live(&g) != 0; } static NetplayVblankEpilogue sdl_vblank_present_body(void) { NetplayVblankEpilogue ep{}; /* Guest quantum for this vblank is complete. Drop top-level-resume armed * by any resume_at (savestate / selfcheck / RB) during that quantum — the * next tick has a live native chain under its dispatch again. */ psx_scheduler_top_level_resume_clear(); int probe_turbo = 0; int probe_reached = 0; struct PostLoadProbeScope { int *turbo; int *reached; ~PostLoadProbeScope() { post_load_probe_on_vblank(*turbo, *reached); } } probe_scope{&probe_turbo, &probe_reached}; #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 psx_local_mouse_begin(sdl_window, local_mouse_live(override)); { /* Outside every debug guard on purpose: production must be measurable. */ extern uint64_t s_frame_count; frame_report_tick(s_frame_count); } 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 (fps_telemetry_enabled() && !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 && s_fps_base_title.empty()) { 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 / guest_video_standard_hz(); double display_fps = 0.0; if (g_frame_interpolation && g_gl_active) { display_fps = g_frame_interpolation_fps > 0 ? (double)g_frame_interpolation_fps : g_host_refresh_hz; } if (!g_headless && sdl_window) { char title[256]; if (display_fps > 0.0) { snprintf(title, sizeof(title), "%s [Game %.0f fps %.2fx | Display %.0f fps]", s_fps_base_title.c_str(), fps, speed, display_fps); } else { snprintf(title, sizeof(title), "%s [Game %.0f fps %.2fx]", s_fps_base_title.c_str(), fps, speed); } SDL_SetWindowTitle(sdl_window, title); } if (!g_headless) { char osd[96]; if (display_fps > 0.0) { snprintf(osd, sizeof(osd), "Game %.0f FPS %.2fx | Display %.0f FPS", fps, speed, display_fps); } else { snprintf(osd, sizeof(osd), "Game %.0f FPS %.2fx", fps, speed); } host_osd_set_status(osd); } 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; /* Fraction of this window's ticks spent resimulating (Replay) * vs running forward normally (Live). Lets a soak confirm * "the game was chain-episoding almost the whole time" (near * 100%) instead of inferring it from raw episode density in * the log — see docs/ROLLBACK_MOTK_HOOKUP.md 2026-08-01. */ const uint64_t replay_ticks = psx_netplay_rb_take_replay_ticks(); const double replay_pct = 100.0 * (double)replay_ticks / (double)s_np_timing_frames; uint32_t gap_p95 = 0, gap_max = 0; /* §27: presentation starvation — eye cares about gaps more * than replay%. Pass: post-settle present_gap_p95 ≤ 33ms. */ netplay_present_gap_stats(&gap_p95, &gap_max); std::fprintf(stderr, "[FPS] game: %.1f fps (%.2fx) | frames: %llu | " "guest=%.2f ms/f admit=%.2f ms/f replay=%.0f%% " "present_gap_p95=%u ms max=%u ms (n=%llu)\n", fps, speed, (unsigned long long)s_frame_count, guest_ms, admit_ms, replay_pct, (unsigned)gap_p95, (unsigned)gap_max, (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); } /* Direct-shortcut claims are settled before any shortcut poll or pad * sample this frame (no-op unless a title allowed a direct shortcut). */ direct_shortcut_claim_update(); if (!g_headless) { /* Pump SDL events to prevent window freeze. */ psx_local_mouse_begin(sdl_window, local_mouse_live(override)); if (!drain_host_events()) return ep; savestate_menu_poll_toggle_buttons(); rewind_poll_toggle_buttons(); fast_forward_toggle_poll_buttons(); psx_rewind_note_frame(); psx_rewind_present_tick((uint32_t)SDL_GetTicks()); if (savestate_menu_open) savestate_menu_host_pause_loop(); if (psx_rewind_is_open()) rewind_host_pause_loop(); } else if (host_pad_layer(nullptr, nullptr, nullptr, nullptr)) { /* Headless with the debug host layer: the host shortcuts that need no * window (Rewind capture/open, fast-forward latch) poll as they would * windowed, so scripted runs can drive them. */ rewind_poll_toggle_buttons(); fast_forward_toggle_poll_buttons(); psx_rewind_note_frame(); if (psx_rewind_is_open()) rewind_host_pause_loop(); } /* 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. * Admit/pace run in sdl_vblank_present() AFTER this body returns so episode * resume longjmp does not cross C++ destructors. Early returns still set * ep.do_epilogue so admit runs every tick (including skip-present paths). * Offline keeps pace-before-present. Barrier wait stays UDP poll — do * not pair this order with SDL_Delay(0) busy-spin (tick-0 hang). */ ep.do_epilogue = psx_netplay_active() != 0 ? 1 : 0; ep.override = override; /* Turbo-active / multitap arming share game-started detection. */ if (psx_netplay_active()) { psx_netplay_finish_frame(); } else { /* Offline N-pad: enable multitap only once the game EXE is running. * Port comes from game.toml [controller] multitap_port (default Port 1; * BPE uses Port 2). Empty tap slots are OK — P1 may be the standalone * pad on the other port. */ if (g_offline_pad_count >= 3 && fntrace_is_game_started() && !sio_get_multitap()) { sio_set_multitap(1); /* Tap seats drop to plain digital unless multitap_analog is on. */ for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { if (!sio_pad_on_multitap(s)) continue; if (sio_get_multitap_analog()) continue; sio_set_pad_config_capable(s, 0); sio_set_pad_analog(s, 0, 0x80, 0x80, 0x80, 0x80); } } /* Solo resim self-check replay republishes the recorded rows itself — * live sampling must not touch SIO during the replay window. */ /* New frame: the low-latency re-sample later this frame reuses this * frame's mod controller source sample (callback runs once). */ mod_controller_source_begin_frame(); if (!psx_selfcheck_replay_input()) { if (g_headless) sample_headless_pad_into_sio(override); else sample_pad_into_sio(override); } /* Offline vblank boundary: record/replay/compare (PSX_RB_SELFCHECK). * Defer opening a window while multitap arming is still pending — * the arm writes SIO once, host-latched, and would not replay. */ psx_selfcheck_finish_frame( (g_offline_pad_count >= 3 && !sio_get_multitap()) ? 1 : 0); } if (!g_headless) update_controller_rumble(); /* 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(); /* Post-savestate CD delay boost (ReadTOC/seek clamp window). * Skip during rollback resim — boost is host-local and not in the CD snap. */ if (!psx_netplay_is_resimulating() && !psx_selfcheck_resim_active()) cdrom_savestate_boost_vblank(); /* Turbo-active test shared by the pacing/present gate below. */ int turbo_loads_active = 0; int logical_load_active = fntrace_is_game_started() && (cdrom_load_in_progress() || cdrom_savestate_cd_wait_active()); int load_run_value = 0; static int load_run = 0; static int release_run = 0; if (g_turbo_loads_enabled && !psx_netplay_active() && !psx_selfcheck_resim_active()) { /* Require a short sustained predicate on entry, then retain turbo over * a short false gap after it has engaged. Netplay / selfcheck keep * wall-pacing off via their own gates; turbo engage hysteresis must * not leak host ambient across resim passes. */ 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 = g_turbo_load_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; probe_turbo = turbo_loads_active; 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; /* Never inject START / poke movie totals under netplay — host-side skip * forks peers (and stomps SIO after sealed publish during resim). */ if (g_auto_skip_fmv && !psx_netplay_active() && !psx_selfcheck_resim_active()) { 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_host_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. * Rollback resim: skip host audio pump (uncapped frames) so SPU/CD audio * paths cannot fork peers during the episode window. */ if (!psx_netplay_is_resimulating() && !psx_selfcheck_resim_active()) { sdl_audio_update(fmv_skip_active, turbo_loads_active && g_turbo_audio_sink_enabled); } #endif /* Render passes (render_pass.c) stay off while presentation is sped up. */ s_presentation_fast_forward = (turbo_loads_active || fmv_skip_active) ? 1 : 0; /* Guest-affecting per-frame services run on EVERY guest frame, before any * presentation early-out (headless, TCP turbo, load/FMV present skips). * Widescreen engagement changes GTE projection and cull margins, and mod * frame hooks may touch guest state, so neither may depend on whether this * frame is presented — otherwise headless validation runs a different game * than the windowed build. Hooks still 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. No-op * without a window. */ update_adaptive_widescreen(); /* Engage widescreen at game entry: BIOS boot stays authentic 4:3. */ if (!g_ws_engaged) { if (fntrace_is_game_started()) { g_ws_engaged = true; g_ws_projection_mode = -1; refresh_widescreen_projection(); } } else { refresh_widescreen_projection(); } if (g_headless) { #ifndef PSX_NO_DEBUG_TOOLS headless_present_image_ring_capture(); #endif ep.skip_pace = 1; return ep; } refresh_host_display_cadence(0, 0); /* 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()) { s_presentation_fast_forward = 1; ep.skip_pace = 1; return ep; } #endif bool manual_turbo_active = false; bool turbo_load_paced = false; /* Manual fast-forward: bounded by default so the game visibly advances and * audio is less hostile. PSX_FAST_FORWARD_SPEED=2..16 changes the cap; * PSX_FAST_FORWARD_SPEED=max restores the old unbounded simulation rate. */ { const Uint8* keys = SDL_GetKeyboardState(NULL); static int turbo_skip = 0; static int turbo_was_down = 0; /* Keyboard ([KeyMap] Turbo, default Tab) or the controller host * shortcut ([hotkeys] fast_forward_pad, default select+L1). Both are * hold-to-run; the pad chord goes through the same combo matcher as * Rewind / Save states so the launcher's binding editor covers it. * g_manual_turbo_latched is the press-to-lock twin (TurboToggle / * fast_forward_toggle_pad) and drives the same path. */ uint8_t host_keys[SDL_NUM_SCANCODES]; psx_keybinds_host_keys(keys, host_keys); const bool kb_turbo = host_hotkey_input_focused() && host_keymap_down(HOST_KEYMAP_TURBO, host_keys, (int)SDL_GetModState()); if (kb_turbo || g_manual_turbo_latched || hotkey_shortcut_down(PSX_ASSIST_BIND_FAST_FORWARD, g_hotkey_pad_fast_forward)) { const int mult = manual_fast_forward_multiplier(); const int present_every = (mult < 0) ? 4 : (mult <= 4 ? 2 : 4); manual_turbo_active = true; s_presentation_fast_forward = 1; if (!turbo_was_down && !g_manual_turbo_latched) { char msg[40]; if (mult < 0) snprintf(msg, sizeof(msg), "Fast forward: max"); else snprintf(msg, sizeof(msg), "Fast forward: %dx", mult); host_osd_push(msg, 900); } turbo_was_down = 1; if (mult >= 2 && g_frame_period_ms > 0.0) { uint64_t perf_start = runtime_perf_section_begin(); frame_pacer_wait(&s_frame_pacer, g_frame_period_ms / (double)mult); runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); latency_ring_mark(LAT_PACED); } turbo_skip = (turbo_skip + 1) % present_every; if (turbo_skip != 0) { ep.skip_pace = 1; return ep; /* skip render this frame */ } } else { turbo_skip = 0; turbo_was_down = 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++; /* A mod may request a bounded wall-clock multiplier instead of the * legacy unpaced host-speed path. Pace every simulated guest frame at * the divided period; the presentation skip below remains independent * so rendering cost does not distort the selected multiplier. */ if (g_turbo_load_wall_multiplier >= 2 && g_frame_period_ms > 0.0) { uint64_t perf_start = runtime_perf_section_begin(); frame_pacer_wait( &s_frame_pacer, g_frame_period_ms / (double)g_turbo_load_wall_multiplier); runtime_perf_section_end( perf_start, &g_runtime_perf.pacer_ticks); latency_ring_mark(LAT_PACED); turbo_load_paced = true; } const int TL_PRESENT_EVERY = 30; s_turbo_present_skip = (s_turbo_present_skip + 1) % TL_PRESENT_EVERY; if (s_turbo_present_skip != 0) { ep.skip_pace = 1; return ep; } } /* 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) { ep.skip_pace = 1; return ep; } } /* Netplay FMV: present 1 of every N depth24 vblanks while MDEC is hot. * §98: default N=2 (was 4). 1/4 made present_gap_p95≈80–100 ms look like * hitches; SW scanout is batched so 1/2 is affordable. Admit + wall pace * still run every tick. During MDEC-idle cutover present every frame. * Override: PSX_NET_FMV_PRESENT_DIV (1=every frame, 2=default, 4=legacy). */ if (psx_netplay_active() && gpu_display_is_depth24() && mdec_recently_active(8)) { static int s_fmv_present_div = -1; int div; if (s_fmv_present_div < 0) { const char *e = getenv("PSX_NET_FMV_PRESENT_DIV"); unsigned v = 2u; if (e && e[0] && sscanf(e, "%u", &v) == 1 && v >= 1u && v <= 8u) s_fmv_present_div = (int)v; else s_fmv_present_div = 2; } div = s_fmv_present_div; if (div <= 1) { s_netplay_depth24_present_skip = 0; } else if (s_netplay_depth24_present_skip > 0) { s_netplay_depth24_present_skip--; return ep; } else { s_netplay_depth24_present_skip = div - 1; } } else { s_netplay_depth24_present_skip = 0; } /* Offline wall-clock pacing before present. Skipped when driver vsync * owns cadence (~60 Hz panel) so the two waits cannot double-block. * Netplay paces in the epilogue AFTER present so Swap overlaps the * peer's guest quantum. Self-check replay runs uncapped like netplay * resim. */ if (!psx_netplay_active() && !psx_selfcheck_resim_active()) { uint64_t perf_start = runtime_perf_section_begin(); if (!manual_turbo_active && !turbo_load_paced && present_should_wall_pace()) { const uint64_t dyn_t0 = g_dynres.active ? SDL_GetPerformanceCounter() : 0; frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); if (dyn_t0) g_dynres.pacer_ticks += SDL_GetPerformanceCounter() - dyn_t0; } 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. * Self-check record keeps pads boundary-latched (netplay tick * semantics) — the mid-frame re-sample would fork the resim. */ if (g_low_latency_input && !psx_selfcheck_input_locked()) { SDL_GameControllerUpdate(); /* refresh pad state after the wait */ if (psx_local_mouse_installed()) { psx_local_mouse_begin(sdl_window, local_mouse_live(override)); if (!drain_host_events()) return ep; // A hotkey acquired during pacing may open a host menu. if (savestate_menu_open) savestate_menu_host_pause_loop(); if (psx_rewind_is_open()) rewind_host_pause_loop(); psx_local_mouse_begin(sdl_window, local_mouse_live(override)); } else { SDL_PumpEvents(); // retain native timing when no policy exists } sample_pad_into_sio(override); latency_ring_restamp_input(); } } /* 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. * Must tick every present — gpu.c arms s_d24_present_hold and also * freezes upload-span tracking while hold > 0; without this call the hold * sticks and depth24_fix_trailing_margin blanks the whole FMV forever. */ if (gpu_depth24_present_hold_tick()) return ep; /* Rollback resim (§33/§47): short catch-up keeps hold-last; long catch-up * periodically presents live Replay VRAM so the display shows progress * while sim stays uncapped. TipHold invent-cap stall uses hold-last from * the admit spin (no guest vblank). * * §74: ownership-chain Replay treadmills at wire pace during fights * (target extends per wire arrival, remaining ≤ 24 forever), so the * blanket hold-last froze the display 135–212 ms per ~12-tick episode * and then skipped ahead ("delay-sync burst" feel). Split by a display * watermark: frames the user has NEVER seen (resim sim beyond the * highest sim ever presented) are new forward progress — present them * live every vblank. Frames at/below the watermark are a rewound * re-play; hold-last exactly as before so §63 edge-input re-show * (pad-log DUP_SIO) stays impossible. */ if (psx_netplay_is_resimulating()) { uint32_t rem = psx_netplay_rb_confirmed_remaining(); uint32_t sim = psx_netplay_sim_tick(); int beyond_shown = sim != 0u && sim != 0xffffffffu && sim > s_netplay_present_sim_watermark; /* §99: FMV media Replay is CD/MDEC-bound — skip dual-raster present * entirely (hold-last). Live FMV present + catchup gate still apply * once media ends; near-tip media loads are short enough that a frozen * frame for a few resim ticks is preferable to 1–3s GL hitch. */ if (psx_netplay_rb_fmv_media_active()) { netplay_hold_last_present_tick(); return ep; } if (!beyond_shown && !netplay_replay_catchup_should_live_present(rem)) { netplay_hold_last_present_tick(); return ep; } /* Fall through to real VRAM present (new frame, or wall-clock gate). */ } { /* Raise the display watermark for any frame that falls through to a * real present (live or Replay). Hold-last paths return above and * never raise it. */ uint32_t sim = psx_netplay_sim_tick(); if (sim != 0u && sim != 0xffffffffu && sim > s_netplay_present_sim_watermark) s_netplay_present_sim_watermark = sim; } /* ---- Display from our VRAM ---- */ probe_reached = 1; uint32_t w = 0, h = 0, present_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; bool local_viewport_crop_applied = false; bool game_view_applied = false; uint32_t gv_x = 0, gv_y = 0, gv_w = 0, gv_h = 0; 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; depth24_cutover_tick(depth24_frame ? 1 : 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 { if (!sdl_renderer && ensure_sw_sdl_present() != 0) return ep; if (sdl_renderer) { SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); SDL_RenderClear(sdl_renderer); host_osd_draw_sdl(sdl_renderer); SDL_RenderPresent(sdl_renderer); } } } #endif return ep; } s_disabled_frame_presented = false; s_force_present_after_load = false; w = di.width; h = di.height; present_h = psx_display_present_height( di.depth24, h, di.screen_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)); const bool game_view = netplay_game_local_view(di, &gv_x, &gv_y, &gv_w, &gv_h); const int local_viewport_slot = game_view ? -1 : netplay_local_viewport_slot(); const bool local_viewport_crop = local_viewport_slot >= 0; bool local_viewport_wide = local_viewport_crop && g_ws_engaged && ws_native_wide_active() && gr_wide_supported() && gpu_ws_netplay_local_viewport_width() > 0; /* 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() && !game_view && (!local_viewport_crop || local_viewport_wide)); if (wide_present) { present_w = local_viewport_wide ? (uint32_t)gpu_ws_netplay_local_viewport_width() : (w + (uint32_t)ws_nw_extra()); } /* The software wide readback can exceed the canonical 640px staging * width, especially on high-resolution and resized adaptive frames. */ const int staging_scale = std::max(1, std::min( std::max(g_video_scale, gr_scale()), SW_MAX_INTERNAL_SCALE)); const size_t staging_pixels = (size_t)present_w * std::max(h, present_h) * (size_t)staging_scale * staging_scale; if (!ensure_sdl_pixel_buf_capacity(staging_pixels)) return ep; /* 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 && (netplay_local_viewport_native_wide() ? g_ws_projection_mode == 2 : g_ws_native_wide != 0); /* 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)present_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 /* Netplay local viewport without native-wide columns (squash or 4:3): * present this peer's half straight from the high-resolution FBO. * The CPU crop below reads the 1x canonical frame, which throws away * internal resolution and smears the proportion-corrected HUD. */ if (g_gl_active && g_gl_fbo_present && game_view) { gl_renderer_present_vram((int)(di.display_x + gv_x), (int)(di.display_y + gv_y), (int)gv_w, (int)gv_h, g_video_aa ? 1 : 0, 1); netplay_note_present(); return ep; } if (g_gl_active && g_gl_fbo_present && !di.depth24 && local_viewport_crop && !local_viewport_wide) { const int half = (int)w / 2; const int half_x = (int)di.display_x + (local_viewport_slot == 1 ? (int)w - half : 0); gl_renderer_present_vram(half_x, (int)di.display_y, half, (int)h, g_video_aa ? 1 : 0, 0); netplay_note_present(); return ep; } if (g_gl_active && g_gl_fbo_present && !di.depth24 && (!local_viewport_crop || local_viewport_wide) && !game_view) { 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. */ const int wide_base = local_viewport_wide ? gpu_ws_netplay_local_viewport_base_x() : (int)di.display_x; if (gl_renderer_present_wide_fbo(wide_base, (int)di.display_y, (int)h, g_video_aa ? 1 : 0)) { netplay_note_present(); return ep; } } 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); netplay_note_present(); return ep; } } 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 && !local_viewport_crop) { if (di.depth24) { /* 24-bit (FMV): packed RGB lives in the CPU mirror — do NOT * sync_cpu (FBO readback clobbers RGB888). Batch per-scanline * (see gpu_depth24_present_row) instead of a per-pixel call * chain — this loop was the FMV present-side cost. */ depth24_stage_scanout(&di, sdl_pixel_buf, present_w); vk_renderer_present_cpu(sdl_pixel_buf, (int)present_w, (int)present_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); } netplay_note_present(); return ep; } #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). * SW-only netplay present: native scanout. Dual-raster FBO present * already returned above at GL SSAA. */ /* Under OpenGL the CPU-side display readout is native (the software * mirror stays 1x; the internal resolution lives in the GL FBO), so * this path presents at 1x. Treating that native image as S-scaled * showed a magnified top-left slice. */ if (netplay_cpu_auth_gpu() && !netplay_gl_dual_quality()) active_scale = 1; else if (g_gl_active) active_scale = 1; else 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 base_x = local_viewport_wide ? gpu_ws_netplay_local_viewport_base_x() : (int)di.display_x; /* The staging buffer is sized for at most SW_MAX_INTERNAL_SCALE; * a GL wide surface at a higher scale falls back to canonical. */ int n = ((size_t)sw * (size_t)h * (size_t)s <= sdl_pixel_buf_capacity) ? gr_render_wide_display(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)), base_x, (int)di.display_y, (int)h) : 0; if (n > 0) { active_scale = s; } else { wide_present = false; local_viewport_wide = 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) { /* Never hires-present under netplay CPU-auth (even if settings say * 4× — that preference is offline-only). */ if (active_scale > 1 && !netplay_cpu_auth_gpu()) { 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 if (di.depth24) { /* Batch per-scanline (see gpu_depth24_present_row) — the * per-pixel call chain was the dominant FMV present cost * (3x the VRAM touches of the 16-bit path below). */ depth24_stage_scanout(&di, sdl_pixel_buf, present_w); } 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); } } } } int present_px_w = (int)present_w * active_scale; int present_px_h = (int)present_h * active_scale; if (game_view && !wide_present) { crop_present_to_game_local_view(sdl_pixel_buf, present_px_w, active_scale, gv_x, gv_y, gv_w, gv_h); present_px_w = (int)gv_w * active_scale; present_px_h = (int)gv_h * active_scale; pin_43 = true; game_view_applied = true; } else if (!local_viewport_wide && crop_present_to_netplay_local_viewport(sdl_pixel_buf, &present_px_w, present_px_h)) { pin_43 = false; local_viewport_crop_applied = true; } smooth_60_present(sdl_pixel_buf, (uint32_t)present_px_w, (uint32_t)present_px_h, !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 = (local_viewport_crop_applied || game_view_applied) ? (uint32_t)(present_px_w * present_px_h) : 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)present_h * active_scale; if (local_viewport_crop_applied && src_w >= 2) src_w /= 2; if (game_view_applied) { src_w = (int)gv_w * active_scale; src_h = (int)gv_h * active_scale; } if (g_gl_active) { /* OpenGL present: upload the active display rect and draw a full-screen * quad. Either SwapWindow vsync OR the wall-clock pacer owns timing, * never both. 24-bit (FMV) frames pin to native 4:3. */ /* FMV follows the same AA setting as everything else. It used to be * pinned to nearest because plain GL_LINEAR fringed the right edge of * low-res 24-bit scanouts into adjacent (often garbage) texels — but * gl_renderer_present now half-texel-insets its UV rect (and crops the * depth24 margin via content_w), which is exactly that bleed, and it * reconstructs a filtered low-res source when [video] fmv_filter opts * into it instead of smearing whole texels. Measured: the * right-edge jump the old comment describes is 0 with filtering on. * Set video AA off to get nearest back. */ gl_renderer_set_fmv_filter(g_video_fmv_filter); gl_renderer_present(sdl_pixel_buf, src_w, src_h, g_video_aa ? 1 : 0, pin_43 ? 1 : 0, 0 /* full width */); netplay_note_present(); } else { if ((!sdl_renderer || !sdl_texture) && ensure_sw_sdl_present() != 0) return ep; if (!sdl_renderer || !sdl_texture) return ep; if (ensure_sdl_present_texture(std::max(1, src_w), std::max(1, src_h)) != 0) return ep; SDL_Rect src = { 0, 0, src_w, src_h }; SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf, (int)(src_w * sizeof(uint32_t))); #ifndef PSX_NO_DEBUG_TOOLS /* Presented-image ring (software/CPU present): the frame being shown. */ present_image_ring_push_argb((uint32_t)s_frame_count, sdl_pixel_buf, (int)src_w, (int)src_h, (int)src_w); #endif { GpuDisplayInfo tdi; gpu_get_display_info(&tdi); gpu_timeline_note(GTL_PRESENT, GTL_PATH_CPU, tdi.display_x | ((uint32_t)tdi.display_y << 16)); } /* Short FMV bands only update [0..src_h). Linear sampling at the bottom * edge blends with uninitialized texels below (X11 SDL backends often * show that as a thin white strip; Wayland may not). Pad one black row * so any residual linear fringe is black, matching the letterbox. */ const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; const int tex_h = (int)PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale; if (src_w > 0 && src_h > 0 && src_h < tex_h) { static std::vector black_pad; black_pad.assign((size_t)src_w, 0xFF000000u); SDL_Rect pad = { 0, src_h, src_w, 1 }; SDL_UpdateTexture(sdl_texture, &pad, black_pad.data(), (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 * tex_scale : g_logical_w; int dst_h = 480 * tex_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. */ if (pin_43 && present_h > 0 && present_h < 240) { int content_h = (dst_h * (int)present_h) / 240; if (content_h < 1) content_h = 1; dst.y = (dst_h - content_h) / 2; dst.h = content_h; } /* Match GL depth24 nearest present — linear AA fringes short FMV bands * into the pad/unwritten row. Restore AA scale mode for 15-bit frames. */ { static int s_sw_scale_nearest = -1; const int want_nearest = depth24_frame || !g_video_aa; if (s_sw_scale_nearest != want_nearest) { SDL_SetTextureScaleMode(sdl_texture, want_nearest ? SDL_ScaleModeNearest : SDL_ScaleModeLinear); s_sw_scale_nearest = want_nearest; } } /* Always clear black: hot-path RenderClear used to inherit the backend * default draw color (often white), so letterbox margins under short FMV * bands flashed as a bright strip on some X11 SDL drivers. */ SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); SDL_RenderClear(sdl_renderer); SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst); host_osd_draw_sdl(sdl_renderer); /* Fulfil a staged present_shot here: after RenderCopy + OSD, before * RenderPresent, the renderer holds exactly the composed frame the player * sees — including the logical-size aspect fit that every buffer-level * capture misses. Reading back costs a GPU sync, so it only runs when a * shot was explicitly requested. */ char shot_path[512]; if (present_shot_take(shot_path, (int)sizeof(shot_path))) { int ow = 0, oh = 0; uint8_t *packed = NULL; #if defined(PSX_SDL3) SDL_Surface *surf = SDL_RenderReadPixels(sdl_renderer, NULL); if (surf) { SDL_Surface *conv = SDL_ConvertSurface(surf, SDL_PIXELFORMAT_RGB24); SDL_DestroySurface(surf); if (conv) { ow = conv->w; oh = conv->h; packed = (uint8_t *)std::malloc((size_t)ow * oh * 3); if (packed) { /* SDL rows are pitch-aligned; png_write_rgb wants packed. */ for (int y = 0; y < oh; y++) std::memcpy(packed + (size_t)y * ow * 3, (const uint8_t *)conv->pixels + (size_t)y * conv->pitch, (size_t)ow * 3); } SDL_DestroySurface(conv); } } #else /* Size the buffer from the renderer's REAL output, not from a * viewport*scale reconstruction: SDL_RenderReadPixels(NULL) fills the * current viewport, and deriving that region from * SDL_RenderGetViewport x SDL_RenderGetScale rounds independently of * what SDL actually writes. The output size is a hard upper bound, so * a full-output allocation cannot be overrun whatever SDL picks. */ int rw = 0, rh = 0; if (SDL_GetRendererOutputSize(sdl_renderer, &rw, &rh) == 0 && rw > 0 && rh > 0) { SDL_Rect vp; SDL_RenderGetViewport(sdl_renderer, &vp); float sx = 1.0f, sy = 1.0f; SDL_RenderGetScale(sdl_renderer, &sx, &sy); ow = (int)(vp.w * sx); oh = (int)(vp.h * sy); if (ow <= 0 || ow > rw) ow = rw; if (oh <= 0 || oh > rh) oh = rh; packed = (uint8_t *)std::malloc((size_t)rw * rh * 3); /* upper bound */ if (packed && SDL_RenderReadPixels(sdl_renderer, NULL, SDL_PIXELFORMAT_RGB24, packed, ow * 3) != 0) { std::free(packed); packed = NULL; } } #endif int wrote = 0; if (packed && ow > 0 && oh > 0) { FILE *pf = std::fopen(shot_path, "wb"); if (pf) { wrote = png_write_rgb(pf, packed, (uint32_t)ow, (uint32_t)oh); std::fclose(pf); } } std::free(packed); present_shot_done(wrote); } /* §33: remember active rect for resim hold-last (not full 640x512). */ s_sw_hold_src = src; s_sw_hold_dst = dst; s_sw_hold_valid = 1; /* Vsync self-heal. PRESENTVSYNC is only armed when driver vsync owns * cadence; the wall-clock pacer otherwise holds 59.94 Hz. 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; wall-clock pacing takes over. */ { 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); netplay_note_present(); 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 return ep; } /* ---- Dynamic internal resolution: the host side --------------------------- * [video] dynamic_resolution (docs/ENHANCEMENTS.md, IR3). The controller * (dynamic_resolution.c) decides; this feeds it one sample per guest VBlank, * after the present, and applies its level through the GL backend. The * sample is the interval's wall time and its guest work: the wall time minus * the pacer's wait, the frame blend's waits, the swap's vsync block (counted * as work when the interval was late and no render pass ran: the driver then * waited on the GPU, not the display), render passes, the blend's extra * presents and the step itself. Holds discard samples: turbo, fast-forward * and turbo loads, FMV, a disabled display, overlay compiles, savestate loads, * rollback resim, window size changes, and the game's first seconds. */ static int dynres_requested(void) { return g_video_dynres_env >= 0 ? g_video_dynres_env : g_video_dynres; } static int dynres_min_value(void) { return g_video_dynres_min_env != PSX_IR_UNSET ? g_video_dynres_min_env : g_video_dynres_min; } /* After GL context init: the ceiling is what the backend allocated, the * floor the configured minimum as a scale (never above the ceiling). */ static void dynres_setup(void) { if (g_dynres.trace) { std::fclose(g_dynres.trace); g_dynres.trace = nullptr; } g_dynres = DynresHost{}; const int ceiling = g_gl_active ? gl_renderer_dynamic_resolution_ceiling() : 0; if (ceiling < 2) { if (dynres_requested() && g_gl_active) std::fprintf(stdout, "psxrecomp: dynamic resolution off: the internal " "scale is fixed here (1x, the high-resolution window, or " "dual raster)\n"); return; } int floor_s = psx_resolve_internal_scale(dynres_min_value(), g_video_ref_lines, psx_sdl_display_pixel_height(nullptr), ceiling); if (floor_s > ceiling) floor_s = ceiling; DynresParams params; dynres_default_params(¶ms); dynres_init(&g_dynres.ctl, ¶ms, floor_s, ceiling, ceiling); DynrtParams rtp; dynrt_default_params(&rtp); dynrt_init(&g_dynres.rt, &rtp, floor_s, ceiling, ceiling); g_dynres.active = floor_s < ceiling; if (const char* e = std::getenv("PSX_DYNRES_FORCE")) { const int n = std::atoi(e); if (n > 0) { g_dynres.active = true; const int l = dynres_force(&g_dynres.ctl, n); (void)dynrt_force(&g_dynres.rt, n); (void)gl_renderer_step_internal_scale_now(l); } } if (const char* e = std::getenv("PSX_DYNRES_TRACE")) if (*e) g_dynres.trace = std::fopen(e, "w"); if (g_dynres.trace) std::fprintf(g_dynres.trace, "t_s,level,load,late,vblank_hz,decision\n"); g_dynres.t0_s = (double)SDL_GetPerformanceCounter() / (double)SDL_GetPerformanceFrequency(); std::fprintf(stdout, "psxrecomp: dynamic resolution %s: %dx..%dx (%d..%d lines)\n", g_dynres.active ? "on" : "inert (floor = ceiling)", floor_s, ceiling, floor_s * g_video_ref_lines, ceiling * g_video_ref_lines); } static void dynres_apply_level(int level) { GlDynresStats st; gl_renderer_dynres_stats(&st); if (level == st.level) return; const int from = st.level; const uint64_t t0 = SDL_GetPerformanceCounter(); const int ok = gl_renderer_step_internal_scale_now(level); const uint64_t t1 = SDL_GetPerformanceCounter(); const double sec = (double)(t1 - t0) / (double)SDL_GetPerformanceFrequency(); if (ok) { dynres_note_step_cost(&g_dynres.ctl, sec); g_dynres.step_ticks += t1 - t0; g_dynres.step_from = from; g_dynres.step_to = level; g_dynres.step_ms = sec * 1000.0; g_dynres.step_measure_next = true; } } /* Render-thread mode: one sample per guest VBlank from what the render * thread measured since the last one (its frames' costs arrive a few frames * late) and the time the emulation thread spent blocked on the queue. */ static void dynres_tick_rt(double now_s, double wall, double period, int held, double tail, const char *why) { DynrtController &c = g_dynres.rt; if (tail > 0.0) dynrt_hold(&c, now_s, tail); if (why) g_dynres.hold_reason = why; else if (now_s >= c.hold_until) g_dynres.hold_reason = ""; GlRthCosts co; gl_renderer_render_thread_costs(&co); RtStats rs; rt_get_stats(&rs); const GlRthCosts &c0 = g_dynres.last_costs; const int frames = (int)(co.frames - c0.frames); const double cost = (double)(co.cost_ns - c0.cost_ns) * 1e-9; const uint64_t bp_ns = rs.backpressure_ns + rs.ring_full_ns; const double bp = (double)(bp_ns - g_dynres.last_bp_ns) * 1e-9; g_dynres.rt_acc_cpu += (double)(co.cpu_ns - c0.cpu_ns) * 1e-6; g_dynres.rt_acc_gpu += (double)(co.gpu_ns - c0.gpu_ns) * 1e-6; g_dynres.rt_acc_frames += co.frames - c0.frames; g_dynres.rt_acc_gpu_frames += co.gpu_frames - c0.gpu_frames; g_dynres.last_costs = co; g_dynres.last_bp_ns = bp_ns; DynrtSample smp{ period, wall, frames, cost, bp, held }; const int level = dynrt_sample(&c, now_s, &smp); if (c.windows != g_dynres.rt_last_windows) { g_dynres.rt_last_windows = c.windows; g_dynres.rt_win_cpu_ms = g_dynres.rt_acc_frames ? g_dynres.rt_acc_cpu / (double)g_dynres.rt_acc_frames : 0.0; g_dynres.rt_win_gpu_ms = g_dynres.rt_acc_gpu_frames ? g_dynres.rt_acc_gpu / (double)g_dynres.rt_acc_gpu_frames : 0.0; g_dynres.rt_acc_cpu = g_dynres.rt_acc_gpu = 0.0; g_dynres.rt_acc_frames = g_dynres.rt_acc_gpu_frames = 0; if (g_dynres.trace) { if (!g_dynres.rt_trace_header) { g_dynres.rt_trace_header = true; std::fprintf(g_dynres.trace, "# render thread: t_s,level,load,bp_share," "guest_hz,cpu_ms,gpu_ms,guest_bound,decision\n"); } std::fprintf(g_dynres.trace, "%.3f,%d,%.3f,%.3f,%.2f,%.2f,%.2f,%d,%s\n", now_s - g_dynres.t0_s, c.level, c.last_load, c.last_bp_share, c.last_hz, g_dynres.rt_win_cpu_ms, g_dynres.rt_win_gpu_ms, c.last_guest_bound, c.last_decision_t == now_s ? c.last_reason : ""); std::fflush(g_dynres.trace); } } dynres_apply_level(level); } /* One guest VBlank, after its present. */ static void dynres_tick(void) { if (!g_dynres.active || !g_gl_active) return; const uint64_t now = SDL_GetPerformanceCounter(); const double freq = (double)SDL_GetPerformanceFrequency(); GlHostLedger led; gl_renderer_host_ledger(&led); const double now_s = (double)now / freq; if (!g_dynres.last_t) { g_dynres.last_t = now; g_dynres.last_ledger = led; g_dynres.last_pacer = g_dynres.pacer_ticks; return; } const GlHostLedger &p0 = g_dynres.last_ledger; const double wall = (double)(now - g_dynres.last_t) / freq; const double period = g_frame_period_ms / 1000.0; const double pacer = (double)(g_dynres.pacer_ticks - g_dynres.last_pacer) / freq; const double idle = (double)(led.idle_ticks - p0.idle_ticks) / freq; const double pass = (double)(led.pass_ticks - p0.pass_ticks) / freq; const double swap = (double)(led.swap_ticks - p0.swap_ticks) / freq; const uint64_t blends = led.interp_presents - p0.interp_presents; const double blend_work = (double)(led.interp_work_ticks - p0.interp_work_ticks) / freq; const double extra = blends > 1 ? blend_work * (double)(blends - 1) / (double)blends : 0.0; const double step = (double)g_dynres.step_ticks / freq; const bool late = wall > period * g_dynres.ctl.p.late_factor; const double swap_idle = (late && pass <= 0.0) ? 0.0 : swap; const double work = wall - pacer - idle - swap_idle - pass - extra - step; g_dynres.last_t = now; g_dynres.last_ledger = led; g_dynres.last_pacer = g_dynres.pacer_ticks; g_dynres.step_ticks = 0; if (g_dynres.step_measure_next) { g_dynres.step_measure_next = false; g_dynres.step_interval_ms = wall * 1000.0; if (g_dynres.trace) std::fprintf(g_dynres.trace, "%.3f,%d,,,,step %d->%d %.2f ms interval %.2f ms\n", now_s - g_dynres.t0_s, g_dynres.step_to, g_dynres.step_from, g_dynres.step_to, g_dynres.step_ms, g_dynres.step_interval_ms); } /* Holds. */ double tail = 0.0; int held = 0; const char *why = nullptr; GpuDisplayInfo di; gpu_get_display_info(&di); const bool started = fntrace_is_game_started() != 0; if (!started) { held = 1; why = "boot"; } else if (!g_dynres.game_started) { tail = 5.0; why = "game entry"; } g_dynres.game_started = started; if (s_presentation_fast_forward) { held = 1; tail = 1.0; why = "fast-forward"; } if (di.disabled || di.depth24 || mdec_recently_active(2)) { held = 1; tail = 1.0; why = "FMV or no display"; } if (autocompile_busy()) { held = 1; tail = 1.0; why = "overlay compile"; } if (psx_netplay_is_resimulating()) { held = 1; tail = 1.0; why = "resim"; } #ifndef PSX_SDL_NO_RENDER if (sdl_window) { int dw = 0, dh = 0; SDL_GL_GetDrawableSize(sdl_window, &dw, &dh); if (dw != g_dynres.drawable_w || dh != g_dynres.drawable_h) { if (g_dynres.drawable_w) { tail = 1.0; why = "window resize"; } g_dynres.drawable_w = dw; g_dynres.drawable_h = dh; } } #endif if (g_dynres.hold_request_s > 0.0) { if (g_dynres.hold_request_s > tail) tail = g_dynres.hold_request_s; why = "savestate load"; g_dynres.hold_request_s = 0.0; } /* The render thread on: its own model (dynres_tick_rt). The level carries * over when the mode changes (the render thread starts at the first * VBlank, after dynres_setup). */ const bool rt = gl_renderer_render_thread_active() != 0; if (rt != g_dynres.rt_mode) { g_dynres.rt_mode = rt; gl_renderer_render_thread_measure(rt ? 1 : 0); GlDynresStats st; gl_renderer_dynres_stats(&st); if (rt) { const DynrtParams rp = g_dynres.rt.p; const int forced = g_dynres.rt.forced; dynrt_init(&g_dynres.rt, &rp, g_dynres.ctl.floor, g_dynres.ctl.ceiling, st.level); if (forced) (void)dynrt_force(&g_dynres.rt, forced); gl_renderer_render_thread_costs(&g_dynres.last_costs); RtStats rs; rt_get_stats(&rs); g_dynres.last_bp_ns = rs.backpressure_ns + rs.ring_full_ns; } else { const DynresParams cp = g_dynres.ctl.p; const int forced = g_dynres.ctl.forced; const int fl = g_dynres.ctl.floor, ce = g_dynres.ctl.ceiling; dynres_init(&g_dynres.ctl, &cp, fl, ce, st.level); if (forced) (void)dynres_force(&g_dynres.ctl, forced); } if (tail < 1.0) tail = 1.0; if (!why) why = rt ? "render thread started" : "render thread stopped"; } if (rt) { dynres_tick_rt(now_s, wall, period, held, tail, why); return; } if (tail > 0.0) dynres_hold(&g_dynres.ctl, now_s, tail); if (why) g_dynres.hold_reason = why; else if (now_s >= g_dynres.ctl.hold_until) g_dynres.hold_reason = ""; DynresSample smp{ period, wall, work, held }; const int level = dynres_sample(&g_dynres.ctl, now_s, &smp); if (g_dynres.trace && g_dynres.ctl.windows != g_dynres.last_windows) { g_dynres.last_windows = g_dynres.ctl.windows; std::fprintf(g_dynres.trace, "%.3f,%d,%.3f,%d,%.2f,%s\n", now_s - g_dynres.t0_s, g_dynres.ctl.level, g_dynres.ctl.last_load, g_dynres.ctl.last_late, g_dynres.ctl.last_vblank_hz, g_dynres.ctl.last_decision_t == now_s ? g_dynres.ctl.last_reason : ""); std::fflush(g_dynres.trace); } dynres_apply_level(level); } /* Debug server (dynres, dynres_force, video_info). */ extern "C" void psx_dynres_summary(int *enabled, int *level, int *floor_s, int *ceiling) { GlDynresStats st; gl_renderer_dynres_stats(&st); if (enabled) *enabled = g_dynres.active ? 1 : 0; if (level) *level = st.level; if (floor_s) *floor_s = g_dynres.active ? g_dynres.ctl.floor : 0; if (ceiling) *ceiling = st.ceiling; } extern "C" int psx_dynres_force(int scale) { if (!g_dynres.active) return -1; const int l = g_dynres.rt_mode ? dynrt_force(&g_dynres.rt, scale) : dynres_force(&g_dynres.ctl, scale); dynres_apply_level(l); return l; } extern "C" int psx_dynres_status_json(char *out, int cap) { GlDynresStats st; gl_renderer_dynres_stats(&st); if (g_dynres.rt_mode) { /* render-thread mode (dynres_tick_rt) */ const DynrtController &r = g_dynres.rt; const double now_s = (double)SDL_GetPerformanceCounter() / (double)SDL_GetPerformanceFrequency(); char blocked[256]; int bp = 0; blocked[0] = 0; for (int l = r.floor; g_dynres.active && l <= r.ceiling && bp < (int)sizeof blocked - 24; l++) { double b = dynrt_up_blocked_s(&r, l, now_s); if (b > 0.0) bp += std::snprintf(blocked + bp, sizeof blocked - (size_t)bp, "%s\"%d\":%.1f", bp ? "," : "", l, b); } GlRthCosts co; gl_renderer_render_thread_costs(&co); const double hold = r.hold_until - now_s; return std::snprintf(out, (size_t)cap, "\"mode\":\"render_thread\",\"enabled\":%d,\"requested\":%d,\"ceiling\":%d," "\"floor\":%d,\"level\":%d,\"internal_lines\":%d,\"forced\":%d,\"load\":%.3f," "\"budget\":%.3f,\"bp_share\":%.3f,\"guest_hz\":%.2f,\"cpu_ms\":%.3f," "\"gpu_ms\":%.3f,\"guest_bound\":%d,\"over\":%d,\"scaled_share\":%.3f," "\"hold\":\"%s\",\"hold_s\":%.2f,\"last_reason\":\"%s\",\"downs\":%llu," "\"ups\":%llu,\"undos\":%llu,\"relapses\":%llu,\"windows\":%llu," "\"held_windows\":%llu,\"guest_bound_windows\":%llu,\"thin_windows\":%llu," "\"steps\":%llu,\"last_from\":%d,\"last_to\":%d,\"last_ms\":%.3f," "\"frames_measured\":%llu,\"gpu_frames\":%llu,\"frames_dropped\":%llu," "\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", g_dynres.active ? 1 : 0, dynres_requested(), st.ceiling, g_dynres.active ? r.floor : 0, st.level, st.level * g_video_ref_lines, r.forced, r.last_load, 1.0 - r.p.margin, r.last_bp_share, r.last_hz, g_dynres.rt_win_cpu_ms, g_dynres.rt_win_gpu_ms, r.last_guest_bound, r.last_over, r.f, g_dynres.hold_reason ? g_dynres.hold_reason : "", hold > 0.0 ? hold : 0.0, r.last_reason ? r.last_reason : "", r.downs, r.ups, r.undos, r.relapses, r.windows, r.held_windows, r.guest_bound_windows, r.thin_windows, (unsigned long long)st.steps, st.last_from, st.last_to, st.last_ms, (unsigned long long)co.frames, (unsigned long long)co.gpu_frames, (unsigned long long)co.dropped, r.down_block_until > now_s ? r.down_block_until - now_s : 0.0, blocked); } const DynresController &c = g_dynres.ctl; const double now_s = (double)SDL_GetPerformanceCounter() / (double)SDL_GetPerformanceFrequency(); char blocked[256]; int bp = 0; blocked[0] = 0; for (int l = c.floor; g_dynres.active && l <= c.ceiling && bp < (int)sizeof blocked - 24; l++) { double b = dynres_up_blocked_s(&c, l, now_s); if (b > 0.0) bp += std::snprintf(blocked + bp, sizeof blocked - (size_t)bp, "%s\"%d\":%.1f", bp ? "," : "", l, b); } double hold = c.hold_until - now_s; return std::snprintf(out, (size_t)cap, "\"enabled\":%d,\"requested\":%d,\"ceiling\":%d,\"floor\":%d,\"level\":%d," "\"internal_lines\":%d,\"forced\":%d,\"load\":%.3f,\"late\":%d,\"vblank_hz\":%.2f," "\"scaled_share\":%.3f,\"hold\":\"%s\",\"hold_s\":%.2f,\"last_reason\":\"%s\"," "\"downs\":%llu,\"ups\":%llu,\"undos\":%llu,\"relapses\":%llu,\"windows\":%llu," "\"held_windows\":%llu,\"steps\":%llu,\"deferred\":%llu,\"last_from\":%d," "\"last_to\":%d,\"last_ms\":%.3f,\"last_interval_ms\":%.3f,\"last_prep_ms\":%.3f," "\"last_seed_ms\":%.3f,\"last_rects_ms\":%.3f,\"last_wide_ms\":%.3f," "\"step_cost_ms\":%.3f,\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", g_dynres.active ? 1 : 0, dynres_requested(), st.ceiling, g_dynres.active ? c.floor : 0, st.level, st.level * g_video_ref_lines, c.forced, c.last_load, c.last_late, c.last_vblank_hz, c.f, g_dynres.hold_reason ? g_dynres.hold_reason : "", hold > 0.0 ? hold : 0.0, c.last_reason ? c.last_reason : "", c.downs, c.ups, c.undos, c.relapses, c.windows, c.held_windows, (unsigned long long)st.steps, (unsigned long long)st.deferred, st.last_from, st.last_to, st.last_ms, g_dynres.step_interval_ms, st.last_prep_ms, st.last_seed_ms, st.last_rects_ms, st.last_wide_ms, c.step_cost_s * 1000.0, c.down_block_until > now_s ? c.down_block_until - now_s : 0.0, blocked); } /* Render thread: start once, then close every frame (after its present, * before pacing, so the render thread draws while the guest waits). */ static void render_thread_vblank(void) { #ifndef PSX_SDL_NO_RENDER if (!g_render_thread) return; if (!s_render_thread_tried) { s_render_thread_tried = 1; if (g_gl_active && gr_backend() == GR_BACKEND_OPENGL && gl_renderer_render_thread_start(g_render_thread_frames)) { std::fprintf(stdout, "psxrecomp: render thread on (OpenGL, %d frame(s) in flight)\n", g_render_thread_frames); } else { std::fprintf(stdout, "psxrecomp: render thread requested but not started " "(needs the OpenGL backend without netplay, frame " "interpolation or a 24-bit display)\n"); } std::fflush(stdout); } gl_renderer_render_thread_frame_boundary(); #endif } static void sdl_vblank_present(void) { sync_guest_cadence_to_video_standard(); NetplayVblankEpilogue ep = sdl_vblank_present_body(); render_thread_vblank(); /* Selfcheck span-end rewind: after present-body C++ RAII, before any * further guest progress. Longjmps on success — keeps every resim load * on the same VBlank boundary (BB fast-poll tails forked #2 vs #3). */ psx_selfcheck_flush_load(); dynres_tick(); /* after the present: a scale step lands between frames */ if (!ep.do_epilogue) return; if (!psx_return_to_lobby_requested()) netplay_barrier_admit(ep.override); /* Episode baseline applied during admit without longjmp — resume now that * present-body C++ destructors have run (mirrors savestate BB-edge path). */ psx_netplay_rb_flush_resume(); if (ep.skip_pace || psx_return_to_lobby_requested()) return; if (psx_netplay_is_resimulating() || psx_netplay_rb_tip_holding()) return; if (!gpu_display_is_depth24() && psx_netplay_catchup_budget() > 0) { psx_netplay_catchup_consume_frame(); return; } uint64_t perf_start = runtime_perf_section_begin(); if (present_should_wall_pace()) 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); } /* game.toml [netplay] + last TOC fingerprint — shared by launcher verify and * the pre-psx_netplay_start gate (works with or without RECOMP_LAUNCHER). */ static PSXRecompV4::NetplayDiscExpect g_netplay_disc_expect{}; /* The netplay mount policy for ONE image. The policy is per-disc, not * per-build: the TOC fingerprint is the disc's own, and the same is true in * principle of required_tracks / required_leadout_lba (a set may mix a * CD-DA disc with a data-only one, and the lead-out LBA is literally the * disc's size). Resolving the whole struct per image -- rather than reaching * for the one global -- is what keeps those honest as soon as a title needs * them; today only the fingerprint has per-disc data to apply. * * A disc with no per-disc fingerprint keeps the flat [netplay] * required_disc_fp, so single-disc titles are unaffected. */ static PSXRecompV4::NetplayDiscExpect netplay_expect_for_disc( const std::filesystem::path& disc) { PSXRecompV4::NetplayDiscExpect e = g_netplay_disc_expect; if (!g_disc_netplay_fps.empty()) { const auto it = g_disc_netplay_fps.find(uppercase_ascii(disc.stem().string())); if (it != g_disc_netplay_fps.end()) e.required_disc_fp = it->second; } return e; } static std::string g_session_disc_fp; static std::filesystem::path g_launcher_disc_path; static bool g_session_netplay_disc_ok = false; #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; const char* g_lnch_argv0 = nullptr; bool g_lnch_netplay_available = false; /* True when this process can turn a player's retail dump into a linked * backend: the codegen host wired Generate (dev tree / setup kit), or * nothing is linked yet (setup host). A shipped bundled build is neither, * and must never answer a BIOS pick with "Generate & rebuild". */ bool g_lnch_can_regen = false; int ae_bios_verify(const char* bios_path, RecompLauncherCBiosVerify* out) { if (!out) return 0; std::memset(out, 0, sizeof(*out)); /* Empty path = use bundled OpenBIOS when this title allows it. * Never needs_regen: switching to OpenBIOS is always a hot-swap. */ if (!bios_path || !bios_path[0]) { out->needs_regen = 0; if (s_openbios_allowed && psx_bios_bundled()) { out->ok = 1; std::snprintf(out->detail, sizeof(out->detail), "Using bundled OpenBIOS."); return 1; } /* Setup host: no BIOS backends linked yet — first Generate for * game C will also emit OpenBIOS from redistributable openbios.bin. * Selecting OpenBIOS itself still does not require a BIOS regen. */ if (s_openbios_allowed && psx_bios_registry_count == 0) { /* Wizard may Continue; Play is blocked in resolve_bios_for_runtime * until the product build under build-release/ is linked. */ out->ok = 1; std::snprintf(out->detail, sizeof(out->detail), "OpenBIOS will be emitted on Generate & rebuild " "(optional: pick %s). Play uses " "build-release/ after rebuild.", psx_expected_bios_label()); return 1; } std::snprintf(out->detail, sizeof(out->detail), "PlayStation BIOS required (%s).", psx_expected_bios_label()); return 1; } /* Bundled build with only its shipped backend and no way to compile * another: a retail image can never be used here, whatever its CRC. * Say so before the identity checks, whose "this build expects * SCPH-1001" wording describes the pinned stem, not a linked backend. * (With the toolchain present a module CAN be built; that case falls * through to the identity check and the module branch below.) */ const bool lnch_module_ok = psx_bios_module_supported(exe_dir_from_argv( g_lnch_argv0 ? g_lnch_argv0 : "").string().c_str()) != 0; if (!g_lnch_can_regen && !lnch_module_ok && psx_bios_registry_count > 0 && !psx_bios_has_selectable()) { out->ok = 0; out->needs_regen = 0; std::snprintf(out->detail, sizeof(out->detail), "This build runs its bundled OpenBIOS only; a retail " "BIOS cannot be selected. Clear the BIOS field to play."); return 1; } /* Match runtime resolve: relative picks like bios/SCPH1001.BIN must not * depend on process cwd (build/ vs project root). Guard filesystem * exceptions so a bad picker path cannot take down the setup wizard. */ try { std::filesystem::path resolved = resolve_bios_path(bios_path, g_lnch_argv0 ? g_lnch_argv0 : ""); const std::string open_path = (!resolved.empty() ? resolved : std::filesystem::path(bios_path)) .string(); std::ifstream f(open_path, std::ios::binary | std::ios::ate); if (!f.is_open()) { std::snprintf(out->detail, sizeof(out->detail), "BIOS file not found."); return 1; } const PsxKnownBiosImage* want = psx_expected_bios(); const std::streamoff want_size = want ? (std::streamoff)want->size : (std::streamoff)(512 * 1024); const std::streamoff size = f.tellg(); if (size != want_size) { std::snprintf(out->detail, sizeof(out->detail), "BIOS must be exactly %lld bytes (got %lld). Use " "%s.", (long long)want_size, (long long)size, psx_expected_bios_label()); return 1; } std::vector data((size_t)size); if (!read_at(f, 0, data.data(), data.size())) { std::snprintf(out->detail, sizeof(out->detail), "Failed to read BIOS file."); return 1; } const uint32_t crc = crc32_compute(data.data(), data.size()); if (want && crc != want->crc32) { out->warn = 1; std::snprintf(out->detail, sizeof(out->detail), "CRC32 %08X (this build expects %s, CRC32 %08X).", crc, want->id, want->crc32); } else if (!want) { out->warn = 1; std::snprintf(out->detail, sizeof(out->detail), "CRC32 %08X (this build pins %s, whose identity " "is not recorded here).", crc, PSX_EXPECTED_BIOS_STEM); } else { std::snprintf(out->detail, sizeof(out->detail), "%s (CRC OK).", psx_expected_bios_label()); } /* Setup host (no backends yet): file is fine for first Generate. */ if (psx_bios_registry_count == 0) { out->ok = 1; return 1; } /* Built host: only accept images that have a linked backend. * Otherwise Play would load mismatched dispatch and crash. */ uint32_t match_crc = 0; uint64_t match_size = 0; if (bios_backend_for_file(open_path, &match_crc, &match_size)) { out->ok = 1; return 1; } out->ok = 0; if (lnch_module_ok && psx_bios_module_known_id(crc, (uint32_t)size)) { /* Buildable from this dump: usable, no Generate & rebuild. * The build itself runs at launch (validate_bios_for_launch). */ const std::string exe_dir = exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : "").string(); const bool cached = psx_bios_module_is_cached(open_path.c_str(), exe_dir.c_str()) != 0; out->warn = 0; if (cached) { out->ok = 1; out->needs_regen = 0; std::snprintf(out->detail, sizeof(out->detail), "%s (CRC OK, ready).", psx_bios_module_known_id(crc, (uint32_t)size)); } else { /* needs_regen + gi.bios_prepare_with_progress: the launcher * runs the build as a progress job (wizard or Play prompt) * and re-verifies; it never offers Generate & rebuild. */ out->ok = 0; out->needs_regen = 1; std::snprintf(out->detail, sizeof(out->detail), "%s (CRC OK). Prepare it once for this build " "(about a minute); then it is ready every launch.", psx_bios_module_known_id(crc, (uint32_t)size)); } } else if (g_lnch_can_regen) { out->needs_regen = 1; std::snprintf(out->detail, sizeof(out->detail), "This BIOS is not compiled into the current build. " "Generate & rebuild to switch (or use OpenBIOS)."); } else { out->needs_regen = 0; std::snprintf(out->detail, sizeof(out->detail), "This BIOS is not compiled into this build and " "cannot be added to it. Use one this build accepts, " "or clear the field for OpenBIOS."); } return 1; } catch (const std::exception& e) { std::snprintf(out->detail, sizeof(out->detail), "BIOS check failed: %s", e.what()); return 1; } catch (...) { std::snprintf(out->detail, sizeof(out->detail), "BIOS check failed."); return 1; } } /* Launcher BIOS prepare job (recomp-ui bios_prepare_with_progress): build * the module for the staged dump with progress, on the worker thread. The * launcher re-verifies afterwards; validate_bios_for_launch then finds the * cached module at Play. */ struct AeBiosPrepareCtx { RecompLauncherCPrepareProgressFn fn; void* ctx; }; void ae_bios_prepare_progress(void* ctx, float pct, const char* msg) { AeBiosPrepareCtx* c = (AeBiosPrepareCtx*)ctx; if (c && c->fn) c->fn(c->ctx, pct, msg); } int ae_bios_prepare(const char* bios_path, char* err_msg, size_t err_cap, RecompLauncherCPrepareProgressFn on_progress, void* progress_ctx) { if (err_msg && err_cap) err_msg[0] = '\0'; if (!bios_path || !bios_path[0]) { if (err_msg && err_cap) std::snprintf(err_msg, err_cap, "No BIOS selected."); return 0; } const std::string exe_dir = exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : "").string(); std::filesystem::path resolved = resolve_bios_path(bios_path, g_lnch_argv0 ? g_lnch_argv0 : ""); const std::string dump = (!resolved.empty() ? resolved : std::filesystem::path(bios_path)).string(); AeBiosPrepareCtx c{on_progress, progress_ctx}; psx_bios_module_set_progress(ae_bios_prepare_progress, &c); char err[256] = {0}; const PsxBiosBackend* m = psx_bios_module_acquire( dump.c_str(), exe_dir.c_str(), /*allow_build=*/1, err, sizeof(err)); psx_bios_module_set_progress(nullptr, nullptr); if (!m) { if (err_msg && err_cap) std::snprintf(err_msg, err_cap, "%s", err); return 0; } if (on_progress) on_progress(progress_ctx, 1.0f, "BIOS ready."); return 1; } int ae_prepare_disc(const char* source_path, char* out_disc_path, size_t out_cap, char* err_msg, size_t err_cap) { if (!source_path || !source_path[0] || !out_disc_path || out_cap == 0) return 0; out_disc_path[0] = '\0'; if (err_msg && err_cap) err_msg[0] = '\0'; namespace fs = std::filesystem; std::error_code ec; if (!fs::is_regular_file(source_path, ec)) { if (err_msg && err_cap) std::snprintf(err_msg, err_cap, "Source dump not found."); return 0; } const fs::path exe_dir = exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : ""); const fs::path root = find_upward(exe_dir, "tools/prepare_disc.py"); if (root.empty()) { if (err_msg && err_cap) std::snprintf(err_msg, err_cap, "tools/prepare_disc.py not found near the executable."); return 0; } const fs::path script = root / "tools" / "prepare_disc.py"; const fs::path out_dir = root / "motk"; fs::create_directories(out_dir, ec); /* Prefer python3; fall back to python (Windows). */ const char* py = "python3"; #if defined(_WIN32) /* On Windows `python` is the usual launcher; python3 may be absent. */ py = "python"; #endif std::string cmd = std::string(py) + " \"" + script.string() + "\" \"" + source_path + "\" --out-dir \"" + out_dir.string() + "\""; #if !defined(_WIN32) /* If python3 missing, retry with python. */ int rc = std::system(cmd.c_str()); if (rc != 0) { cmd = std::string("python \"") + script.string() + "\" \"" + source_path + "\" --out-dir \"" + out_dir.string() + "\""; rc = std::system(cmd.c_str()); } #else int rc = std::system(cmd.c_str()); #endif if (rc != 0) { if (err_msg && err_cap) std::snprintf(err_msg, err_cap, "prepare_disc.py failed (exit %d). Check the dump is " "2448 bytes/sector.", rc); return 0; } const fs::path cue = out_dir / "Star Wars - Masters of Teras Kasi (USA).cue"; const fs::path bin = out_dir / "Star Wars - Masters of Teras Kasi (USA).bin"; fs::path playable = fs::exists(cue, ec) ? cue : bin; if (!fs::exists(playable, ec)) { if (err_msg && err_cap) std::snprintf(err_msg, err_cap, "prepare_disc finished but no .cue/.bin was written."); return 0; } playable = normalize_disc_path_for_launch(playable); std::snprintf(out_disc_path, out_cap, "%s", playable.string().c_str()); return 1; } #if defined(RECOMP_LAUNCHER_HAS_SBI_STATUS) int ae_import_sbi(const char* disc, const char* sbi, char* out_disc, size_t out_cap, char* error, size_t error_cap) { try { const auto resolved = PSXRecompV4::resolve_disc_path(disc); const auto root = exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : "") / "sbi-input"; const auto imported = PSXRecompV4::import_sbi_setup(resolved.data, resolved.mount, sbi, root).string(); if (imported.size() >= out_cap) throw std::runtime_error("The imported disc path is too long."); std::snprintf(out_disc, out_cap, "%s", imported.c_str()); return 1; } catch (const std::exception& e) { if (error && error_cap) std::snprintf(error, error_cap, "%s", e.what()); return 0; } } #endif int ae_disc_verify(const char* disc_path, RecompLauncherCDiscVerify* out) { if (!disc_path || !disc_path[0] || !out) return 0; g_launcher_disc_path = disc_path; std::memset(out, 0, sizeof(*out)); /* Which serial THIS disc should carry -- the set's per-disc value, or * the game's own for a single-disc title. The expected CRC is disc * 1's and is only consulted when the game supplied one, so a set that * ships a CRC and lets the player pick disc 2 lands on the "warn" * verdict rather than "bad". */ const std::string expect_serial = expected_serial_for_disc(std::filesystem::path(disc_path), g_lnch_expected_serial); /* The netplay gate is the SELECTED disc's, for the same reason the * serial is -- see netplay_expect_for_disc(). */ const PSXRecompV4::NetplayDiscExpect np_expect = netplay_expect_for_disc(std::filesystem::path(disc_path)); PSXRecompV4::DiscIdentity id = PSXRecompV4::identify_disc( disc_path, expect_serial, g_lnch_expected_crc, g_lnch_has_crc, /*compute_crc*/ g_lnch_has_crc, g_lnch_netplay_available ? &np_expect : nullptr); const std::string& serial = !id.detected_serial.empty() ? id.detected_serial : expect_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; if (g_lnch_netplay_available) { out->track_count = id.track_count; out->netplay_ok = id.netplay_ok ? 1 : 0; std::snprintf(out->disc_fp, sizeof(out->disc_fp), "%s", id.disc_fp.c_str()); std::snprintf(out->netplay_detail, sizeof(out->netplay_detail), "%s", id.netplay_detail.c_str()); g_session_disc_fp = id.disc_fp; g_session_netplay_disc_ok = id.netplay_ok && !id.disc_fp.empty(); psx_lobby_set_disc_fp(id.disc_fp.c_str()); } else { out->netplay_ok = 1; g_session_disc_fp.clear(); g_session_netplay_disc_ok = false; } // Verdict shown by the launcher. TOC / [netplay] failures only affect // netplay-capable titles; ordinary offline disc verification is // strictly serial/header/optional-CRC based. 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 if (g_lnch_netplay_available && !id.netplay_ok) out->verdict = 2; // TOC/cue else out->verdict = 1; // ok const auto resolved = PSXRecompV4::resolve_disc_path(disc_path); const auto companion = PSXRecompV4::check_sbi_setup(resolved.data, resolved.mount); #if defined(RECOMP_LAUNCHER_HAS_SBI_STATUS) out->sbi_status = !companion.required ? RECOMP_SBI_NA : companion.ready ? RECOMP_SBI_OK : RECOMP_SBI_MISSING; #endif static std::string last_companion_warning; if (!companion.ready) { out->verdict = 3; // Block Play/Finish through the existing disc gate. if (last_companion_warning != companion.message) { last_companion_warning = companion.message; launcher_warning("SBI file required", companion.message); } } else { last_companion_warning.clear(); } 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; int g_lnch_game_players = 2; /* from game.toml; lobby max_slots default */ std::filesystem::path g_lnch_settings_path; std::string g_lnch_lobby_url; RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; int g_lnch_lobby_input_delay = 6; int g_lnch_lobby_input_prediction = 10; /* §108: online lobbies always SFU; force_input_relay is set from launch * relay_endpoint. force_turn is a rollback delay-floor hint only. */ int g_lnch_force_input_relay = 0; int g_lnch_force_turn = 0; /* Host relay (2026-10-01): online rooms this build hosts ask the server * for transport "host" (match_caps.relay). recomp-ui owns the setting * (Network Settings / Lobby Settings, persisted there) and pushes it * through relay_host_set; default on. */ int g_lnch_relay_host = 1; /* Lobby default on; host “Disable Rollback” clears this → delay_sync. */ int g_lnch_rollback = 1; int g_lnch_multitap_analog = 1; int g_lnch_host_max_slots = 2; /* Delay-sync READY/START waits for every seat in slot_count. Use seated * players (not lobby max_slots) so a 3/5 room can start. Sparse seats * (moved) still need width covering the highest occupied index. */ /* Session slot plan. The lobby host is ALWAYS session slot 0 -- the sim * authority every host-only path keys on -- whatever lobby seat it holds * after a swap or a move to the gallery; the other players follow in * lobby-seat order. Each drives the controller port of its LOBBY seat, so * the game sees players where the lobby seated them; a host in the * gallery drives no port. Every peer builds this from the same seat table * the start delivered, so they agree. * seats[] : occupied player seats, any order; host_seat among them or * -1 when the host is in the gallery * my_seat : this peer's player seat, or -1 (spectator) */ struct AeSlotPlan { int local_slot = -1; /* -1: not a player (spectator) */ int slot_count = 0; uint32_t occupied = 0; int port[RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS + 1]; }; static bool ae_np_plan_session_slots(const int* seats, int n, int host_seat, int my_seat, bool i_am_host, AeSlotPlan* out) { if (!out) return false; *out = AeSlotPlan{}; for (int& p : out->port) p = -1; const int cap = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS + 1; /* Slot 0: the host, with its seat's port (or none from the gallery). */ out->port[0] = host_seat; out->slot_count = 1; if (i_am_host) out->local_slot = 0; /* Then the others, by ascending seat. */ int sorted[RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS]; int m = 0; for (int i = 0; i < n && m < RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS; ++i) { if (seats[i] < 0 || seats[i] == host_seat) continue; bool dup = false; for (int j = 0; j < m; ++j) if (sorted[j] == seats[i]) dup = true; if (!dup) sorted[m++] = seats[i]; } for (int i = 1; i < m; ++i) for (int j = i; j > 0 && sorted[j - 1] > sorted[j]; --j) std::swap(sorted[j - 1], sorted[j]); for (int i = 0; i < m && out->slot_count < cap; ++i) { const int s = out->slot_count++; out->port[s] = sorted[i]; if (!i_am_host && my_seat == sorted[i]) out->local_slot = s; } if (out->slot_count < 2) out->slot_count = 2; out->occupied = (out->slot_count >= 32) ? 0xffffffffu : ((1u << out->slot_count) - 1u); return true; } static int ae_np_session_slot_count(int player_count, int max_slots, int local_slot, int game_fallback, int slot_cap) { int slots = player_count >= 2 ? player_count : (max_slots >= 2 ? max_slots : (game_fallback >= 2 ? game_fallback : 2)); if (local_slot + 1 > slots) slots = local_slot + 1; if (slots < 2) slots = 2; if (slots > slot_cap) slots = slot_cap; return slots; } bool g_lnch_hosting_lan = false; bool g_lnch_joined_lan = false; /* Join Direct / cross-machine: membership via UDP, not the local file. */ bool g_lnch_remote_lan = false; std::string g_lnch_lan_endpoint; uint32_t g_lnch_lan_session_id = 1; uint32_t g_lnch_lan_start_plan_session = 0; static constexpr int kAeLanMaxSlots = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS; struct AeLanLobbyState { std::string name; std::string game; std::string endpoint; std::string host_name; std::string joiner_name; /* legacy / first guest mirror */ std::string password; bool started = false; int host_slot = 0; int max_slots = 2; uint32_t session_id = 1; std::string slot_name[kAeLanMaxSlots]; std::string slot_id[kAeLanMaxSlots]; /* stable per-seat player id */ std::string mod_caps_json; /* host plan; local media stays on each peer */ }; AeLanLobbyState g_lnch_remote_lan_state{}; int g_lnch_lan_my_slot = -1; std::string g_lnch_lan_player_id; /* local process identity for LAN seats */ /* Per-seat BIOS offers for LAN settle (mirrors online bios_offer). */ struct AeLanSlotBios { int valid = 0; int prefer_openbios = 1; int can_openbios = 1; int can_scph1001 = 0; uint32_t retail_crc = 0; /* image this seat would boot; 0 = not sent */ }; AeLanSlotBios g_lnch_lan_slot_bios[kAeLanMaxSlots]{}; /* Match-only BIOS token from lobby settle or LAN START ("openbios"|"scph1001"). */ char g_lnch_session_bios[16]{}; /* With "scph1001": CRC-32 of the retail image every peer boots (0 = any, * from an older host). See netplay_bios_settle.h. */ uint32_t g_lnch_session_bios_crc = 0; /* Bring-your-own memory card (seat 1 / P2). Per-seat offers for LAN * (mirrors online memcard_offer); the local offer as last published by * the launcher UI; the host's allow flag; and the value settled at start * that every peer launches from. */ struct AeLanSlotMemcard { int valid = 0; int has_card = 0; int share = 0; }; AeLanSlotMemcard g_lnch_lan_slot_memcard[kAeLanMaxSlots]{}; PsxLobbyMemcardOffer g_lnch_memcard_offer{}; int g_lnch_guest_memcard = 1; /* host allow (host-local) */ int g_lnch_lan_guest_memcard_allow = 1; /* guest: host allow from MOTK5 UPDATE */ int g_lnch_lan_guest_memcard_active = 0; /* settled at LAN START */ static void ae_np_lan_send_memcard_offer_to_host(void); static void ae_np_lan_sync_local_slot_memcard(void); /* LAN lobby chat: the host is the room, so it owns the ring and relays * (MOTK5 CHATREQ guest→host, MOTK5 CHAT host→everyone). Online rooms use * the lobby client's ring (server echo) instead. */ static constexpr int kAeLanChatRing = 64; struct AeLanChatMsg { char player_id[48]; char from[64]; char text[256]; int is_system; }; AeLanChatMsg g_lnch_lan_chat[kAeLanChatRing]{}; int g_lnch_lan_chat_head = 0; int g_lnch_lan_chat_count = 0; uint32_t g_lnch_lan_chat_seq = 0; uint32_t g_lnch_lan_chat_seq_base = 0; /* seq of the oldest ring entry */ static void ae_np_lan_chat_clear(void) { g_lnch_lan_chat_head = 0; g_lnch_lan_chat_count = 0; g_lnch_lan_chat_seq_base = g_lnch_lan_chat_seq + 1; } static void ae_np_lan_chat_push(const char* player_id, const char* from, const char* text, int is_system) { if (!text || !text[0]) return; int idx; if (g_lnch_lan_chat_count < kAeLanChatRing) { idx = (g_lnch_lan_chat_head + g_lnch_lan_chat_count) % kAeLanChatRing; ++g_lnch_lan_chat_count; } else { idx = g_lnch_lan_chat_head; g_lnch_lan_chat_head = (g_lnch_lan_chat_head + 1) % kAeLanChatRing; ++g_lnch_lan_chat_seq_base; } AeLanChatMsg& m = g_lnch_lan_chat[idx]; m = {}; std::snprintf(m.player_id, sizeof(m.player_id), "%s", player_id ? player_id : ""); std::snprintf(m.from, sizeof(m.from), "%s", from ? from : ""); std::snprintf(m.text, sizeof(m.text), "%s", text); /* A LAN room has no server to mask for it: every peer masks the * line as it lands in the ring, the host included. */ #if defined(PSX_HAS_RECOMP_NET) if (!is_system) (void)rnet_chat_filter_apply(m.text, sizeof(m.text)); #endif m.is_system = is_system ? 1 : 0; ++g_lnch_lan_chat_seq; } /* One line only: the wire format is newline-delimited. */ static void ae_np_chat_sanitize(const char* in, char* out, size_t cap) { size_t o = 0; if (!out || cap == 0) return; for (; in && *in && o + 1 < cap; ++in) { const unsigned char c = (unsigned char)*in; if (c == '\n' || c == '\r') { out[o++] = ' '; continue; } if (c < 0x20) continue; out[o++] = (char)c; } out[o] = '\0'; } static void ae_np_lan_send_chat_to_peers(const char* player_id, const char* from, const char* text); /* Host: " " as a system line, into its own ring and to every * peer. Empty sender fields are what mark it as system on the wire. */ static void ae_np_lan_chat_announce(const char* name, const char* what) { char line[224]; if (!name || !name[0]) return; std::snprintf(line, sizeof(line), "%s %s", name, what); ae_np_lan_chat_push("", "", line, 1); ae_np_lan_send_chat_to_peers("", "", line); } static int ae_np_lan_occupied(const AeLanLobbyState& state); static int ae_np_lan_endpoint_port(const std::string& endpoint); static bool ae_np_read_lan_file_state(AeLanLobbyState* state); static void ae_np_set_session_bios_token(const char* token, uint32_t retail_crc = 0); static void ae_np_clear_session_bios_token(void); static void ae_np_lan_clear_slot_bios(int slot); static void ae_np_lan_store_slot_bios(int slot, int prefer_open, int can_open, int can_scph, uint32_t retail_crc = 0); static void ae_np_lan_sync_local_slot_bios(void); static int ae_np_lan_settle_session_bios(char* out, size_t out_cap, uint32_t* out_crc, char* why, size_t why_cap); static void ae_np_append_lan_bios_join(char* msg, size_t msg_cap, int* io_off); static int ae_np_parse_lan_bios_tail(char* p, int* prefer_open, int* can_open, int* can_scph); #ifdef _WIN32 using AeLanSock = SOCKET; static constexpr AeLanSock kAeLanSockInvalid = INVALID_SOCKET; #else using AeLanSock = int; static constexpr AeLanSock kAeLanSockInvalid = -1; #endif static void ae_np_lan_sock_close(AeLanSock* s); static bool ae_np_lan_set_nonblock(AeLanSock s); AeLanSock g_lnch_lan_udp = kAeLanSockInvalid; sockaddr_in g_lnch_lan_peers[kAeLanMaxSlots]{}; bool g_lnch_lan_peer_ok[kAeLanMaxSlots]{}; uint32_t g_lnch_lan_join_pulse_ms = 0; /* LAN list latency from last Refresh probe; -1 unknown. */ int g_lnch_lan_latency_ms = -1; /* Cross-machine LAN lobby discovery (UDP broadcast browse/beacon). * The on-disk netplay_lan_lobby.txt registry only works for same-cwd / * same-machine; remote peers discover hosts via MOTK1 BROWSE→BEACON. */ static constexpr int kAeLanDiscoverMax = 16; static constexpr int kAeLanBrowsePortLo = 7777; static constexpr int kAeLanBrowsePortHi = 7808; /* inclusive */ static constexpr uint32_t kAeLanDiscoverStaleMs = 8000u; struct AeLanDiscovered { char endpoint[64]; char name[64]; char game[64]; int player_count = 0; int max_slots = 2; int has_password = 0; int latency_ms = -1; int input_delay = 6; int input_prediction = 10; int rollback = 1; uint32_t session_id = 1; uint32_t last_seen_ms = 0; }; static AeLanDiscovered g_lnch_lan_discovered[kAeLanDiscoverMax]; static int g_lnch_lan_discovered_n = 0; static uint32_t g_lnch_lan_beacon_announce_ms = 0; /* The LAN room is a file. Two instances of one build only see the same * room if they read the same file, and the working directory is whatever * each was launched from, so the registry is anchored to the executable's * directory rather than to the cwd. */ std::filesystem::path ae_np_lan_file() { const auto exe = exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : ""); if (exe.empty()) return std::filesystem::current_path() / "netplay_lan_lobby.txt"; return exe / "netplay_lan_lobby.txt"; } static void ae_np_lan_sock_set_broadcast(AeLanSock s) { if (s == kAeLanSockInvalid) return; #ifdef _WIN32 BOOL on = TRUE; setsockopt(s, SOL_SOCKET, SO_BROADCAST, (const char*)&on, sizeof(on)); #else int on = 1; setsockopt(s, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on)); #endif } static void ae_np_lan_prune_discovered(void) { const uint32_t now = SDL_GetTicks(); int w = 0; for (int i = 0; i < g_lnch_lan_discovered_n; ++i) { if ((uint32_t)(now - g_lnch_lan_discovered[i].last_seen_ms) > kAeLanDiscoverStaleMs) continue; if (w != i) g_lnch_lan_discovered[w] = g_lnch_lan_discovered[i]; ++w; } g_lnch_lan_discovered_n = w; } static int ae_np_lan_discovered_find(const char* endpoint) { if (!endpoint || !endpoint[0]) return -1; for (int i = 0; i < g_lnch_lan_discovered_n; ++i) { if (std::strcmp(g_lnch_lan_discovered[i].endpoint, endpoint) == 0) return i; } return -1; } static void ae_np_lan_discovered_upsert(const AeLanDiscovered& in) { if (!in.endpoint[0]) return; const int idx = ae_np_lan_discovered_find(in.endpoint); if (idx >= 0) { AeLanDiscovered& e = g_lnch_lan_discovered[idx]; e = in; return; } if (g_lnch_lan_discovered_n >= kAeLanDiscoverMax) { /* Drop oldest. */ int oldest = 0; for (int i = 1; i < g_lnch_lan_discovered_n; ++i) { if (g_lnch_lan_discovered[i].last_seen_ms < g_lnch_lan_discovered[oldest].last_seen_ms) oldest = i; } g_lnch_lan_discovered[oldest] = in; return; } g_lnch_lan_discovered[g_lnch_lan_discovered_n++] = in; } static int ae_np_lan_format_beacon(char* buf, size_t cap, const AeLanLobbyState& st) { if (!buf || cap < 32) return -1; const int players = ae_np_lan_occupied(st); const int max_slots = st.max_slots >= 2 ? st.max_slots : 2; const int has_pw = st.password.empty() ? 0 : 1; const uint32_t sid = st.session_id ? st.session_id : 1u; int delay = g_lnch_lobby_input_delay; int pred = g_lnch_lobby_input_prediction; if (delay < 2) delay = 2; if (delay > 20) delay = 20; if (pred < 2) pred = 2; if (pred > 16) pred = 16; return std::snprintf( buf, cap, "MOTK1 BEACON\n%s\n%s\n%s\n%d\n%d\n%d\n%u\n%d\n%d\n%d\n", st.name.empty() ? "LAN Lobby" : st.name.c_str(), st.game.empty() ? "PSX" : st.game.c_str(), st.endpoint.c_str(), players, max_slots, has_pw, (unsigned)sid, delay, pred, g_lnch_rollback ? 1 : 0); } /* Parse BEACON; prefer reply source IP + advertised port for joinability. */ static int ae_np_lan_parse_beacon(char* buf, const sockaddr_in* from, int rtt_ms) { if (!buf || std::strncmp(buf, "MOTK1 BEACON\n", 13) != 0) return -1; char* p = buf + 13; char* lines[10] = {}; for (int i = 0; i < 10; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) { if (i < 6) return -1; break; } *nl = '\0'; p = nl + 1; } if (!lines[0] || !lines[1] || !lines[2]) return -1; AeLanDiscovered d{}; std::snprintf(d.name, sizeof(d.name), "%s", lines[0]); std::snprintf(d.game, sizeof(d.game), "%s", lines[1]); const int adv_port = ae_np_lan_endpoint_port(lines[2]); int port = adv_port > 0 ? adv_port : 7777; if (from) { char ip[64]; if (inet_ntop(AF_INET, &from->sin_addr, ip, sizeof(ip))) std::snprintf(d.endpoint, sizeof(d.endpoint), "%s:%d", ip, port); else std::snprintf(d.endpoint, sizeof(d.endpoint), "%s", lines[2]); } else { std::snprintf(d.endpoint, sizeof(d.endpoint), "%s", lines[2]); } d.player_count = lines[3] ? std::atoi(lines[3]) : 0; d.max_slots = lines[4] ? std::atoi(lines[4]) : 2; if (d.max_slots < 2) d.max_slots = 2; if (d.max_slots > kAeLanMaxSlots) d.max_slots = kAeLanMaxSlots; d.has_password = lines[5] ? (std::atoi(lines[5]) != 0) : 0; d.session_id = lines[6] ? (uint32_t)std::strtoul(lines[6], nullptr, 10) : 1u; if (!d.session_id) d.session_id = 1u; d.input_delay = lines[7] ? std::atoi(lines[7]) : 6; d.input_prediction = lines[8] ? std::atoi(lines[8]) : 10; d.rollback = lines[9] ? (std::atoi(lines[9]) != 0) : 1; if (d.input_delay < 2) d.input_delay = 2; if (d.input_delay > 20) d.input_delay = 20; if (d.input_prediction < 2) d.input_prediction = 2; if (d.input_prediction > 16) d.input_prediction = 16; d.latency_ms = rtt_ms; d.last_seen_ms = SDL_GetTicks(); ae_np_lan_discovered_upsert(d); return 0; } static void ae_np_lan_broadcast_msg(AeLanSock s, int port, const char* msg) { if (s == kAeLanSockInvalid || !msg || port <= 0 || port > 65535) return; sockaddr_in to{}; to.sin_family = AF_INET; to.sin_port = htons((uint16_t)port); to.sin_addr.s_addr = htonl(INADDR_BROADCAST); const int n = (int)std::strlen(msg); #ifdef _WIN32 sendto(s, msg, n, 0, (const sockaddr*)&to, sizeof(to)); #else sendto(s, msg, (size_t)n, 0, (const sockaddr*)&to, sizeof(to)); #endif } /* Async LAN browse: broadcast once, collect BEACONs across frames in * ae_np_lan_browse_pump (no SDL_Delay busy-wait on the UI thread). */ static AeLanSock g_lan_browse_sock = kAeLanSockInvalid; static uint32_t g_lan_browse_t0 = 0; static uint32_t g_lan_browse_deadline = 0; static int g_lan_browse_file_pending = 0; static void ae_np_lan_browse_close(void) { if (g_lan_browse_sock != kAeLanSockInvalid) ae_np_lan_sock_close(&g_lan_browse_sock); g_lan_browse_deadline = 0; g_lan_browse_t0 = 0; } static void ae_np_lan_browse_start(uint32_t wait_ms) { if (g_lan_browse_sock != kAeLanSockInvalid) return; /* already collecting */ #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); #endif AeLanSock s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (s == kAeLanSockInvalid) return; if (!ae_np_lan_set_nonblock(s)) { ae_np_lan_sock_close(&s); return; } ae_np_lan_sock_set_broadcast(s); sockaddr_in bind_addr{}; bind_addr.sin_family = AF_INET; bind_addr.sin_addr.s_addr = htonl(INADDR_ANY); bind_addr.sin_port = htons(0); if (bind(s, (sockaddr*)&bind_addr, sizeof(bind_addr)) != 0) { ae_np_lan_sock_close(&s); return; } const char browse[] = "MOTK1 BROWSE\n"; for (int port = kAeLanBrowsePortLo; port <= kAeLanBrowsePortHi; ++port) ae_np_lan_broadcast_msg(s, port, browse); g_lan_browse_sock = s; g_lan_browse_t0 = SDL_GetTicks(); g_lan_browse_deadline = g_lan_browse_t0 + (wait_ms ? wait_ms : 250u); } static void ae_np_lan_file_promote_without_rtt(void) { AeLanLobbyState st; if (!ae_np_read_lan_file_state(&st) || st.started) { g_lnch_lan_latency_ms = -1; return; } g_lnch_lan_latency_ms = -1; if (ae_np_lan_discovered_find(st.endpoint.c_str()) >= 0) return; AeLanDiscovered d{}; std::snprintf(d.endpoint, sizeof(d.endpoint), "%s", st.endpoint.c_str()); std::snprintf(d.name, sizeof(d.name), "%s", st.name.empty() ? "LAN Lobby" : st.name.c_str()); std::snprintf(d.game, sizeof(d.game), "%s", st.game.empty() ? "PSX" : st.game.c_str()); d.player_count = ae_np_lan_occupied(st); d.max_slots = st.max_slots >= 2 ? st.max_slots : 2; d.has_password = st.password.empty() ? 0 : 1; d.latency_ms = -1; d.input_delay = g_lnch_lobby_input_delay; d.input_prediction = g_lnch_lobby_input_prediction; d.rollback = g_lnch_rollback ? 1 : 0; d.session_id = st.session_id ? st.session_id : 1u; d.last_seen_ms = SDL_GetTicks(); ae_np_lan_discovered_upsert(d); } static void ae_np_lan_browse_pump(void) { if (g_lan_browse_sock == kAeLanSockInvalid) { if (g_lan_browse_file_pending) { g_lan_browse_file_pending = 0; if (!g_lnch_hosting_lan && !g_lnch_joined_lan) ae_np_lan_file_promote_without_rtt(); } return; } for (;;) { char buf[1024]; sockaddr_in from{}; #ifdef _WIN32 int fromlen = (int)sizeof(from); const int n = recvfrom(g_lan_browse_sock, buf, (int)sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); #else socklen_t fromlen = sizeof(from); const int n = (int)recvfrom(g_lan_browse_sock, buf, sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); #endif if (n <= 0) break; buf[n] = '\0'; const int rtt = (int)(SDL_GetTicks() - g_lan_browse_t0); (void)ae_np_lan_parse_beacon(buf, &from, rtt); } if ((int32_t)(g_lan_browse_deadline - SDL_GetTicks()) > 0) return; ae_np_lan_browse_close(); ae_np_lan_prune_discovered(); if (g_lan_browse_file_pending) { g_lan_browse_file_pending = 0; if (!g_lnch_hosting_lan && !g_lnch_joined_lan) ae_np_lan_file_promote_without_rtt(); } } static void ae_np_lan_apply_discovered_caps(const char* endpoint) { const int idx = ae_np_lan_discovered_find(endpoint); if (idx < 0) return; const AeLanDiscovered& d = g_lnch_lan_discovered[idx]; g_lnch_lobby_input_delay = d.input_delay; g_lnch_lobby_input_prediction = d.input_prediction; g_lnch_rollback = d.rollback ? 1 : 0; } static int ae_np_lan_fill_lobby_from_discovered( int discovered_index, RecompLauncherCNetplayLobby* out) { if (!out || discovered_index < 0 || discovered_index >= g_lnch_lan_discovered_n) return 0; const AeLanDiscovered& d = g_lnch_lan_discovered[discovered_index]; std::snprintf(out->lobby_id, sizeof(out->lobby_id), "lan:%s", d.endpoint); std::snprintf(out->name, sizeof(out->name), "LAN - %s", d.name[0] ? d.name : "Lobby"); std::snprintf(out->game_name, sizeof(out->game_name), "%s", d.game[0] ? d.game : "PSX"); out->game_version[0] = '\0'; out->player_count = d.player_count; out->max_slots = d.max_slots; out->has_password = d.has_password; out->latency_ms = d.latency_ms; return 1; } static void ae_np_lan_sock_close(AeLanSock* s) { if (!s || *s == kAeLanSockInvalid) return; #ifdef _WIN32 closesocket(*s); #else close(*s); #endif *s = kAeLanSockInvalid; } static void ae_np_lan_udp_close(void) { ae_np_lan_sock_close(&g_lnch_lan_udp); for (int i = 0; i < kAeLanMaxSlots; ++i) g_lnch_lan_peer_ok[i] = false; } static void ae_np_lan_sync_legacy_names(AeLanLobbyState& state) { if (state.max_slots < 2) state.max_slots = 2; if (state.max_slots > kAeLanMaxSlots) state.max_slots = kAeLanMaxSlots; if (state.host_slot < 0 || state.host_slot >= state.max_slots) state.host_slot = 0; if (!state.slot_name[state.host_slot].empty()) state.host_name = state.slot_name[state.host_slot]; else if (!state.host_name.empty()) state.slot_name[state.host_slot] = state.host_name; state.joiner_name.clear(); for (int i = 0; i < state.max_slots; ++i) { if (i == state.host_slot) continue; if (!state.slot_name[i].empty()) { state.joiner_name = state.slot_name[i]; break; } } } static int ae_np_lan_occupied(const AeLanLobbyState& state) { int n = 0; for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) if (!state.slot_name[i].empty()) ++n; return n; } static int ae_np_lan_find_free_slot(const AeLanLobbyState& state) { for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) { if (i == state.host_slot) continue; if (state.slot_name[i].empty()) return i; } return -1; } /* Guest seat by display name. Never match host_slot — same-machine * instances often share settings.toml player_name, and reclaiming the * host seat made JOIN "succeed" with no visible joiner. */ static int ae_np_lan_find_guest_slot_by_name(const AeLanLobbyState& state, const char* name) { if (!name || !name[0]) return -1; for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) { if (i == state.host_slot) continue; if (state.slot_name[i] == name) return i; } return -1; } static int ae_np_lan_find_slot_by_id(const AeLanLobbyState& state, const char* player_id) { if (!player_id || !player_id[0]) return -1; for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) { if (state.slot_id[i] == player_id) return i; } return -1; } /* Stable per-process LAN identity (prefer WS player_id when connected). */ static const char* ae_np_lan_local_player_id(void) { if (!g_lnch_lan_player_id.empty()) return g_lnch_lan_player_id.c_str(); const char* ws = psx_lobby_player_id(); if (ws && ws[0]) { g_lnch_lan_player_id = ws; return g_lnch_lan_player_id.c_str(); } unsigned char b[16]; bool ok = false; #if !defined(_WIN32) FILE* ur = std::fopen("/dev/urandom", "rb"); if (ur) { ok = std::fread(b, 1, sizeof(b), ur) == sizeof(b); std::fclose(ur); } #endif if (!ok) { uint64_t t = (uint64_t)SDL_GetTicks() ^ ((uint64_t)(uintptr_t)&g_lnch_lan_player_id << 32) ^ ((uint64_t)SDL_GetPerformanceCounter() << 17); for (size_t i = 0; i < sizeof(b); ++i) { t = t * 6364136223846793005ull + 1ull; b[i] = (unsigned char)(t >> 56); } } char hex[40]; std::snprintf(hex, sizeof(hex), "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]); g_lnch_lan_player_id = hex; return g_lnch_lan_player_id.c_str(); } /* Exact-name uniqueness among seats: Alex, Alex (2), Alex (3), … */ static std::string ae_np_lan_unique_display_name(const AeLanLobbyState& state, const char* requested, int skip_slot) { std::string base = (requested && requested[0]) ? requested : "Player"; auto taken = [&](const std::string& n) { for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) { if (i == skip_slot) continue; if (state.slot_name[i] == n) return true; } return false; }; if (!taken(base)) return base; for (int n = 2; n <= 64; ++n) { char cand[96]; std::snprintf(cand, sizeof(cand), "%s (%d)", base.c_str(), n); if (!taken(cand)) return cand; } return base + " (" + std::string(ae_np_lan_local_player_id()).substr(0, 8) + ")"; } /* Seat or reconnect a guest by player_id; uniquify display name on insert. */ static int ae_np_lan_seat_guest(AeLanLobbyState& st, const char* player_id, const char* requested_name) { if (!player_id || !player_id[0]) return -1; int slot = ae_np_lan_find_slot_by_id(st, player_id); if (slot >= 0) { if (slot == st.host_slot) return -1; st.slot_name[slot] = ae_np_lan_unique_display_name(st, requested_name, slot); return slot; } slot = ae_np_lan_find_free_slot(st); if (slot < 0) return -1; st.slot_id[slot] = player_id; st.slot_name[slot] = ae_np_lan_unique_display_name(st, requested_name, -1); return slot; } static void ae_np_lan_clear_peer_slot(int slot) { if (slot < 0 || slot >= kAeLanMaxSlots) return; g_lnch_lan_peer_ok[slot] = false; } static void ae_np_lan_set_peer_slot(int slot, const sockaddr_in& addr) { if (slot < 0 || slot >= kAeLanMaxSlots) return; g_lnch_lan_peers[slot] = addr; g_lnch_lan_peer_ok[slot] = true; } static int ae_np_lan_endpoint_port(const std::string& endpoint) { const size_t colon = endpoint.rfind(':'); if (colon == std::string::npos) return 7777; const int p = std::atoi(endpoint.c_str() + colon + 1); return (p > 0 && p <= 65535) ? p : 7777; } static bool ae_np_lan_endpoint_host(const std::string& endpoint, char* host, size_t host_len) { if (!host || host_len == 0) return false; const size_t colon = endpoint.rfind(':'); if (colon == std::string::npos || colon == 0) return false; if (colon >= host_len) return false; std::memcpy(host, endpoint.data(), colon); host[colon] = '\0'; return host[0] != '\0'; } static bool ae_np_lan_set_nonblock(AeLanSock s) { #ifdef _WIN32 u_long mode = 1; return ioctlsocket(s, FIONBIO, &mode) == 0; #else const int fl = fcntl(s, F_GETFL, 0); return fl >= 0 && fcntl(s, F_SETFL, fl | O_NONBLOCK) == 0; #endif } /* Exclusive bind probe (no SO_REUSEADDR) so a busy port is detected. */ static bool ae_np_udp_port_available(int port) { if (port <= 0 || port > 65535) return false; #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); #endif AeLanSock s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (s == kAeLanSockInvalid) return false; sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons((uint16_t)port); const bool ok = (bind(s, (sockaddr*)&addr, sizeof(addr)) == 0); ae_np_lan_sock_close(&s); return ok; } /* Online create: try preferred, then the next few ports. */ static int ae_np_find_free_udp_port(int preferred) { if (preferred <= 0 || preferred > 65535) preferred = 7777; for (int i = 0; i < 32; ++i) { const int p = preferred + i; if (p > 65535) break; if (ae_np_udp_port_available(p)) return p; } return -1; } static bool ae_np_endpoint_replace_port(char* endpoint, size_t cap, int port) { if (!endpoint || cap < 4 || port <= 0 || port > 65535) return false; char host[64]; if (!ae_np_lan_endpoint_host(endpoint, host, sizeof(host))) { if (endpoint[0] == ':' || !endpoint[0]) std::snprintf(host, sizeof(host), "0.0.0.0"); else return false; } const int n = std::snprintf(endpoint, cap, "%s:%d", host, port); return n > 0 && (size_t)n < cap; } static bool ae_np_lan_udp_ensure(bool bind_port, int port) { #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); #endif if (g_lnch_lan_udp == kAeLanSockInvalid) { g_lnch_lan_udp = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (g_lnch_lan_udp == kAeLanSockInvalid) return false; if (!ae_np_lan_set_nonblock(g_lnch_lan_udp)) { ae_np_lan_udp_close(); return false; } /* Do not SO_REUSEADDR on the host lobby port — a second host on the * same port must fail so we can surface "port in use". */ if (bind_port) { sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons((uint16_t)port); if (bind(g_lnch_lan_udp, (sockaddr*)&addr, sizeof(addr)) != 0) { ae_np_lan_udp_close(); return false; } } } return true; } static void ae_np_lan_udp_sendto(const sockaddr_in& to, const char* msg) { if (g_lnch_lan_udp == kAeLanSockInvalid || !msg) return; const int n = (int)std::strlen(msg); #ifdef _WIN32 sendto(g_lnch_lan_udp, msg, n, 0, (const sockaddr*)&to, sizeof(to)); #else sendto(g_lnch_lan_udp, msg, (size_t)n, 0, (const sockaddr*)&to, sizeof(to)); #endif } bool ae_np_read_lan_state(AeLanLobbyState* state) { if (!state) return false; if (g_lnch_remote_lan) { *state = g_lnch_remote_lan_state; ae_np_lan_sync_legacy_names(*state); return !state->endpoint.empty(); } std::ifstream f(ae_np_lan_file()); if (!f) return false; std::string started, host_slot, session, max_slots_s; 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); std::getline(f, session); state->started = started == "1"; state->host_slot = std::atoi(host_slot.c_str()); if (state->host_slot < 0) state->host_slot = 0; state->session_id = 1; if (!session.empty()) { const unsigned v = (unsigned)std::strtoul(session.c_str(), nullptr, 10); if (v) state->session_id = (uint32_t)v; } state->max_slots = 2; for (int i = 0; i < kAeLanMaxSlots; ++i) { state->slot_name[i].clear(); state->slot_id[i].clear(); } if (std::getline(f, max_slots_s)) { int ms = std::atoi(max_slots_s.c_str()); if (ms >= 2 && ms <= kAeLanMaxSlots) state->max_slots = ms; for (int i = 0; i < state->max_slots; ++i) std::getline(f, state->slot_name[i]); for (int i = 0; i < state->max_slots; ++i) { if (!std::getline(f, state->slot_id[i])) state->slot_id[i].clear(); } } else { /* Legacy 2P file: host + single joiner. */ state->slot_name[state->host_slot == 1 ? 1 : 0] = state->host_name; const int guest = state->host_slot == 1 ? 0 : 1; state->slot_name[guest] = state->joiner_name; } ae_np_lan_sync_legacy_names(*state); std::getline(f, state->mod_caps_json); return !state->endpoint.empty(); } bool ae_np_write_lan_state(const AeLanLobbyState& state_in) { AeLanLobbyState state = state_in; ae_np_lan_sync_legacy_names(state); if (g_lnch_remote_lan) { g_lnch_remote_lan_state = state; return true; } std::ofstream f(ae_np_lan_file(), std::ios::trunc); if (!f) return false; const uint32_t sid = state.session_id ? state.session_id : 1u; 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" << sid << "\n" << state.max_slots << "\n"; for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) f << state.slot_name[i] << "\n"; for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) f << state.slot_id[i] << "\n"; if (!state.mod_caps_json.empty()) f << state.mod_caps_json << "\n"; return (bool)f; } static void ae_np_lan_send_update_to_peers(const AeLanLobbyState& state_in) { AeLanLobbyState state = state_in; ae_np_lan_sync_legacy_names(state); if (g_lnch_hosting_lan) ae_np_lan_sync_local_slot_bios(); char msg3[1536]; int off3 = std::snprintf(msg3, sizeof(msg3), "MOTK3 UPDATE\n%d\n%d\n%d\n%u\n", state.max_slots, state.host_slot, state.started ? 1 : 0, (unsigned)(state.session_id ? state.session_id : 1u)); for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots && off3 > 0 && off3 < (int)sizeof(msg3) - 128; ++i) { off3 += std::snprintf(msg3 + off3, sizeof(msg3) - (size_t)off3, "%s\n%s\n", state.slot_id[i].c_str(), state.slot_name[i].c_str()); } /* MOTK4 carries per-seat bios_offer so guests can preview settle. */ char msg4[2048]; int off4 = std::snprintf(msg4, sizeof(msg4), "MOTK4 UPDATE\n%d\n%d\n%d\n%u\n", state.max_slots, state.host_slot, state.started ? 1 : 0, (unsigned)(state.session_id ? state.session_id : 1u)); for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots && off4 > 0 && off4 < (int)sizeof(msg4) - 160; ++i) { const AeLanSlotBios& b = g_lnch_lan_slot_bios[i]; off4 += std::snprintf( msg4 + off4, sizeof(msg4) - (size_t)off4, "%s\n%s\n%d\n%d\n%d\n%d\n", state.slot_id[i].c_str(), state.slot_name[i].c_str(), b.valid ? 1 : 0, b.prefer_openbios ? 1 : 0, b.can_openbios ? 1 : 0, b.can_scph1001 ? 1 : 0); } /* MOTK5: MOTK4 + host memcard allow in the header + per-seat * memcard offer (valid/has_card/share). Newer guests take this one * and ignore MOTK3/4; older guests never see the extra lines. */ char msg5[2304]; int off5 = std::snprintf(msg5, sizeof(msg5), "MOTK5 UPDATE\n%d\n%d\n%d\n%u\n%d\n", state.max_slots, state.host_slot, state.started ? 1 : 0, (unsigned)(state.session_id ? state.session_id : 1u), g_lnch_guest_memcard ? 1 : 0); for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots && off5 > 0 && off5 < (int)sizeof(msg5) - 176; ++i) { const AeLanSlotBios& b = g_lnch_lan_slot_bios[i]; const AeLanSlotMemcard& mc = g_lnch_lan_slot_memcard[i]; off5 += std::snprintf( msg5 + off5, sizeof(msg5) - (size_t)off5, "%s\n%s\n%d\n%d\n%d\n%d\n%d\n%d\n%d\n", state.slot_id[i].c_str(), state.slot_name[i].c_str(), b.valid ? 1 : 0, b.prefer_openbios ? 1 : 0, b.can_openbios ? 1 : 0, b.can_scph1001 ? 1 : 0, mc.valid ? 1 : 0, mc.has_card ? 1 : 0, mc.share ? 1 : 0); } if (off3 <= 0 && off4 <= 0 && off5 <= 0) return; for (int i = 0; i < kAeLanMaxSlots; ++i) { if (!g_lnch_lan_peer_ok[i]) continue; if (!state.mod_caps_json.empty()) { const std::string plan = "MOTK6 MODS\n" + std::to_string(state.session_id) + "\n" + state.mod_caps_json; ae_np_lan_udp_sendto(g_lnch_lan_peers[i], plan.c_str()); } if (off3 > 0) ae_np_lan_udp_sendto(g_lnch_lan_peers[i], msg3); if (off4 > 0) ae_np_lan_udp_sendto(g_lnch_lan_peers[i], msg4); if (off5 > 0) ae_np_lan_udp_sendto(g_lnch_lan_peers[i], msg5); } } static void ae_np_lan_atexit_cleanup(void) { if (!g_lnch_hosting_lan) return; std::error_code ec; std::filesystem::remove(ae_np_lan_file(), ec); g_lnch_hosting_lan = false; } /* Returns false if the lobby UDP port cannot be bound (in use). */ bool ae_np_write_lan_lobby(const char* name, const char* endpoint, const char* password, int max_slots) { ae_np_lan_udp_close(); g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_my_slot = 0; 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 : ""; if (max_slots < 2) max_slots = 2; if (max_slots > kAeLanMaxSlots) max_slots = kAeLanMaxSlots; state.max_slots = max_slots; state.host_slot = 0; state.slot_name[0] = state.host_name; state.slot_id[0] = ae_np_lan_local_player_id(); const int port = ae_np_lan_endpoint_port(state.endpoint); if (!ae_np_udp_port_available(port) || !ae_np_lan_udp_ensure(true, port)) { ae_np_lan_udp_close(); return false; } if (!ae_np_write_lan_state(state)) { ae_np_lan_udp_close(); return false; } g_lnch_hosting_lan = true; g_lnch_joined_lan = false; g_lnch_lan_endpoint = state.endpoint; for (int i = 0; i < kAeLanMaxSlots; ++i) g_lnch_lan_slot_bios[i] = {}; ae_np_clear_session_bios_token(); ae_np_lan_sync_local_slot_bios(); static bool atexit_hooked = false; if (!atexit_hooked) { std::atexit(ae_np_lan_atexit_cleanup); atexit_hooked = true; } return true; } 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 = ae_np_lan_occupied(state); out->max_slots = state.max_slots >= 2 ? state.max_slots : (g_lnch_host_max_slots >= 2 ? g_lnch_host_max_slots : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2)); if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; if (out->max_slots > kAeLanMaxSlots) out->max_slots = kAeLanMaxSlots; out->has_password = state.password.empty() ? 0 : 1; out->latency_ms = g_lnch_hosting_lan ? 0 : g_lnch_lan_latency_ms; return 1; } static bool ae_np_remote_has_same_name_as_lan(const char* lan_display_name) { static constexpr const char kLanPrefix[] = "LAN - "; const char* base = lan_display_name; if (base && std::strncmp(base, kLanPrefix, sizeof(kLanPrefix) - 1) == 0) base += sizeof(kLanPrefix) - 1; if (!base || !base[0]) return false; for (int i = 0; i < psx_lobby_list_count(); ++i) { PsxLobbyRow row{}; if (!psx_lobby_list_get(i, &row)) continue; if (std::strcmp(row.name, base) == 0) return true; } return false; } static bool ae_np_read_lan_file_state(AeLanLobbyState* state) { if (!state) return false; const bool prior = g_lnch_remote_lan; g_lnch_remote_lan = false; const bool ok = ae_np_read_lan_state(state); g_lnch_remote_lan = prior; return ok; } /* Probe LAN host; returns RTT ms, or -1 on timeout/failure. */ static int ae_np_lan_probe_rtt_ms(const std::string& endpoint, uint32_t timeout_ms) { char host[64]; if (!ae_np_lan_endpoint_host(endpoint, host, sizeof(host))) return -1; #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); #endif AeLanSock s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (s == kAeLanSockInvalid) return -1; if (!ae_np_lan_set_nonblock(s)) { ae_np_lan_sock_close(&s); return -1; } sockaddr_in to{}; to.sin_family = AF_INET; to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(endpoint)); if (inet_pton(AF_INET, host, &to.sin_addr) != 1) { ae_np_lan_sock_close(&s); return -1; } const char ping[] = "MOTK1 PING\n"; const uint32_t t0 = SDL_GetTicks(); #ifdef _WIN32 sendto(s, ping, (int)sizeof(ping) - 1, 0, (const sockaddr*)&to, sizeof(to)); #else sendto(s, ping, sizeof(ping) - 1, 0, (const sockaddr*)&to, sizeof(to)); #endif const uint32_t deadline = t0 + timeout_ms; int rtt = -1; while ((int32_t)(deadline - SDL_GetTicks()) > 0) { char buf[64]; sockaddr_in from{}; #ifdef _WIN32 int fromlen = (int)sizeof(from); const int n = recvfrom(s, buf, (int)sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); #else socklen_t fromlen = sizeof(from); const int n = (int)recvfrom(s, buf, sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); #endif if (n > 0) { buf[n] = '\0'; if (std::strncmp(buf, "MOTK1 PONG", 10) == 0) { rtt = (int)(SDL_GetTicks() - t0); break; } } SDL_Delay(5); } ae_np_lan_sock_close(&s); return rtt; } static bool ae_np_lan_probe_host_ms(const std::string& endpoint, uint32_t timeout_ms) { return ae_np_lan_probe_rtt_ms(endpoint, timeout_ms) >= 0; } static bool ae_np_lan_probe_host(const std::string& endpoint) { return ae_np_lan_probe_host_ms(endpoint, 200u); } static int ae_np_lan_parse_motk2_update(char* body, AeLanLobbyState* out) { if (!body || !out) return -1; char* lines[8] = {}; char* p = body; for (int i = 0; i < 4; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; p = nl + 1; } int max_slots = std::atoi(lines[0]); int host_slot = std::atoi(lines[1]); int started = std::atoi(lines[2]); unsigned sid = (unsigned)std::strtoul(lines[3], nullptr, 10); if (max_slots < 2) max_slots = 2; if (max_slots > kAeLanMaxSlots) max_slots = kAeLanMaxSlots; if (host_slot < 0 || host_slot >= max_slots) host_slot = 0; out->max_slots = max_slots; out->host_slot = host_slot; out->started = started != 0; out->session_id = sid ? (uint32_t)sid : 1u; for (int i = 0; i < kAeLanMaxSlots; ++i) { out->slot_name[i].clear(); out->slot_id[i].clear(); } for (int i = 0; i < max_slots; ++i) { char* nl = std::strchr(p, '\n'); if (nl) { *nl = '\0'; out->slot_name[i] = p; p = nl + 1; } else { out->slot_name[i] = p; p = p + std::strlen(p); } } ae_np_lan_sync_legacy_names(*out); return 0; } /* MOTK4 UPDATE: MOTK3 fields + per-slot bios_offer (valid/prefer/can_*). */ static int ae_np_lan_parse_motk4_update(char* body, AeLanLobbyState* out) { if (!body || !out) return -1; char* lines[8] = {}; char* p = body; for (int i = 0; i < 4; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; p = nl + 1; } int max_slots = std::atoi(lines[0]); int host_slot = std::atoi(lines[1]); int started = std::atoi(lines[2]); unsigned sid = (unsigned)std::strtoul(lines[3], nullptr, 10); if (max_slots < 2) max_slots = 2; if (max_slots > kAeLanMaxSlots) max_slots = kAeLanMaxSlots; if (host_slot < 0 || host_slot >= max_slots) host_slot = 0; out->max_slots = max_slots; out->host_slot = host_slot; out->started = started != 0; out->session_id = sid ? (uint32_t)sid : 1u; for (int i = 0; i < kAeLanMaxSlots; ++i) { out->slot_name[i].clear(); out->slot_id[i].clear(); g_lnch_lan_slot_bios[i] = {}; } for (int i = 0; i < max_slots; ++i) { char* nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; out->slot_id[i] = p; p = nl + 1; nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; out->slot_name[i] = p; p = nl + 1; int bios_lines[4] = {}; for (int b = 0; b < 4; ++b) { nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; bios_lines[b] = std::atoi(p); p = nl + 1; } if (bios_lines[0]) { ae_np_lan_store_slot_bios(i, bios_lines[1], bios_lines[2], bios_lines[3]); } } ae_np_lan_sync_legacy_names(*out); return 0; } /* MOTK5 UPDATE: MOTK4 + host memcard allow (header line 5) + per-slot * memcard offer (valid/has_card/share) after the bios lines. */ static int ae_np_lan_parse_motk5_update(char* body, AeLanLobbyState* out) { if (!body || !out) return -1; char* lines[8] = {}; char* p = body; for (int i = 0; i < 5; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; p = nl + 1; } int max_slots = std::atoi(lines[0]); int host_slot = std::atoi(lines[1]); int started = std::atoi(lines[2]); unsigned sid = (unsigned)std::strtoul(lines[3], nullptr, 10); const int allow = std::atoi(lines[4]) != 0; if (max_slots < 2) max_slots = 2; if (max_slots > kAeLanMaxSlots) max_slots = kAeLanMaxSlots; if (host_slot < 0 || host_slot >= max_slots) host_slot = 0; out->max_slots = max_slots; out->host_slot = host_slot; out->started = started != 0; out->session_id = sid ? (uint32_t)sid : 1u; for (int i = 0; i < kAeLanMaxSlots; ++i) { out->slot_name[i].clear(); out->slot_id[i].clear(); g_lnch_lan_slot_bios[i] = {}; g_lnch_lan_slot_memcard[i] = {}; } for (int i = 0; i < max_slots; ++i) { char* nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; out->slot_id[i] = p; p = nl + 1; nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; out->slot_name[i] = p; p = nl + 1; int v[7] = {}; for (int b = 0; b < 7; ++b) { nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; v[b] = std::atoi(p); p = nl + 1; } if (v[0]) ae_np_lan_store_slot_bios(i, v[1], v[2], v[3]); if (v[4]) { g_lnch_lan_slot_memcard[i].valid = 1; g_lnch_lan_slot_memcard[i].has_card = v[5] ? 1 : 0; g_lnch_lan_slot_memcard[i].share = v[6] ? 1 : 0; } } g_lnch_lan_guest_memcard_allow = allow; ae_np_lan_sync_legacy_names(*out); return 0; } /* MOTK3 UPDATE: header + (player_id, display_name) per slot. */ static int ae_np_lan_parse_motk3_update(char* body, AeLanLobbyState* out) { if (!body || !out) return -1; char* lines[8] = {}; char* p = body; for (int i = 0; i < 4; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; p = nl + 1; } int max_slots = std::atoi(lines[0]); int host_slot = std::atoi(lines[1]); int started = std::atoi(lines[2]); unsigned sid = (unsigned)std::strtoul(lines[3], nullptr, 10); if (max_slots < 2) max_slots = 2; if (max_slots > kAeLanMaxSlots) max_slots = kAeLanMaxSlots; if (host_slot < 0 || host_slot >= max_slots) host_slot = 0; out->max_slots = max_slots; out->host_slot = host_slot; out->started = started != 0; out->session_id = sid ? (uint32_t)sid : 1u; for (int i = 0; i < kAeLanMaxSlots; ++i) { out->slot_name[i].clear(); out->slot_id[i].clear(); } for (int i = 0; i < max_slots; ++i) { char* nl = std::strchr(p, '\n'); if (!nl) return -1; *nl = '\0'; out->slot_id[i] = p; p = nl + 1; nl = std::strchr(p, '\n'); if (nl) { *nl = '\0'; out->slot_name[i] = p; p = nl + 1; } else { out->slot_name[i] = p; p = p + std::strlen(p); } } ae_np_lan_sync_legacy_names(*out); return 0; } /* Send JOIN and wait for UPDATE / ERR. Returns 0, -1 full, -2 password, -3 timeout. */ static int ae_np_lan_wait_join_ack(const std::string& endpoint, const char* password, AeLanLobbyState* out) { if (!out) return -1; char host[64]; if (!ae_np_lan_endpoint_host(endpoint, host, sizeof(host))) return -3; if (!ae_np_lan_udp_ensure(false, 0)) return -3; sockaddr_in to{}; to.sin_family = AF_INET; to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(endpoint)); if (inet_pton(AF_INET, host, &to.sin_addr) != 1) return -3; std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; const char* my_id = ae_np_lan_local_player_id(); char msg[448]; int off = std::snprintf(msg, sizeof(msg), "MOTK3 JOIN\n%s\n%s\n%s\n", my_id, me.c_str(), password ? password : ""); ae_np_append_lan_bios_join(msg, sizeof(msg), &off); ae_np_lan_udp_sendto(to, msg); const uint32_t deadline = SDL_GetTicks() + 1000u; while ((int32_t)(deadline - SDL_GetTicks()) > 0) { char buf[2560]; /* MOTK5 UPDATE: 8 seats x (id, name, 7 ints) */ sockaddr_in from{}; #ifdef _WIN32 int fromlen = (int)sizeof(from); const int n = recvfrom(g_lnch_lan_udp, buf, (int)sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); #else socklen_t fromlen = sizeof(from); const int n = (int)recvfrom(g_lnch_lan_udp, buf, sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); #endif if (n <= 0) { SDL_Delay(5); continue; } buf[n] = '\0'; if (std::strncmp(buf, "MOTK1 ERR\n", 10) == 0) { const char* code = buf + 10; if (std::strncmp(code, "bad_password", 12) == 0) return -2; return -1; } if (std::strncmp(buf, "MOTK3 UPDATE\n", 13) == 0) { if (ae_np_lan_parse_motk3_update(buf + 13, out) != 0) continue; out->endpoint = endpoint; const int my_slot = ae_np_lan_find_slot_by_id(*out, my_id); if (my_slot < 0) return -1; g_lnch_lan_my_slot = my_slot; return 0; } if (std::strncmp(buf, "MOTK2 UPDATE\n", 13) == 0) { if (ae_np_lan_parse_motk2_update(buf + 13, out) != 0) continue; out->endpoint = endpoint; /* Legacy host: uniquified name may differ from requested. */ int my_slot = ae_np_lan_find_guest_slot_by_name(*out, me.c_str()); if (my_slot < 0) { for (int i = 0; i < out->max_slots; ++i) { if (i == out->host_slot) continue; if (!out->slot_name[i].empty() && out->slot_name[i].rfind(me, 0) == 0) { my_slot = i; break; } } } if (my_slot < 0) return -1; g_lnch_lan_my_slot = my_slot; return 0; } if (std::strncmp(buf, "MOTK1 UPDATE\n", 13) == 0) { char* p = buf + 13; char* lines[4] = {}; for (int i = 0; i < 4; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) break; *nl = '\0'; p = nl + 1; } if (!lines[0] || !lines[1] || !lines[2] || !lines[3]) continue; out->host_name = lines[0]; out->joiner_name = lines[1]; out->host_slot = (std::atoi(lines[2]) == 1) ? 1 : 0; out->started = std::atoi(lines[3]) != 0; out->max_slots = 2; out->slot_name[0].clear(); out->slot_name[1].clear(); out->slot_name[out->host_slot] = out->host_name; out->slot_name[1 - out->host_slot] = out->joiner_name; out->endpoint = endpoint; if (out->joiner_name != me) return -1; g_lnch_lan_my_slot = 1 - out->host_slot; return 0; } SDL_Delay(5); } return -3; } /* Drop orphan LAN registry files (host crashed / unreachable). * Never delete merely because started=1: host closes lobby UDP before * delay-sync bind, so PING fails mid-launch and a delete races the joiner * reading session_id/started from the file. Started lobbies are already * hidden by ae_np_lan_list_visible(). */ static void ae_np_lan_rescan(void) { if (g_lnch_hosting_lan) { g_lnch_lan_latency_ms = 0; /* Still browse so the host list can show other LAN rooms. */ ae_np_lan_browse_start(200u); AeLanLobbyState self{}; if (ae_np_read_lan_file_state(&self) && !self.started) { AeLanDiscovered d{}; std::snprintf(d.endpoint, sizeof(d.endpoint), "%s", self.endpoint.c_str()); std::snprintf(d.name, sizeof(d.name), "%s", self.name.empty() ? "LAN Lobby" : self.name.c_str()); std::snprintf(d.game, sizeof(d.game), "%s", self.game.empty() ? "PSX" : self.game.c_str()); d.player_count = ae_np_lan_occupied(self); d.max_slots = self.max_slots >= 2 ? self.max_slots : 2; d.has_password = self.password.empty() ? 0 : 1; d.latency_ms = 0; d.input_delay = g_lnch_lobby_input_delay; d.input_prediction = g_lnch_lobby_input_prediction; d.rollback = g_lnch_rollback ? 1 : 0; d.session_id = self.session_id ? self.session_id : 1u; d.last_seen_ms = SDL_GetTicks(); ae_np_lan_discovered_upsert(d); } return; } if (g_lnch_joined_lan) return; /* Cross-machine discovery + legacy file row — non-blocking. */ g_lan_browse_file_pending = 1; ae_np_lan_browse_start(300u); /* If the browse socket failed to open, promote the file row now. */ ae_np_lan_browse_pump(); } static bool ae_np_lan_list_visible(void) { if (g_lnch_hosting_lan) { AeLanLobbyState st; return ae_np_read_lan_file_state(&st) && !st.started; } AeLanLobbyState st; if (!ae_np_read_lan_file_state(&st) || st.started) return false; return true; } /* Extra LAN rows after the online server list. Prefer discovered table; * fall back to a single file-backed row when hosting / same-machine. */ static int ae_np_lan_list_extra_count(void) { ae_np_lan_prune_discovered(); if (g_lnch_lan_discovered_n > 0) return g_lnch_lan_discovered_n; return ae_np_lan_list_visible() ? 1 : 0; } /* Advertise local BIOS capability for lobby settle (OpenBIOS vs SCPH-1001). */ static void ae_np_refresh_bios_offer(const char* launcher_bios_path) { PsxLobbyBiosOffer offer{}; offer.valid = 1; offer.can_openbios = (s_openbios_allowed && psx_bios_bundled() != nullptr) ? 1 : 0; const char* argv0 = g_lnch_argv0 ? g_lnch_argv0 : ""; /* CRC-32 of the linked retail image a file matches, 0 if none. */ auto retail_crc_of = [&](const std::filesystem::path& p) -> uint32_t { std::error_code ec; if (p.empty() || !std::filesystem::exists(p, ec)) return 0; const PsxBiosBackend* b = bios_backend_for_file(p, nullptr, nullptr); return (b && b->image && !b->image->image_bundled) ? b->image->image_crc32 : 0; }; auto path_is_retail = [&](const std::filesystem::path& p) -> bool { return retail_crc_of(p) != 0; }; /* The first dump in the order resolve_match_session_bios_path tries * them: that is the image this peer boots for a retail match, so its * CRC is what the settle must compare. */ uint32_t dump_crc = 0; if (launcher_bios_path && launcher_bios_path[0]) dump_crc = retail_crc_of(resolve_bios_path(launcher_bios_path, argv0)); if (!dump_crc) dump_crc = retail_crc_of(read_cached_path(argv0, "bios.cfg")); if (!dump_crc) dump_crc = retail_crc_of(discover_retail_bios_near(argv0)); offer.can_scph1001 = (psx_bios_has_selectable() && dump_crc) ? 1 : 0; offer.retail_crc = offer.can_scph1001 ? dump_crc : 0; /* Empty / bundled path = explicit OpenBIOS preference. */ offer.prefer_openbios = 1; if (launcher_bios_path && launcher_bios_path[0]) { const auto p = resolve_bios_path(launcher_bios_path, argv0); if (path_is_retail(p)) offer.prefer_openbios = 0; } else { /* No launcher path — bios.cfg empty ⇒ OpenBIOS; retail path ⇒ SCPH. */ const auto cached = read_cached_path(argv0, "bios.cfg"); if (path_is_retail(cached)) offer.prefer_openbios = 0; } if (!offer.can_openbios && offer.can_scph1001) offer.prefer_openbios = 0; psx_lobby_set_bios_offer(&offer); } static void ae_np_refresh_bios_offer_from_disk(void) { ae_np_refresh_bios_offer(nullptr); } static void ae_np_set_session_bios_token(const char* token, uint32_t retail_crc) { g_lnch_session_bios[0] = '\0'; g_lnch_session_bios_crc = 0; if (!token || !token[0]) return; if (std::strcmp(token, "openbios") != 0 && std::strcmp(token, "scph1001") != 0) return; std::snprintf(g_lnch_session_bios, sizeof(g_lnch_session_bios), "%s", token); if (std::strcmp(token, "scph1001") == 0) g_lnch_session_bios_crc = retail_crc; } static void ae_np_clear_session_bios_token(void) { g_lnch_session_bios[0] = '\0'; g_lnch_session_bios_crc = 0; } static void ae_np_log_settled_bios(const char* where) { const char* token = g_lnch_session_bios[0] ? g_lnch_session_bios : "openbios"; if (std::strcmp(token, "scph1001") == 0 && g_lnch_session_bios_crc) { char name[32]; netplay_bios_describe_crc(g_lnch_session_bios_crc, name, sizeof(name)); std::fprintf(stdout, "psxrecomp: %s settled session BIOS = %s (retail %s, crc %08x)\n", where, token, name, (unsigned)g_lnch_session_bios_crc); } else { std::fprintf(stdout, "psxrecomp: %s settled session BIOS = %s\n", where, token); } } static void ae_np_lan_clear_slot_bios(int slot) { if (slot < 0 || slot >= kAeLanMaxSlots) return; g_lnch_lan_slot_bios[slot] = {}; /* A seat's offers leave with the seat — both of them. */ g_lnch_lan_slot_memcard[slot] = {}; } static void ae_np_lan_store_slot_bios(int slot, int prefer_open, int can_open, int can_scph, uint32_t retail_crc) { if (slot < 0 || slot >= kAeLanMaxSlots) return; AeLanSlotBios& b = g_lnch_lan_slot_bios[slot]; b.valid = 1; b.prefer_openbios = prefer_open ? 1 : 0; b.can_openbios = can_open ? 1 : 0; b.can_scph1001 = can_scph ? 1 : 0; b.retail_crc = can_scph ? retail_crc : 0; if (!b.can_openbios && !b.can_scph1001) b.can_openbios = 1; } static void ae_np_lan_sync_local_slot_bios(void) { ae_np_refresh_bios_offer_from_disk(); const PsxLobbyBiosOffer* offer = psx_lobby_bios_offer(); int slot = -1; if (g_lnch_hosting_lan) { AeLanLobbyState st; if (ae_np_read_lan_state(&st)) slot = st.host_slot; } else { slot = g_lnch_lan_my_slot; } if (slot < 0 || !offer || !offer->valid) return; ae_np_lan_store_slot_bios(slot, offer->prefer_openbios, offer->can_openbios, offer->can_scph1001, offer->retail_crc); } static void ae_np_lan_send_chat_to_peers(const char* player_id, const char* from, const char* text) { char msg[448]; std::snprintf(msg, sizeof(msg), "MOTK5 CHAT\n%s\n%s\n%s\n", player_id ? player_id : "", from ? from : "", text); for (int i = 0; i < kAeLanMaxSlots; ++i) { if (!g_lnch_lan_peer_ok[i]) continue; ae_np_lan_udp_sendto(g_lnch_lan_peers[i], msg); } } static void ae_np_lan_sync_local_slot_memcard(void) { int slot = -1; if (g_lnch_hosting_lan) { AeLanLobbyState st; if (ae_np_read_lan_state(&st)) slot = st.host_slot; } else { slot = g_lnch_lan_my_slot; } if (slot < 0 || slot >= kAeLanMaxSlots || !g_lnch_memcard_offer.valid) return; g_lnch_lan_slot_memcard[slot].valid = 1; g_lnch_lan_slot_memcard[slot].has_card = g_lnch_memcard_offer.has_card ? 1 : 0; g_lnch_lan_slot_memcard[slot].share = g_lnch_memcard_offer.share ? 1 : 0; } /* LAN guest: push the local memcard offer to the host (MOTK5 MEMCARD). * The host folds it into its seat table and re-broadcasts UPDATE. */ static void ae_np_lan_send_memcard_offer_to_host(void) { if (!g_lnch_joined_lan || !g_lnch_remote_lan) return; if (g_lnch_lan_udp == kAeLanSockInvalid) return; char host[64]; if (!ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host))) return; sockaddr_in to{}; to.sin_family = AF_INET; to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); if (inet_pton(AF_INET, host, &to.sin_addr) != 1) return; char msg[192]; std::snprintf(msg, sizeof(msg), "MOTK5 MEMCARD\n%s\n%d\n%d\n", ae_np_lan_local_player_id(), g_lnch_memcard_offer.has_card ? 1 : 0, g_lnch_memcard_offer.share ? 1 : 0); ae_np_lan_udp_sendto(to, msg); } /* ---- LAN seat self-service (MOTK5 SEATMOVE / SWAPASK / SWAPANS / SWAPRES) * The host is the room: a guest asks it to move (SEATMOVE); a free seat * is taken outright, an occupied one turns into an ask relayed to the * occupant (SWAPASK), whose answer (SWAPANS) the host applies and * reports back (SWAPRES). The host's own moves apply directly, and the * host's own asks / answers use the same state the UI reads. */ struct AeLanSwap { bool incoming = false; char asker_id[48] = {0}; char asker_name[64] = {0}; int from_slot = -1; int outgoing = 0; /* 0 idle, 1 waiting, 2 accepted, -1 declined */ }; AeLanSwap g_lnch_lan_swap; static bool ae_np_lan_send_to_host(const char* msg) { if (!g_lnch_joined_lan || !g_lnch_remote_lan) return false; if (g_lnch_lan_udp == kAeLanSockInvalid) return false; char host[64]; if (!ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host))) return false; sockaddr_in to{}; to.sin_family = AF_INET; to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); if (inet_pton(AF_INET, host, &to.sin_addr) != 1) return false; ae_np_lan_udp_sendto(to, msg); return true; } /* Host: trade two player seats (either may be empty) and tell the room. */ static bool ae_np_lan_swap_seats(int a, int b) { AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return false; if (a < 0 || b < 0 || a >= state.max_slots || b >= state.max_slots || a == b) return false; std::swap(state.slot_name[a], state.slot_name[b]); std::swap(state.slot_id[a], state.slot_id[b]); if (state.host_slot == a) state.host_slot = b; else if (state.host_slot == b) state.host_slot = a; if (a < kAeLanMaxSlots && b < kAeLanMaxSlots) { std::swap(g_lnch_lan_peers[a], g_lnch_lan_peers[b]); std::swap(g_lnch_lan_peer_ok[a], g_lnch_lan_peer_ok[b]); std::swap(g_lnch_lan_slot_bios[a], g_lnch_lan_slot_bios[b]); std::swap(g_lnch_lan_slot_memcard[a], g_lnch_lan_slot_memcard[b]); } state.started = false; ae_np_lan_sync_legacy_names(state); if (!ae_np_write_lan_state(state)) return false; ae_np_lan_send_update_to_peers(state); return true; } /* Host: a seat request from `asker_id` for seat `to`. */ static void ae_np_lan_host_seat_request(const char* asker_id, int to) { AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) return; const int from = ae_np_lan_find_slot_by_id(st, asker_id); if (from < 0 || to < 0 || to >= st.max_slots || to == from) return; if (st.slot_name[to].empty()) { (void)ae_np_lan_swap_seats(from, to); return; } if (to == st.host_slot) { /* Asking the host: the host's own prompt. */ g_lnch_lan_swap.incoming = true; std::snprintf(g_lnch_lan_swap.asker_id, sizeof(g_lnch_lan_swap.asker_id), "%s", asker_id); std::snprintf(g_lnch_lan_swap.asker_name, sizeof(g_lnch_lan_swap.asker_name), "%s", st.slot_name[from].c_str()); g_lnch_lan_swap.from_slot = from; return; } if (to < kAeLanMaxSlots && g_lnch_lan_peer_ok[to]) { char msg[224]; std::snprintf(msg, sizeof(msg), "MOTK5 SWAPASK\n%s\n%s\n%d\n", asker_id, st.slot_name[from].c_str(), from); ae_np_lan_udp_sendto(g_lnch_lan_peers[to], msg); } } /* Host: an answer from the occupant `responder_id` to `asker_id`. */ static void ae_np_lan_host_seat_answer(const char* responder_id, const char* asker_id, int accept) { AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) return; const int mine = ae_np_lan_find_slot_by_id(st, responder_id); const int theirs = ae_np_lan_find_slot_by_id(st, asker_id); int swapped = 0; if (mine >= 0 && theirs >= 0 && accept) swapped = ae_np_lan_swap_seats(mine, theirs) ? 1 : 0; if (std::strcmp(asker_id, ae_np_lan_local_player_id()) == 0) { g_lnch_lan_swap.outgoing = swapped ? 2 : -1; return; } /* The asker's peer binding moved with its seat. */ AeLanLobbyState now; if (!ae_np_read_lan_state(&now)) return; const int asker_slot = ae_np_lan_find_slot_by_id(now, asker_id); if (asker_slot >= 0 && asker_slot < kAeLanMaxSlots && g_lnch_lan_peer_ok[asker_slot]) { char msg[48]; std::snprintf(msg, sizeof(msg), "MOTK5 SWAPRES\n%d\n", swapped); ae_np_lan_udp_sendto(g_lnch_lan_peers[asker_slot], msg); } } /* Seat 1's offer && host allow — the value the match launches with. */ static int ae_np_lan_guest_memcard_effective(const AeLanLobbyState& st) { if (!g_lnch_guest_memcard) return 0; if (st.max_slots < 2) return 0; if (st.slot_name[1].empty() && st.slot_id[1].empty()) return 0; const AeLanSlotMemcard& mc = g_lnch_lan_slot_memcard[1]; return (mc.valid && mc.has_card && mc.share) ? 1 : 0; } /* Same settle rule as psx_lobby_settle_session_bios (netplay_bios_settle), * over LAN seat offers. Returns 0 with the token in out and the retail CRC * in *out_crc; 1 when no BIOS suits every seat (why says which). */ static int ae_np_lan_settle_session_bios(char* out, size_t out_cap, uint32_t* out_crc, char* why, size_t why_cap) { if (!out || out_cap < 9) return -1; out[0] = '\0'; if (out_crc) *out_crc = 0; if (why && why_cap) why[0] = '\0'; AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) { std::strncpy(out, "openbios", out_cap - 1); out[out_cap - 1] = '\0'; return 0; } ae_np_lan_sync_local_slot_bios(); NetplayBiosSeat seats[kAeLanMaxSlots] = {}; int n = 0; const int host_slot = (st.host_slot >= 0 && st.host_slot < kAeLanMaxSlots) ? st.host_slot : 0; for (int i = 0; i < st.max_slots && i < kAeLanMaxSlots; ++i) { if (st.slot_name[i].empty()) continue; const AeLanSlotBios& b = g_lnch_lan_slot_bios[i]; NetplayBiosSeat& s = seats[n++]; s.offered = b.valid; s.can_openbios = b.can_openbios; s.can_retail = b.can_scph1001; s.prefer_openbios = b.prefer_openbios; s.retail_crc = b.retail_crc; s.is_host = (i == host_slot) ? 1 : 0; } const NetplayBiosSettle settle = netplay_bios_settle(seats, n); if (settle.kind == NETPLAY_BIOS_NONE) { if (why && why_cap) netplay_bios_describe_refusal(&settle, seats, n, why, why_cap); return 1; } std::strncpy(out, netplay_bios_token(settle.kind), out_cap - 1); out[out_cap - 1] = '\0'; if (out_crc) *out_crc = settle.retail_crc; return 0; } static void ae_np_append_lan_bios_join(char* msg, size_t msg_cap, int* io_off) { if (!msg || !io_off || *io_off < 0) return; ae_np_refresh_bios_offer_from_disk(); const PsxLobbyBiosOffer* offer = psx_lobby_bios_offer(); const char* prefer = "openbios"; int can_open = 1; int can_scph = 0; if (offer && offer->valid) { prefer = offer->prefer_openbios ? "openbios" : "scph1001"; can_open = offer->can_openbios ? 1 : 0; can_scph = offer->can_scph1001 ? 1 : 0; } const int n = std::snprintf(msg + *io_off, msg_cap - (size_t)*io_off, "%s\n%d\n%d\n", prefer, can_open, can_scph); if (n > 0) *io_off += n; /* Memcard offer tail (has_card, share). Older hosts stop reading * after the three bios lines, so this is invisible to them. */ if (*io_off > 0 && (size_t)*io_off < msg_cap) { const int m = std::snprintf(msg + *io_off, msg_cap - (size_t)*io_off, "%d\n%d\n", g_lnch_memcard_offer.has_card ? 1 : 0, g_lnch_memcard_offer.share ? 1 : 0); if (m > 0) *io_off += m; } /* Line 6: CRC-32 of the retail image this peer boots; "" * when it has none. Older hosts stop after the memcard lines. */ if (*io_off > 0 && (size_t)*io_off < msg_cap) { char crc[16]; netplay_bios_format_crc((offer && offer->valid && offer->can_scph1001) ? offer->retail_crc : 0, crc, sizeof(crc)); const int c = std::snprintf(msg + *io_off, msg_cap - (size_t)*io_off, "%s\n", crc); if (c > 0) *io_off += c; } } /* Optional JOIN retail CRC: line 6 of the tail, after the three bios and * two memcard lines. 0 when absent (an older guest). Does not modify tail. */ static uint32_t ae_np_parse_lan_bios_crc_tail(const char* tail) { if (!tail) return 0; const char* p = tail; for (int i = 0; i < 5; ++i) { const char* nl = std::strchr(p, '\n'); if (!nl) return 0; p = nl + 1; } char line[16] = {}; size_t n = 0; while (p[n] && p[n] != '\n' && n + 1 < sizeof(line)) { line[n] = p[n]; ++n; } return netplay_bios_parse_crc(line); } /* Optional JOIN memcard tail after the 3 bios lines: has_card\nshare\n. * Returns 0 and fills *out when present; -1 (out cleared) otherwise. */ static int ae_np_parse_lan_memcard_tail(const char* tail, AeLanSlotMemcard* out) { if (out) *out = {}; if (!tail || !out) return -1; const char* p = tail; for (int i = 0; i < 3; ++i) { const char* nl = std::strchr(p, '\n'); if (!nl) return -1; p = nl + 1; } char* end = nullptr; if (!*p) return -1; const long has_card = std::strtol(p, &end, 10); if (!end || *end != '\n') return -1; p = end + 1; if (!*p) return -1; const long share = std::strtol(p, &end, 10); if (!end || (*end != '\n' && *end != '\0')) return -1; out->valid = 1; out->has_card = has_card ? 1 : 0; out->share = share ? 1 : 0; return 0; } /* Parse optional JOIN bios tail: prefer\ncan_open\ncan_scph\n */ static int ae_np_parse_lan_bios_tail(char* p, int* prefer_open, int* can_open, int* can_scph) { if (!p || !prefer_open || !can_open || !can_scph) return -1; char* lines[3] = {}; for (int i = 0; i < 3; ++i) { if (!p || !*p) return -1; lines[i] = p; char* nl = std::strchr(p, '\n'); if (nl) { *nl = '\0'; p = nl + 1; } else { p = p + std::strlen(p); } } if (std::strcmp(lines[0], "openbios") == 0) *prefer_open = 1; else if (std::strcmp(lines[0], "scph1001") == 0) *prefer_open = 0; else return -1; *can_open = (std::atoi(lines[1]) != 0) ? 1 : 0; *can_scph = (std::atoi(lines[2]) != 0) ? 1 : 0; return 0; } static std::string g_lnch_mod_plan_error; static void ae_copy_lobby_text(char* out, size_t cap, const char* in) { if (!out || cap == 0) return; std::snprintf(out, cap, "%s", in ? in : ""); } static const RecompLauncherCModProvider* ae_mod_provider(void) { return PSXRecompV4::mod_runtime_launcher_provider(); } static int ae_mod_package_get_by_id(const char* id, RecompLauncherCModPackage* out) { const RecompLauncherCModProvider* mods = ae_mod_provider(); if (!mods || !mods->package_count || !mods->package_get || !id || !id[0]) return 0; const int count = mods->package_count(mods->ctx); for (int i = 0; i < count; ++i) { RecompLauncherCModPackage pkg{}; if (!mods->package_get(mods->ctx, i, &pkg)) continue; if (std::strcmp(pkg.id, id) != 0) continue; if (out) *out = pkg; return 1; } return 0; } static int ae_mod_pkg_installed(const PsxLobbyModPkg* need, char* reason, size_t reason_cap) { const RecompLauncherCModProvider* mods = ae_mod_provider(); RecompLauncherCModPackage pkg{}; if (reason && reason_cap) reason[0] = '\0'; if (!need || !need->id[0]) return 0; if (!ae_mod_package_get_by_id(need->id, &pkg)) { ae_copy_lobby_text(reason, reason_cap, "not installed"); return 0; } if (need->ver[0] && std::strcmp(pkg.version, need->ver) != 0) { int found_version = 0; if (mods && mods->version_count && mods->version_get) { const int versions = mods->version_count(mods->ctx, need->id); for (int i = 0; i < versions; ++i) { RecompLauncherCModVersion ver{}; if (mods->version_get(mods->ctx, need->id, i, &ver) && std::strcmp(ver.version, need->ver) == 0) { found_version = 1; break; } } } if (!found_version) { ae_copy_lobby_text(reason, reason_cap, "version mismatch"); return 0; } } if (pkg.has_error) { ae_copy_lobby_text(reason, reason_cap, pkg.status[0] ? pkg.status : "does not match your game image"); return 0; } return 1; } static void ae_fill_lobby_mod_pkg_from_package(PsxLobbyModPkg* out, const RecompLauncherCModPackage& pkg) { if (!out) return; std::memset(out, 0, sizeof(*out)); ae_copy_lobby_text(out->id, sizeof(out->id), pkg.id); ae_copy_lobby_text(out->ver, sizeof(out->ver), pkg.version); ae_copy_lobby_text(out->name, sizeof(out->name), pkg.name); out->builtin = pkg.removable ? 0 : 1; out->size = 0; } static void ae_np_refresh_mod_offer(void) { if (!g_netplay_content_negotiation) return; const RecompLauncherCModProvider* mods = ae_mod_provider(); PsxLobbyModOffer offer{}; if (!mods || !mods->package_count || !mods->package_get) { psx_lobby_set_mod_offer(nullptr); return; } offer.valid = 1; const int count = mods->package_count(mods->ctx); for (int i = 0; i < count && offer.count < PSX_LOBBY_MAX_MODS; ++i) { RecompLauncherCModPackage pkg{}; if (!mods->package_get(mods->ctx, i, &pkg) || !pkg.id[0]) continue; if (mods->version_count && mods->version_get) { const int versions = mods->version_count(mods->ctx, pkg.id); for (int v = 0; v < versions && offer.count < PSX_LOBBY_MAX_MODS; ++v) { RecompLauncherCModVersion ver{}; if (!mods->version_get(mods->ctx, pkg.id, v, &ver) || !ver.version[0]) continue; ae_fill_lobby_mod_pkg_from_package(&offer.pkgs[offer.count], pkg); ae_copy_lobby_text(offer.pkgs[offer.count].ver, sizeof(offer.pkgs[offer.count].ver), ver.version); offer.pkgs[offer.count].builtin = ver.removable ? 0 : 1; offer.count++; } } else { ae_fill_lobby_mod_pkg_from_package(&offer.pkgs[offer.count], pkg); offer.count++; } } psx_lobby_set_mod_offer(&offer); } static void ae_append_feat_token(char* out, size_t cap, const char* token) { if (!out || cap == 0 || !token || !token[0]) return; const size_t len = std::strlen(out); if (len >= cap - 1) return; std::snprintf(out + len, cap - len, "%s%s", len ? "," : "", token); } static void ae_append_feature_options(char* token, size_t cap, const RecompLauncherCModProvider* mods, const RecompLauncherCModFeature& feature) { if (!token || cap == 0 || !mods || !mods->feature_option_get) return; const size_t base_len = std::strlen(token); size_t len = base_len; for (int i = 0; i < feature.option_count; ++i) { RecompLauncherCModOption opt{}; if (!mods->feature_option_get(mods->ctx, feature.package_id, feature.id, i, &opt)) continue; if (!opt.id[0]) continue; const char* sep = (len == base_len) ? "=" : "+"; const int wrote = std::snprintf(token + len, len < cap ? cap - len : 0, "%s%s~%s", sep, opt.id, opt.value); if (wrote < 0 || (size_t)wrote >= (len < cap ? cap - len : 0)) { token[base_len] = '\0'; return; } len += (size_t)wrote; } } static int ae_np_fill_caps_mod_plan(PsxLobbyMatchCaps* caps) { const RecompLauncherCModProvider* mods = ae_mod_provider(); g_lnch_mod_plan_error.clear(); if (!caps) { g_lnch_mod_plan_error = "missing match caps"; return 0; } caps->mod_plan_fp[0] = '\0'; caps->mod_count = 0; std::memset(caps->mods, 0, sizeof(caps->mods)); if (!PSXRecompV4::mod_runtime_prepare_resources(g_launcher_disc_path, &g_lnch_mod_plan_error)) return 0; if (!mods || !mods->package_count || !mods->package_get) return 1; if (mods->feature_count && mods->feature_get) { const int feature_count = mods->feature_count(mods->ctx); for (int i = 0; i < feature_count; ++i) { RecompLauncherCModFeature feature{}; if (!mods->feature_get(mods->ctx, i, &feature) || !feature.enabled) continue; int pkg_index = -1; for (int j = 0; j < caps->mod_count; ++j) { if (std::strcmp(caps->mods[j].id, feature.package_id) == 0) { pkg_index = j; break; } } if (pkg_index < 0) { if (caps->mod_count >= PSX_LOBBY_MAX_MODS) { g_lnch_mod_plan_error = "too many enabled mods for lobby caps"; return 0; } RecompLauncherCModPackage pkg{}; if (!ae_mod_package_get_by_id(feature.package_id, &pkg)) continue; pkg_index = caps->mod_count++; ae_fill_lobby_mod_pkg_from_package(&caps->mods[pkg_index], pkg); } char token[PSX_LOBBY_MOD_FEATS_LEN] = {}; ae_copy_lobby_text(token, sizeof(token), feature.id); ae_append_feature_options(token, sizeof(token), mods, feature); ae_append_feat_token(caps->mods[pkg_index].feats, sizeof(caps->mods[pkg_index].feats), token); } } else { const int count = mods->package_count(mods->ctx); for (int i = 0; i < count && caps->mod_count < PSX_LOBBY_MAX_MODS; ++i) { RecompLauncherCModPackage pkg{}; if (!mods->package_get(mods->ctx, i, &pkg) || !pkg.enabled) continue; ae_fill_lobby_mod_pkg_from_package(&caps->mods[caps->mod_count], pkg); caps->mod_count++; } } if (caps->mod_count > 0) { const std::string fp = PSXRecompV4::mod_runtime_plan_fingerprint_portable(); if (fp.empty()) { g_lnch_mod_plan_error = "enabled mods did not produce a portable netplay fingerprint"; return 0; } ae_copy_lobby_text(caps->mod_plan_fp, sizeof(caps->mod_plan_fp), fp.c_str()); } 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 = g_lnch_lobby_input_delay; if (caps.input_delay < 2) caps.input_delay = 2; if (caps.input_delay > 20) caps.input_delay = 20; caps.input_prediction = g_lnch_lobby_input_prediction; if (caps.input_prediction < 2) caps.input_prediction = 2; if (caps.input_prediction > 16) caps.input_prediction = 16; caps.force_input_relay = g_lnch_force_input_relay != 0; caps.relay_host = g_lnch_relay_host != 0; caps.force_turn = g_lnch_force_turn != 0; caps.rollback = g_lnch_rollback != 0; caps.multitap_analog = g_lnch_multitap_analog != 0; if (s) caps.multitap_analog = s->multitap_analog != 0; caps.guest_memcard = g_lnch_guest_memcard != 0; caps.guest_memcard_active = 0; /* settled at request_start */ if (g_netplay_content_negotiation && !ae_np_fill_caps_mod_plan(&caps)) { caps.mod_plan_fp[0] = '\0'; caps.mod_count = 0; std::memset(caps.mods, 0, sizeof(caps.mods)); } return caps; } /* Online: seat 1's published offer && host allow. Host-side only — the * value is settled once at start and shipped in the launch caps. */ static int ae_np_ws_guest_memcard_effective(void) { if (!g_lnch_guest_memcard) return 0; const int mc = psx_lobby_member_count(); for (int i = 0; i < mc; ++i) { PsxLobbyMember mem{}; if (!psx_lobby_member_get(i, &mem)) continue; if (mem.slot != 1) continue; /* Seat semantics, not lobby-host semantics: seat 0 is the sim * authority whose cards are the match cards, whoever hosts the * room. A lobby host who moved to seat 1 brings a card like any * other seat-1 player. */ return (mem.memcard_offer_valid && mem.memcard_has_card && mem.memcard_share) ? 1 : 0; } return 0; } static void ae_np_push_match_caps(const RecompLauncherCSettings* settings) { if (g_lnch_hosting_lan && g_netplay_content_negotiation) { AeLanLobbyState state; PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); char wire[8192]; if (!g_lnch_mod_plan_error.empty() || !ae_np_read_lan_state(&state) || !psx_lobby_encode_match_caps(wire, sizeof(wire), &caps)) return; state.mod_caps_json = wire; psx_lobby_set_local_match_caps(&caps); if (ae_np_write_lan_state(state)) ae_np_lan_send_update_to_peers(state); return; } if (!psx_lobby_in_lobby() || !psx_lobby_is_host()) return; const PsxLobbyMatchCaps* cur = psx_lobby_match_caps(); PsxLobbyMatchCaps caps = (cur && cur->valid) ? *cur : ae_netplay_caps_from_settings(settings); caps.valid = 1; caps.input_delay = g_lnch_lobby_input_delay; if (caps.input_delay < 2) caps.input_delay = 2; if (caps.input_delay > 20) caps.input_delay = 20; caps.input_prediction = g_lnch_lobby_input_prediction; if (caps.input_prediction < 2) caps.input_prediction = 2; if (caps.input_prediction > 16) caps.input_prediction = 16; caps.force_input_relay = g_lnch_force_input_relay != 0; caps.relay_host = g_lnch_relay_host != 0; caps.force_turn = g_lnch_force_turn != 0; caps.rollback = g_lnch_rollback != 0; caps.multitap_analog = g_lnch_multitap_analog != 0; if (settings) caps.multitap_analog = settings->multitap_analog != 0; caps.guest_memcard = g_lnch_guest_memcard != 0; caps.guest_memcard_active = 0; if (g_netplay_content_negotiation && !ae_np_fill_caps_mod_plan(&caps)) { std::fprintf(stderr, "psxrecomp: cannot publish lobby mod plan: %s\n", g_lnch_mod_plan_error.c_str()); return; } ae_np_refresh_mod_offer(); (void)psx_lobby_set_match_caps(&caps); } /* Bring-your-own memory card callbacks (see recomp_launcher.h). */ int ae_np_memcard_offer_set(void*, int has_card, int share) { PsxLobbyMemcardOffer next = g_lnch_memcard_offer; next.valid = 1; next.has_card = has_card ? 1 : 0; if (share >= 0) next.share = share ? 1 : 0; const bool changed = !g_lnch_memcard_offer.valid || next.has_card != g_lnch_memcard_offer.has_card || next.share != g_lnch_memcard_offer.share; g_lnch_memcard_offer = next; psx_lobby_set_memcard_offer(&next); if (!changed) return 0; if (g_lnch_hosting_lan) { ae_np_lan_sync_local_slot_memcard(); AeLanLobbyState st; if (ae_np_read_lan_state(&st)) ae_np_lan_send_update_to_peers(st); } else if (g_lnch_joined_lan) { ae_np_lan_sync_local_slot_memcard(); ae_np_lan_send_memcard_offer_to_host(); } else if (psx_lobby_in_lobby()) { /* Re-advertise now; ae_np_pump would also catch it, later. */ (void)psx_lobby_set_ready(1); } return 0; } int ae_np_guest_memcard_get(void*) { if (g_lnch_joined_lan) return g_lnch_lan_guest_memcard_allow ? 1 : 0; if (!g_lnch_hosting_lan) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) return caps->guest_memcard ? 1 : 0; } return g_lnch_guest_memcard ? 1 : 0; } /* ---- seat self-service callbacks (see recomp_launcher.h) ---- */ int ae_np_seat_move_self(void*, int to_slot) { if (g_lnch_hosting_lan) { AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) return -1; if (to_slot < 0 || to_slot >= st.max_slots || to_slot == st.host_slot) return -1; if (!st.slot_name[to_slot].empty()) return -1; /* occupied: ask instead */ return ae_np_lan_swap_seats(st.host_slot, to_slot) ? 0 : -1; } if (g_lnch_joined_lan) { char msg[96]; std::snprintf(msg, sizeof(msg), "MOTK5 SEATMOVE\n%s\n%d\n", ae_np_lan_local_player_id(), to_slot); return ae_np_lan_send_to_host(msg) ? 0 : -1; } return psx_lobby_seat_move_self(to_slot); } int ae_np_seat_swap_request(void*, int target_slot) { if (g_lnch_hosting_lan) { AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) return -1; if (target_slot < 0 || target_slot >= st.max_slots || target_slot == st.host_slot || st.slot_name[target_slot].empty()) return -1; if (g_lnch_lan_swap.outgoing == 1) return -1; if (target_slot >= kAeLanMaxSlots || !g_lnch_lan_peer_ok[target_slot]) return -1; char msg[224]; std::snprintf(msg, sizeof(msg), "MOTK5 SWAPASK\n%s\n%s\n%d\n", ae_np_lan_local_player_id(), st.slot_name[st.host_slot].c_str(), st.host_slot); ae_np_lan_udp_sendto(g_lnch_lan_peers[target_slot], msg); g_lnch_lan_swap.outgoing = 1; return 0; } if (g_lnch_joined_lan) { if (g_lnch_lan_swap.outgoing == 1) return -1; char msg[96]; std::snprintf(msg, sizeof(msg), "MOTK5 SEATMOVE\n%s\n%d\n", ae_np_lan_local_player_id(), target_slot); if (!ae_np_lan_send_to_host(msg)) return -1; g_lnch_lan_swap.outgoing = 1; return 0; } return psx_lobby_seat_swap_request(target_slot); } int ae_np_seat_swap_incoming(void*, char* who, size_t who_cap, int* from_slot) { if (g_lnch_hosting_lan || g_lnch_joined_lan) { if (!g_lnch_lan_swap.incoming) return 0; if (who && who_cap) std::snprintf(who, who_cap, "%s", g_lnch_lan_swap.asker_name); if (from_slot) *from_slot = g_lnch_lan_swap.from_slot; return 1; } return psx_lobby_seat_swap_incoming(who, who_cap, from_slot); } int ae_np_seat_swap_respond(void*, int accept) { if (g_lnch_hosting_lan) { if (!g_lnch_lan_swap.incoming) return -1; g_lnch_lan_swap.incoming = false; ae_np_lan_host_seat_answer(ae_np_lan_local_player_id(), g_lnch_lan_swap.asker_id, accept); return 0; } if (g_lnch_joined_lan) { if (!g_lnch_lan_swap.incoming) return -1; g_lnch_lan_swap.incoming = false; char msg[160]; std::snprintf(msg, sizeof(msg), "MOTK5 SWAPANS\n%s\n%s\n%d\n", ae_np_lan_local_player_id(), g_lnch_lan_swap.asker_id, accept ? 1 : 0); return ae_np_lan_send_to_host(msg) ? 0 : -1; } return psx_lobby_seat_swap_respond(accept); } int ae_np_seat_swap_outgoing(void*) { if (g_lnch_hosting_lan || g_lnch_joined_lan) return g_lnch_lan_swap.outgoing; return psx_lobby_seat_swap_outgoing(); } void ae_np_seat_swap_clear(void*) { if (g_lnch_hosting_lan || g_lnch_joined_lan) { if (g_lnch_lan_swap.outgoing != 1) g_lnch_lan_swap.outgoing = 0; return; } psx_lobby_seat_swap_clear(); } /* Host-from-the-gallery is an online (server) feature: the LAN room has * no gallery, and it is the server that sizes the relay for it. */ int ae_np_host_can_spectate(void*) { if (g_lnch_hosting_lan || g_lnch_joined_lan) return 0; return (psx_lobby_in_lobby() && psx_lobby_is_host()) ? 1 : 0; } /* Lobby chat callbacks (see recomp_launcher.h). */ static bool g_lnch_chat_was_seated = false; static void ae_np_chat_track_room(void) { /* A fresh room starts with an empty ring, whichever transport. */ const bool seated = g_lnch_hosting_lan || g_lnch_joined_lan || psx_lobby_in_lobby(); if (seated != g_lnch_chat_was_seated) { ae_np_lan_chat_clear(); g_lnch_chat_was_seated = seated; } } int ae_np_chat_send(void*, const char* text) { char line[256]; ae_np_chat_track_room(); ae_np_chat_sanitize(text, line, sizeof(line)); if (!line[0]) return -1; if (g_lnch_hosting_lan) { AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) return -1; const char* me = ae_np_lan_local_player_id(); const int slot = st.host_slot; const char* from = (slot >= 0 && slot < kAeLanMaxSlots && !st.slot_name[slot].empty()) ? st.slot_name[slot].c_str() : psx_lobby_display_name(); ae_np_lan_chat_push(me, from, line, 0); ae_np_lan_send_chat_to_peers(me, from, line); return 0; } if (g_lnch_joined_lan) { if (!g_lnch_remote_lan || g_lnch_lan_udp == kAeLanSockInvalid) return -1; char host[64]; if (!ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host))) return -1; sockaddr_in to{}; to.sin_family = AF_INET; to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); if (inet_pton(AF_INET, host, &to.sin_addr) != 1) return -1; char msg[400]; std::snprintf(msg, sizeof(msg), "MOTK5 CHATREQ\n%s\n%s\n", ae_np_lan_local_player_id(), line); ae_np_lan_udp_sendto(to, msg); return 0; } return psx_lobby_send_chat(line); } #if defined(PSX_HAS_RECOMP_NET) /* ---- optional Discord sign-in ------------------------------------- * Thin adapters over psx_netplay_auth, which owns the HTTP, the worker * thread and the device key. Nothing here blocks a frame except the * rename, which is one round trip and wants a verdict for its modal. */ int ae_np_account_available(void*) { return rnet_account_available(); } int ae_np_account_login_begin(void*) { return rnet_account_login_begin(); } int ae_np_account_state(void*) { return rnet_account_state(); } const char* ae_np_account_handle(void*) { return rnet_account_handle(); } const char* ae_np_account_username(void*) { return rnet_account_username(); } const char* ae_np_account_error(void*) { return rnet_account_error(); } int ae_np_account_sign_out(void*) { return rnet_account_sign_out(); } int ae_np_account_set_handle(void*, const char* h) { return rnet_account_set_handle(h); } /* Point the account client at the lobby host -- once per URL, not once * per pump: the login worker thread reads the host while a sign-in is in * flight, and re-initialising it 60 times a second under that read is a * data race for no gain. The secret is anchored to the EXECUTABLE * directory before the first init: its default is the bare relative name * "netplay_secret", resolved against the working directory, so the same * install signed itself out depending on where it was launched from. * rnet_auth migrates an old CWD-relative file into this path on first * load, so nobody is signed out by the move. Same shape as the SNES * host (snes_host_lobby.c cb_pump). */ void ae_np_account_sync(void) { static std::string s_auth_url; const std::string& url = g_lnch_lobby_url; if (url.empty() || url == s_auth_url) return; if (s_auth_url.empty()) { const std::string secret = (exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : "") / "netplay_secret").string(); rnet_account_set_secret_path(secret.c_str()); } s_auth_url = url; rnet_account_init(url.c_str()); } #endif /* PSX_HAS_RECOMP_NET: account client is not linked in offline builds */ /* ---- list scope -------------------------------------------------------- * The launcher forks LAN / Direct IP from online before the browser, and * only it knows which fork the player took. Without the scope a player * who chose LAN was shown online rooms they had no connection for, and * one who chose online was shown LAN rooms from their own machine. The * values are RECOMP_LAUNCHER_LIST_SCOPE_*; 0 (any) is the historical * merge, and what a recomp-ui without the callback leaves us in. */ int g_lnch_list_scope = 0; int ae_np_list_scope_set(void*, int scope) { g_lnch_list_scope = scope; return 0; } bool ae_np_list_want_online(void) { return g_lnch_list_scope != 1; } bool ae_np_list_want_lan(void) { return g_lnch_list_scope != 2; } /* ---- moderation ------------------------------------------------------ * Both go to the lobby server; a LAN room has none, and the launcher * only offers them online. What a report contains is recomp-net's * (chat_report.h); the block list is the launcher's own file, pushed * here so the server can refuse to pair or seat the two together. */ int ae_np_chat_report(void*, const char* const* mids, int mid_count, const char* reason, const char* note) { if (g_lnch_hosting_lan || g_lnch_joined_lan) return -1; return psx_lobby_report_chat(mids, mid_count, reason, note); } int ae_np_set_blocks(void*, const char* accounts) { if (g_lnch_hosting_lan || g_lnch_joined_lan) return -1; return psx_lobby_set_blocks(accounts); } /* ---- automatch ------------------------------------------------------- * Thin: the lobby client owns the protocol and the state machine, and * this only translates its vocabulary into the launcher's. The one piece * of POLICY here is mods_enabled -- see ae_np_automatch_queue. */ bool ae_np_online_mode(void) { return !g_lnch_hosting_lan && !g_lnch_joined_lan && psx_lobby_connected(); } int ae_np_automatch_available(void*) { if (!ae_np_online_mode()) return 0; /* Ask once the answer could exist. The launcher polls this every * frame while the netplay page is up, which is exactly when a reply * is useful, and the client refuses to re-send while one is * outstanding. */ if (!psx_lobby_automatch_available()) (void)psx_lobby_automatch_request_rulesets(); return psx_lobby_automatch_available(); } int ae_np_automatch_ruleset_count(void*) { return psx_lobby_automatch_ruleset_count(); } #if defined(RECOMP_LAUNCHER_HAS_AUTOMATCH) int ae_np_automatch_ruleset_get(void*, int index, RecompLauncherCNetplayRuleset* out) { PsxLobbyRuleset r{}; if (!out || !psx_lobby_automatch_ruleset_get(index, &r)) return 0; std::memset(out, 0, sizeof(*out)); std::snprintf(out->id, sizeof(out->id), "%s", r.id); std::snprintf(out->label, sizeof(out->label), "%s", r.label); std::snprintf(out->caps_summary, sizeof(out->caps_summary), "%s", r.caps_summary); std::snprintf(out->game_version, sizeof(out->game_version), "%s", r.game_version); out->max_slots = r.max_slots > 0 ? r.max_slots : 2; return 1; } int ae_np_automatch_found_get(void*, RecompLauncherCNetplayFound* out) { PsxLobbyAutomatchFound f{}; if (!out || !psx_lobby_automatch_found_get(&f)) return 0; std::memset(out, 0, sizeof(*out)); std::snprintf(out->handle, sizeof(out->handle), "%s", f.opponent); std::snprintf(out->username, sizeof(out->username), "%s", f.opponent_username); std::snprintf(out->country, sizeof(out->country), "%s", f.opponent_country); std::snprintf(out->ruleset_label, sizeof(out->ruleset_label), "%s", f.ruleset_label); out->est_rtt_ms = f.est_rtt_ms; out->accept_secs_left = f.accept_secs; return 1; } #endif int ae_np_automatch_queue(void*, const char* ruleset_id) { if (!ae_np_online_mode()) return -1; /* mods_enabled asserts that a SIM-AFFECTING mod feature is on locally * beyond what the ruleset imposes. On PSX that is never true for a * netplay session: every netplay launch, rematch included, goes * through mod_runtime_clear_for_netplay and refuses to start if the * plan cannot be cleared, and the caps a match runs (aspect, turbo * loads, BIOS, FMV skip) are the server's ruleset, settled through * match_caps like any host's. There is no cosmetic-exemption * mechanism on this runtime either, so the evidence list is empty. */ return psx_lobby_automatch_queue(ruleset_id, 0, "") == 0 ? 0 : -1; } int ae_np_automatch_cancel(void*) { return psx_lobby_automatch_cancel(); } int ae_np_automatch_state(void*) { return psx_lobby_automatch_state(); } int ae_np_automatch_queued_secs(void*) { return psx_lobby_automatch_queued_secs(); } int ae_np_automatch_pool(void*) { return psx_lobby_automatch_pool(); } int ae_np_automatch_accept(void*, int accept) { return psx_lobby_automatch_accept(accept); } const char* ae_np_automatch_error(void*) { return psx_lobby_automatch_error(); } /* After a match: an automatch room is the server's, not a host's. It is * created at both-accept, nobody can join it, and there is no host to * rematch with -- staying seated parks the player in a room that can * never fill, and the server refuses their next ticket with * already_in_lobby. So leave it, and tell the caller not to reopen the * launcher on the room. 1 when a room was left. */ int ae_np_leave_automatch_room_after_match(void) { if (g_lnch_hosting_lan || g_lnch_joined_lan) return 0; if (!psx_lobby_automatch_room()) return 0; std::fprintf(stderr, "psxrecomp: leaving the automatch room (no host to rematch with)\n"); (void)psx_lobby_leave(); return 1; } int ae_np_chat_count(void*) { ae_np_chat_track_room(); if (g_lnch_hosting_lan || g_lnch_joined_lan) return g_lnch_lan_chat_count; return psx_lobby_chat_count(); } int ae_np_chat_get(void*, int index, RecompLauncherCNetplayChatMessage* out) { if (!out) return 0; std::memset(out, 0, sizeof(*out)); if (g_lnch_hosting_lan || g_lnch_joined_lan) { if (index < 0 || index >= g_lnch_lan_chat_count) return 0; const AeLanChatMsg& m = g_lnch_lan_chat[(g_lnch_lan_chat_head + index) % kAeLanChatRing]; std::snprintf(out->from, sizeof(out->from), "%s", m.from); std::snprintf(out->text, sizeof(out->text), "%s", m.text); out->is_system = m.is_system; out->is_local = std::strcmp(m.player_id, ae_np_lan_local_player_id()) == 0; out->seq = g_lnch_lan_chat_seq_base + (uint32_t)index; return 1; } PsxLobbyChatMsg msg{}; if (!psx_lobby_chat_get(index, &msg)) return 0; std::snprintf(out->from, sizeof(out->from), "%s", msg.from); std::snprintf(out->text, sizeof(out->text), "%s", msg.text); #if defined(RECOMP_LAUNCHER_HAS_CHAT_REPORT) /* The server's id for the line -- what a report names. Empty for a * system line or an older server, and then it cannot be reported. */ std::snprintf(out->mid, sizeof(out->mid), "%s", msg.mid); #endif #if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) std::snprintf(out->account, sizeof(out->account), "%s", msg.account); #endif out->is_local = msg.is_local; out->is_system = msg.is_system; out->seq = msg.seq; return 1; } /* Server chat: per-game, online only. A LAN room has no server and no * wider audience, so the panel is hidden there (send refuses, count 0). */ int ae_np_server_chat_send(void*, const char* text) { if (g_lnch_hosting_lan || g_lnch_joined_lan || !psx_lobby_connected()) return -1; char line[256]; ae_np_chat_sanitize(text, line, sizeof(line)); if (!line[0]) return -1; return psx_lobby_send_server_chat(line); } int ae_np_server_chat_count(void*) { if (g_lnch_hosting_lan || g_lnch_joined_lan) return 0; return psx_lobby_server_chat_count(); } int ae_np_server_chat_get(void*, int index, RecompLauncherCNetplayChatMessage* out) { if (!out) return 0; std::memset(out, 0, sizeof(*out)); PsxLobbyChatMsg msg{}; if (!psx_lobby_server_chat_get(index, &msg)) return 0; std::snprintf(out->from, sizeof(out->from), "%s", msg.from); std::snprintf(out->text, sizeof(out->text), "%s", msg.text); #if defined(RECOMP_LAUNCHER_HAS_CHAT_REPORT) /* The server's id for the line -- what a report names. Empty for a * system line or an older server, and then it cannot be reported. */ std::snprintf(out->mid, sizeof(out->mid), "%s", msg.mid); #endif #if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) std::snprintf(out->account, sizeof(out->account), "%s", msg.account); #endif out->is_local = msg.is_local; out->is_system = msg.is_system; out->seq = msg.seq; return 1; } int ae_np_guest_memcard_set(void*, int allow) { if (g_lnch_joined_lan) return -1; if (!g_lnch_hosting_lan && psx_lobby_in_lobby() && !psx_lobby_is_host()) return -1; g_lnch_guest_memcard = allow ? 1 : 0; if (g_lnch_hosting_lan) { AeLanLobbyState st; if (ae_np_read_lan_state(&st)) ae_np_lan_send_update_to_peers(st); } else { ae_np_push_match_caps(nullptr); } return 0; } int ae_np_input_delay_get(void*) { /* Online guests show host-authoritative match_caps. */ if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) { int d = caps->input_delay; if (d < 2) d = 2; if (d > 20) d = 20; return d; } } return g_lnch_lobby_input_delay; } int ae_np_input_delay_set(void*, int delay_frames) { if (delay_frames < 2) delay_frames = 2; if (delay_frames > 20) delay_frames = 20; g_lnch_lobby_input_delay = delay_frames; ae_np_push_match_caps(nullptr); return 0; } int ae_np_force_input_relay_get(void*) { if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) return caps->force_input_relay ? 1 : 0; } return g_lnch_force_input_relay; } int ae_np_force_input_relay_set(void*, int force) { g_lnch_force_input_relay = force ? 1 : 0; ae_np_push_match_caps(nullptr); return 0; } int ae_np_force_turn_get(void*) { if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) return caps->force_turn ? 1 : 0; } return g_lnch_force_turn; } /* Host relay preference and live state (recomp_launcher.h relay_host_*). */ int ae_np_relay_host_get(void*) { if (g_lnch_hosting_lan || g_lnch_joined_lan) return 0; if (psx_lobby_in_lobby()) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) return caps->relay_host ? 1 : 0; } return g_lnch_relay_host; } int ae_np_relay_host_set(void*, int on) { g_lnch_relay_host = on ? 1 : 0; psx_lobby_set_relay_host_pref(g_lnch_relay_host); if (!g_lnch_hosting_lan && !g_lnch_joined_lan) ae_np_push_match_caps(nullptr); /* no-op unless hosting online */ return 0; } int ae_np_relay_status(void*, char* out, size_t out_cap) { if (!out || !out_cap) return 0; out[0] = '\0'; #if defined(PSX_HAS_RECOMP_NET) if (g_lnch_hosting_lan || g_lnch_joined_lan || !psx_lobby_in_lobby()) return 0; RNetHostRelayStatus st; if (!psx_lobby_host_relay_status(&st)) return 0; if (st.role == 1) { int guests = 0, proven = 0; const int n = psx_lobby_member_count(); for (int i = 0; i < n; ++i) { PsxLobbyMember mem; if (!psx_lobby_member_get(i, &mem) || mem.is_spectator) continue; if (std::strcmp(mem.player_id, psx_lobby_host_player_id()) == 0) continue; guests++; if (std::strcmp(mem.path, "direct") == 0 && mem.path_fresh) proven++; } if (!st.port.done) std::snprintf(out, out_cap, "You carry the match. Opening UDP port %u (%s)...", (unsigned)st.port.local_port, st.port.stage[0] ? st.port.stage : "starting"); else if (!st.port.endpoint[0]) std::snprintf(out, out_cap, "%s", st.port.detail); else std::snprintf(out, out_cap, "You carry the match at %s (%s). Guests who can reach you: %d of %d%s", st.port.endpoint, st.port.how, proven, guests, guests && proven < guests ? ". Any guest who cannot sends the match through the lobby " "server's relay." : "."); return 1; } if (st.role == 2) { if (st.probing || !st.last_report[0]) std::snprintf(out, out_cap, "The host carries the match. Checking you can reach %s...", st.probed[0] ? st.probed : "the host"); else if (std::strcmp(st.last_report, "direct") == 0) std::snprintf(out, out_cap, "The host carries the match; you reach it directly (%s).", st.probed); else std::snprintf(out, out_cap, "The host's port did not answer (%s): the lobby server will relay " "this match. Retrying.", st.probed); return 1; } return 0; #else return 0; /* offline build: no lobby host relay */ #endif } int ae_np_force_turn_set(void*, int force) { if (g_lnch_hosting_lan || g_lnch_joined_lan) return 0; /* LAN/Direct IP — no online delay-floor hint */ /* Delay floor only (§108); does not change SFU vs ICE transport. */ g_lnch_force_turn = force ? 1 : 0; ae_np_push_match_caps(nullptr); return 0; } int ae_np_rollback_get(void*) { if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) return caps->rollback ? 1 : 0; } return g_lnch_rollback; } int ae_np_rollback_set(void*, int enable) { g_lnch_rollback = enable ? 1 : 0; ae_np_push_match_caps(nullptr); return 0; } int ae_np_multitap_analog_get(void*) { if (g_force_digital_pads) return 0; if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) return caps->multitap_analog ? 1 : 0; } return g_lnch_multitap_analog; } int ae_np_multitap_analog_set(void*, int enable) { if (g_force_digital_pads) enable = 0; g_lnch_multitap_analog = enable ? 1 : 0; ae_np_push_match_caps(nullptr); return 0; } int ae_np_input_prediction_get(void*) { if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) { int p = caps->input_prediction; if (p < 2) p = 2; if (p > 16) p = 16; return p; } } return g_lnch_lobby_input_prediction; } int ae_np_input_prediction_set(void*, int prediction_frames) { if (prediction_frames < 2) prediction_frames = 2; if (prediction_frames > 16) prediction_frames = 16; g_lnch_lobby_input_prediction = prediction_frames; ae_np_push_match_caps(nullptr); return 0; } /* Seat ceiling for the active room (listing / LOBBY UI). 0 if unknown. */ int ae_np_lobby_max_slots(void*) { if (g_lnch_hosting_lan || g_lnch_joined_lan) { AeLanLobbyState state; if (ae_np_read_lan_state(&state) && state.max_slots >= 2) return state.max_slots; return g_lnch_host_max_slots >= 2 ? g_lnch_host_max_slots : 0; } if (psx_lobby_in_lobby()) { const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); if (ji && ji->max_slots >= 2) return ji->max_slots; return g_lnch_host_max_slots >= 2 ? g_lnch_host_max_slots : 0; } return 0; } 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()); /* The auth endpoints live on the same host and port as the lobby * socket, so the sign-in follows whatever server the player points at. */ #if defined(PSX_HAS_RECOMP_NET) ae_np_account_sync(); #endif } int ae_np_connect(void*) { psx_lobby_set_game_identity(g_lnch_netplay_game_name.c_str(), psx_lobby_game_version()); psx_lobby_set_disc_fp(g_session_disc_fp.c_str()); psx_lobby_set_max_slots(g_lnch_game_players); ae_np_refresh_mod_offer(); const int rc = psx_lobby_connect(ae_np_default_url(nullptr)); /* connect resets g_lc; re-apply so create/join never advertise "". */ psx_lobby_set_game_identity(g_lnch_netplay_game_name.c_str(), psx_lobby_game_version()); psx_lobby_set_disc_fp(g_session_disc_fp.c_str()); psx_lobby_set_max_slots(g_lnch_game_players); ae_np_refresh_mod_offer(); return rc; } int ae_np_connected(void*) { return psx_lobby_connected(); } static void ae_np_lan_udp_pump(void) { if (!g_lnch_hosting_lan && !(g_lnch_joined_lan && g_lnch_remote_lan)) return; if (g_lnch_hosting_lan) { AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) return; const int lobby_port = ae_np_lan_endpoint_port(st.endpoint); if (g_lnch_lan_udp == kAeLanSockInvalid) (void)ae_np_lan_udp_ensure(true, lobby_port); /* Periodic broadcast BEACON so Refresh can find us without a * unicast target (and for guests that listen between browses). */ if (g_lnch_lan_udp != kAeLanSockInvalid && !st.started) { const uint32_t now = SDL_GetTicks(); if (g_lnch_lan_beacon_announce_ms == 0u || now - g_lnch_lan_beacon_announce_ms >= 2000u) { g_lnch_lan_beacon_announce_ms = now; ae_np_lan_sock_set_broadcast(g_lnch_lan_udp); char beacon[768]; if (ae_np_lan_format_beacon(beacon, sizeof(beacon), st) > 0) ae_np_lan_broadcast_msg(g_lnch_lan_udp, lobby_port, beacon); } } } else if (g_lnch_remote_lan) { if (g_lnch_lan_udp == kAeLanSockInvalid) (void)ae_np_lan_udp_ensure(false, 0); const uint32_t now = SDL_GetTicks(); if (g_lnch_lan_udp != kAeLanSockInvalid && now - g_lnch_lan_join_pulse_ms >= 400u) { g_lnch_lan_join_pulse_ms = now; char host[64]; if (ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host))) { sockaddr_in to{}; to.sin_family = AF_INET; to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); if (inet_pton(AF_INET, host, &to.sin_addr) == 1) { std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; char msg[448]; int off = std::snprintf( msg, sizeof(msg), "MOTK3 JOIN\n%s\n%s\n%s\n", ae_np_lan_local_player_id(), me.c_str(), g_lnch_remote_lan_state.password.c_str()); ae_np_append_lan_bios_join(msg, sizeof(msg), &off); ae_np_lan_udp_sendto(to, msg); } } } } if (g_lnch_lan_udp == kAeLanSockInvalid) return; for (;;) { char buf[8448]; /* bounded 8 KiB plan plus LAN protocol fields */ sockaddr_in from{}; #ifdef _WIN32 int fromlen = (int)sizeof(from); const int n = recvfrom(g_lnch_lan_udp, buf, (int)sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); #else socklen_t fromlen = sizeof(from); const int n = (int)recvfrom(g_lnch_lan_udp, buf, sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); #endif if (n <= 0) break; buf[n] = '\0'; /* Content negotiation: a guest accepts lobby datagrams (and so a * plan) only from the host endpoint it joined. */ if (g_lnch_remote_lan && g_netplay_content_negotiation) { char host[64]; in_addr expected{}; if (!ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host)) || inet_pton(AF_INET, host, &expected) != 1 || from.sin_addr.s_addr != expected.s_addr || ntohs(from.sin_port) != ae_np_lan_endpoint_port(g_lnch_lan_endpoint)) continue; } if (std::strncmp(buf, "MOTK6 MODS\n", 11) == 0 && g_lnch_remote_lan && g_netplay_content_negotiation) { const char* json = std::strchr(buf + 11, '\n'); PsxLobbyMatchCaps caps{}; if (json && psx_lobby_decode_match_caps(json + 1, &caps)) { g_lnch_remote_lan_state.mod_caps_json = json + 1; psx_lobby_set_local_match_caps(&caps); } continue; } if (std::strncmp(buf, "MOTK1 PING", 10) == 0 && g_lnch_hosting_lan) { ae_np_lan_udp_sendto(from, "MOTK1 PONG\n"); continue; } /* LAN lobby discovery: answer browse with a BEACON (unicast). */ if (std::strncmp(buf, "MOTK1 BROWSE", 12) == 0 && g_lnch_hosting_lan) { AeLanLobbyState st; if (ae_np_read_lan_state(&st) && !st.started) { char beacon[768]; if (ae_np_lan_format_beacon(beacon, sizeof(beacon), st) > 0) ae_np_lan_udp_sendto(from, beacon); } continue; } if (std::strncmp(buf, "MOTK3 JOIN\n", 11) == 0 && g_lnch_hosting_lan) { char* p = buf + 11; char* nl = std::strchr(p, '\n'); if (!nl) continue; *nl = '\0'; const char* player_id = p; p = nl + 1; nl = std::strchr(p, '\n'); if (!nl) continue; *nl = '\0'; const char* name = p; p = nl + 1; nl = std::strchr(p, '\n'); const char* pass; char* bios_tail = nullptr; if (nl) { *nl = '\0'; pass = p; bios_tail = nl + 1; } else { pass = p; } AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) continue; if (st.password != pass) { ae_np_lan_udp_sendto(from, "MOTK1 ERR\nbad_password\n"); continue; } const int slot = ae_np_lan_seat_guest(st, player_id, name); if (slot < 0) { ae_np_lan_udp_sendto(from, "MOTK1 ERR\nfull\n"); continue; } int prefer_open = 1, can_open = 1, can_scph = 0; AeLanSlotMemcard mc_offer{}; (void)ae_np_parse_lan_memcard_tail(bios_tail, &mc_offer); /* Read before ae_np_parse_lan_bios_tail, which cuts the tail. */ const uint32_t retail_crc = ae_np_parse_lan_bios_crc_tail(bios_tail); if (bios_tail && ae_np_parse_lan_bios_tail(bios_tail, &prefer_open, &can_open, &can_scph) == 0) { ae_np_lan_store_slot_bios(slot, prefer_open, can_open, can_scph, retail_crc); } else { /* Legacy JOIN without bios_offer — cannot assume SCPH. */ ae_np_lan_clear_slot_bios(slot); } if (slot >= 0 && slot < kAeLanMaxSlots) g_lnch_lan_slot_memcard[slot] = mc_offer; st.started = false; ae_np_lan_sync_legacy_names(st); if (!ae_np_write_lan_state(st)) continue; ae_np_lan_set_peer_slot(slot, from); ae_np_lan_send_update_to_peers(st); ae_np_lan_chat_announce(st.slot_name[slot].c_str(), "has joined."); continue; } if (std::strncmp(buf, "MOTK1 JOIN\n", 11) == 0 && g_lnch_hosting_lan) { char* p = buf + 11; char* nl = std::strchr(p, '\n'); if (!nl) continue; *nl = '\0'; const char* name = p; p = nl + 1; nl = std::strchr(p, '\n'); if (nl) *nl = '\0'; const char* pass = p; AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) continue; if (st.password != pass) { ae_np_lan_udp_sendto(from, "MOTK1 ERR\nbad_password\n"); continue; } /* Legacy JOIN: synthesize an id from peer addr so same-name * clients still get distinct seats + (2)/(3) labels. */ char synth_id[48]; std::snprintf(synth_id, sizeof(synth_id), "motk1-%08x-%04x", (unsigned)ntohl(from.sin_addr.s_addr), (unsigned)ntohs(from.sin_port)); const int slot = ae_np_lan_seat_guest(st, synth_id, name); if (slot < 0) { ae_np_lan_udp_sendto(from, "MOTK1 ERR\nfull\n"); continue; } /* Legacy MOTK1 JOIN has no bios_offer. */ ae_np_lan_clear_slot_bios(slot); st.started = false; ae_np_lan_sync_legacy_names(st); if (!ae_np_write_lan_state(st)) continue; ae_np_lan_set_peer_slot(slot, from); ae_np_lan_send_update_to_peers(st); ae_np_lan_chat_announce(st.slot_name[slot].c_str(), "has joined."); continue; } if (std::strncmp(buf, "MOTK5 SEATMOVE\n", 15) == 0 && g_lnch_hosting_lan) { /* MOTK5 SEATMOVE\n\n\n */ char* p = buf + 15; char* nl = std::strchr(p, '\n'); if (!nl) continue; *nl = '\0'; const char* who = p; p = nl + 1; nl = std::strchr(p, '\n'); if (nl) *nl = '\0'; ae_np_lan_host_seat_request(who, std::atoi(p)); continue; } if (std::strncmp(buf, "MOTK5 SWAPANS\n", 14) == 0 && g_lnch_hosting_lan) { /* MOTK5 SWAPANS\n\n\n\n */ char* p = buf + 14; char* lines[3] = {}; for (int i = 0; i < 3; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) { if (i < 2) lines[i] = nullptr; break; } *nl = '\0'; p = nl + 1; } if (!lines[0] || !lines[1] || !lines[2]) continue; ae_np_lan_host_seat_answer(lines[0], lines[1], std::atoi(lines[2]) != 0); continue; } if (std::strncmp(buf, "MOTK5 CHATREQ\n", 14) == 0 && g_lnch_hosting_lan) { /* MOTK5 CHATREQ\n\n\n — a seated guest's * line. The host stamps the name it seated them under and * relays to everyone (itself included, via the ring). */ char* p = buf + 14; char* nl = std::strchr(p, '\n'); if (!nl) continue; *nl = '\0'; const char* who = p; p = nl + 1; nl = std::strchr(p, '\n'); if (nl) *nl = '\0'; char text[256]; ae_np_chat_sanitize(p, text, sizeof(text)); if (!text[0]) continue; AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) continue; const int slot = ae_np_lan_find_slot_by_id(st, who); if (slot < 0 || slot >= kAeLanMaxSlots) continue; const char* from = st.slot_name[slot].c_str(); ae_np_lan_chat_push(who, from, text, 0); ae_np_lan_send_chat_to_peers(who, from, text); continue; } if (std::strncmp(buf, "MOTK5 MEMCARD\n", 14) == 0 && g_lnch_hosting_lan) { /* MOTK5 MEMCARD\n\n\n\n — a seated * guest re-publishing its memcard offer mid-lobby. */ char* p = buf + 14; char* lines[3] = {}; for (int i = 0; i < 3; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) break; *nl = '\0'; p = nl + 1; } if (!lines[0] || !lines[1] || !lines[2]) continue; AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) continue; const int slot = ae_np_lan_find_slot_by_id(st, lines[0]); if (slot < 0 || slot >= kAeLanMaxSlots || slot == st.host_slot) continue; g_lnch_lan_slot_memcard[slot].valid = 1; g_lnch_lan_slot_memcard[slot].has_card = std::atoi(lines[1]) != 0; g_lnch_lan_slot_memcard[slot].share = std::atoi(lines[2]) != 0; ae_np_lan_send_update_to_peers(st); continue; } if ((std::strncmp(buf, "MOTK3 LEAVE\n", 12) == 0 || std::strncmp(buf, "MOTK1 LEAVE\n", 12) == 0) && g_lnch_hosting_lan) { const bool by_id = std::strncmp(buf, "MOTK3 LEAVE\n", 12) == 0; char* p = buf + 12; char* nl = std::strchr(p, '\n'); if (nl) *nl = '\0'; const char* leave_key = (p && p[0]) ? p : nullptr; AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) continue; int cleared = -1; std::string left_name; if (leave_key) { if (by_id) { cleared = ae_np_lan_find_slot_by_id(st, leave_key); if (cleared == st.host_slot) cleared = -1; } else { for (int i = 0; i < st.max_slots; ++i) { if (i == st.host_slot) continue; if (st.slot_name[i] == leave_key) { cleared = i; break; } } } if (cleared >= 0) { left_name = st.slot_name[cleared]; st.slot_name[cleared].clear(); st.slot_id[cleared].clear(); ae_np_lan_clear_slot_bios(cleared); } } else { /* Legacy leave without name: drop first guest. */ for (int i = 0; i < st.max_slots; ++i) { if (i == st.host_slot) continue; if (!st.slot_name[i].empty()) { left_name = st.slot_name[i]; st.slot_name[i].clear(); st.slot_id[i].clear(); ae_np_lan_clear_slot_bios(i); cleared = i; break; } } } st.started = false; ae_np_lan_sync_legacy_names(st); ae_np_write_lan_state(st); if (cleared >= 0) ae_np_lan_clear_peer_slot(cleared); ae_np_lan_send_update_to_peers(st); if (cleared >= 0 && !left_name.empty()) ae_np_lan_chat_announce(left_name.c_str(), "has left."); continue; } if (!g_lnch_remote_lan) continue; if (std::strncmp(buf, "MOTK5 SWAPASK\n", 14) == 0) { /* MOTK5 SWAPASK\n\n\n\n */ char* p = buf + 14; char* lines[3] = {}; for (int i = 0; i < 3; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) { if (i < 2) lines[i] = nullptr; break; } *nl = '\0'; p = nl + 1; } if (!lines[0] || !lines[1] || !lines[2]) continue; g_lnch_lan_swap.incoming = true; std::snprintf(g_lnch_lan_swap.asker_id, sizeof(g_lnch_lan_swap.asker_id), "%s", lines[0]); std::snprintf(g_lnch_lan_swap.asker_name, sizeof(g_lnch_lan_swap.asker_name), "%s", lines[1]); g_lnch_lan_swap.from_slot = std::atoi(lines[2]); continue; } if (std::strncmp(buf, "MOTK5 SWAPRES\n", 14) == 0) { g_lnch_lan_swap.outgoing = std::atoi(buf + 14) != 0 ? 2 : -1; continue; } if (std::strncmp(buf, "MOTK5 CHAT\n", 11) == 0) { /* MOTK5 CHAT\n\n\n\n from the host. */ char* p = buf + 11; char* lines[3] = {}; for (int i = 0; i < 3; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) { if (i < 2) lines[i] = nullptr; break; } *nl = '\0'; p = nl + 1; } if (!lines[0] || !lines[1] || !lines[2]) continue; /* No sender name = a system line ("X has joined."). */ ae_np_lan_chat_push(lines[0], lines[1], lines[2], lines[1][0] == '\0'); continue; } if (std::strncmp(buf, "MOTK5 UPDATE\n", 13) == 0) { AeLanLobbyState st = g_lnch_remote_lan_state; if (ae_np_lan_parse_motk5_update(buf + 13, &st) != 0) continue; st.endpoint = g_lnch_lan_endpoint; g_lnch_remote_lan_state = st; const int my_slot = ae_np_lan_find_slot_by_id(st, ae_np_lan_local_player_id()); if (my_slot < 0) { g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_lan_endpoint.clear(); g_lnch_lan_my_slot = -1; ae_np_lan_udp_close(); } else { /* The host echoes our offer back in its seat table. If it * does not match what we published, our MOTK5 MEMCARD was * lost (UDP) — resend; the next echo settles it. */ const AeLanSlotMemcard echoed = g_lnch_lan_slot_memcard[my_slot]; g_lnch_lan_my_slot = my_slot; ae_np_lan_sync_local_slot_bios(); ae_np_lan_sync_local_slot_memcard(); if (g_lnch_memcard_offer.valid && (!echoed.valid || echoed.has_card != (g_lnch_memcard_offer.has_card ? 1 : 0) || echoed.share != (g_lnch_memcard_offer.share ? 1 : 0))) ae_np_lan_send_memcard_offer_to_host(); } continue; } if (std::strncmp(buf, "MOTK4 UPDATE\n", 13) == 0) { AeLanLobbyState st = g_lnch_remote_lan_state; if (ae_np_lan_parse_motk4_update(buf + 13, &st) != 0) continue; st.endpoint = g_lnch_lan_endpoint; g_lnch_remote_lan_state = st; const int my_slot = ae_np_lan_find_slot_by_id(st, ae_np_lan_local_player_id()); if (my_slot < 0) { g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_lan_endpoint.clear(); g_lnch_lan_my_slot = -1; ae_np_lan_udp_close(); } else { g_lnch_lan_my_slot = my_slot; ae_np_lan_sync_local_slot_bios(); } continue; } if (std::strncmp(buf, "MOTK3 UPDATE\n", 13) == 0) { AeLanLobbyState st = g_lnch_remote_lan_state; if (ae_np_lan_parse_motk3_update(buf + 13, &st) != 0) continue; st.endpoint = g_lnch_lan_endpoint; g_lnch_remote_lan_state = st; const int my_slot = ae_np_lan_find_slot_by_id(st, ae_np_lan_local_player_id()); if (my_slot < 0) { g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_lan_endpoint.clear(); g_lnch_lan_my_slot = -1; ae_np_lan_udp_close(); } else { g_lnch_lan_my_slot = my_slot; } continue; } if (std::strncmp(buf, "MOTK2 UPDATE\n", 13) == 0) { AeLanLobbyState st = g_lnch_remote_lan_state; if (ae_np_lan_parse_motk2_update(buf + 13, &st) != 0) continue; st.endpoint = g_lnch_lan_endpoint; g_lnch_remote_lan_state = st; if (g_lnch_lan_my_slot >= 0 && g_lnch_lan_my_slot < st.max_slots && !st.slot_name[g_lnch_lan_my_slot].empty()) { /* keep assigned slot */ } else { std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; const int my_slot = ae_np_lan_find_guest_slot_by_name(st, me.c_str()); if (my_slot < 0) { g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_lan_endpoint.clear(); g_lnch_lan_my_slot = -1; ae_np_lan_udp_close(); } else { g_lnch_lan_my_slot = my_slot; } } continue; } if (std::strncmp(buf, "MOTK1 UPDATE\n", 13) == 0) { char* p = buf + 13; char* lines[4] = {}; for (int i = 0; i < 4; ++i) { lines[i] = p; char* nl = std::strchr(p, '\n'); if (!nl) break; *nl = '\0'; p = nl + 1; } if (!lines[0] || !lines[1] || !lines[2] || !lines[3]) continue; g_lnch_remote_lan_state.host_name = lines[0]; g_lnch_remote_lan_state.joiner_name = lines[1]; g_lnch_remote_lan_state.host_slot = (std::atoi(lines[2]) == 1) ? 1 : 0; g_lnch_remote_lan_state.started = std::atoi(lines[3]) != 0; g_lnch_remote_lan_state.max_slots = 2; g_lnch_remote_lan_state.slot_name[0].clear(); g_lnch_remote_lan_state.slot_name[1].clear(); g_lnch_remote_lan_state.slot_name[g_lnch_remote_lan_state.host_slot] = g_lnch_remote_lan_state.host_name; g_lnch_remote_lan_state.slot_name[1 - g_lnch_remote_lan_state.host_slot] = g_lnch_remote_lan_state.joiner_name; std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; if (g_lnch_remote_lan_state.joiner_name != me) { g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_lan_endpoint.clear(); g_lnch_lan_my_slot = -1; ae_np_lan_udp_close(); } else { g_lnch_lan_my_slot = 1 - g_lnch_remote_lan_state.host_slot; } continue; } /* MOTK6 START = MOTK1 START fields plus the host plan. Sent only by * content-negotiation hosts; everyone else keeps MOTK1 START. */ if (std::strncmp(buf, "MOTK6 START\n", 12) == 0 && g_lnch_remote_lan && g_netplay_content_negotiation) { const char* json = buf + 12; for (int i = 0; i < 6 && json; ++i) { json = std::strchr(json, '\n'); if (json) ++json; } PsxLobbyMatchCaps caps{}; if (!json || !psx_lobby_decode_match_caps(json, &caps)) continue; g_lnch_remote_lan_state.mod_caps_json = json; psx_lobby_set_local_match_caps(&caps); g_lnch_lan_start_plan_session = (uint32_t)std::strtoul(buf + 12, nullptr, 10); buf[4] = '1'; /* shared START fields below */ } if (std::strncmp(buf, "MOTK1 START\n", 12) == 0 && (g_lnch_remote_lan || !g_netplay_content_negotiation)) { g_lnch_remote_lan_state.started = true; /* MOTK1 START\n\n[\n\n\n * [\n[\n[\n]]]] * Trailing caps are host-authoritative (incl. settled BIOS). */ char* p = buf + 12; char* nl = std::strchr(p, '\n'); if (nl) *nl = '\0'; if (*p) { const unsigned v = (unsigned)std::strtoul(p, nullptr, 10); if (v) { g_lnch_lan_session_id = (uint32_t)v; g_lnch_remote_lan_state.session_id = (uint32_t)v; } } /* Default off: a legacy host never uploads a guest card, and * a guest that waited for one would stall the match. */ g_lnch_lan_guest_memcard_active = 0; if (nl) { p = nl + 1; char* lines[6] = {}; for (int i = 0; i < 6; ++i) { if (!p || !*p) break; lines[i] = p; char* n2 = std::strchr(p, '\n'); if (n2) { *n2 = '\0'; p = n2 + 1; } else { p = nullptr; } } if (lines[4] && lines[4][0]) g_lnch_lan_guest_memcard_active = (std::atoi(lines[4]) != 0) ? 1 : 0; if (lines[0] && lines[0][0]) { int d = std::atoi(lines[0]); if (d < 2) d = 2; if (d > 20) d = 20; g_lnch_lobby_input_delay = d; } if (lines[1] && lines[1][0]) { int pred = std::atoi(lines[1]); if (pred < 2) pred = 2; if (pred > 16) pred = 16; g_lnch_lobby_input_prediction = pred; } if (lines[2] && lines[2][0]) g_lnch_rollback = (std::atoi(lines[2]) != 0) ? 1 : 0; if (lines[3] && lines[3][0]) /* Line 6 names the retail image; an * older host sends none, which accepts any dump. */ ae_np_set_session_bios_token( lines[3], lines[5] ? netplay_bios_parse_crc(lines[5]) : 0u); else /* Legacy host: force OpenBIOS so mixed local prefs * cannot silently desync. */ ae_np_set_session_bios_token("openbios"); } if (g_lnch_lan_start_plan_session == g_lnch_remote_lan_state.session_id && !g_lnch_remote_lan_state.mod_caps_json.empty()) { PsxLobbyMatchCaps caps{}; if (psx_lobby_decode_match_caps(g_lnch_remote_lan_state.mod_caps_json.c_str(), &caps)) ae_np_set_session_bios_token(caps.session_bios, caps.session_bios_crc); } continue; } if (std::strncmp(buf, "MOTK1 KICK\n", 11) == 0 || std::strncmp(buf, "MOTK1 ERR\n", 10) == 0) { g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_lan_endpoint.clear(); g_lnch_remote_lan_state = {}; ae_np_lan_udp_close(); } } } int ae_np_connecting(void*) { return psx_lobby_connecting(); } void ae_np_pump(void*) { #if defined(PSX_HAS_RECOMP_NET) /* Redeems a stored device key on the first pump, so a machine that has * signed in once comes up signed in with no player action. */ ae_np_account_sync(); rnet_account_pump(); /* Publish the account name to the lobby. The lobby's display name is * what seats and the players-online list show, and nothing else * pushed the handle into it: signing in -- including the automatic * sign-in from a stored secret -- only updated the ACCOUNT, so a * signed-in player created a room and appeared under the LAN name. * Done here rather than at a sign-in edge because there is no single * such edge: interactive login, stored-secret redemption and a * server-side handle change all land asynchronously in the pump. * Comparing against the live name makes this idempotent -- * set_display_name only re-sends hello when the value changed. */ if (rnet_account_state() == RNET_ACCOUNT_SIGNED_IN) { const char* handle = rnet_account_handle(); const char* shown = psx_lobby_display_name(); if (handle && handle[0] && (!shown || std::strcmp(shown, handle) != 0)) psx_lobby_set_display_name(handle); } #endif psx_lobby_pump(); ae_np_lan_browse_pump(); ae_np_lan_udp_pump(); /* Lobby UI has no Ready toggle; production WS still requires every * seated player ready before start. Keep seats ready while in-room * (including after soft-return rematch clears ready). Re-advertise * when the local BIOS offer changes so settle stays current. */ if (!g_lnch_hosting_lan && !g_lnch_joined_lan && psx_lobby_in_lobby()) { static PsxLobbyBiosOffer s_last_offer{}; static PsxLobbyMemcardOffer s_last_mc{}; static PsxLobbyModOffer s_last_mod_offer{}; ae_np_refresh_bios_offer_from_disk(); ae_np_refresh_mod_offer(); const PsxLobbyBiosOffer* cur = psx_lobby_bios_offer(); const PsxLobbyMemcardOffer* mc = psx_lobby_memcard_offer(); const PsxLobbyModOffer* mo = psx_lobby_mod_offer(); const int mod_offer_changed = g_netplay_content_negotiation && mo && (!s_last_mod_offer.valid || std::memcmp(mo, &s_last_mod_offer, sizeof(*mo)) != 0); const int offer_changed = !cur || !s_last_offer.valid || cur->can_openbios != s_last_offer.can_openbios || cur->can_scph1001 != s_last_offer.can_scph1001 || cur->prefer_openbios != s_last_offer.prefer_openbios || (mc && mc->valid && (!s_last_mc.valid || mc->has_card != s_last_mc.has_card || mc->share != s_last_mc.share)) || mod_offer_changed; if (!psx_lobby_local_ready() || offer_changed) { (void)psx_lobby_set_ready(1); if (cur) s_last_offer = *cur; if (mc) s_last_mc = *mc; if (mo) s_last_mod_offer = *mo; } } } 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*) { ae_np_lan_rescan(); psx_lobby_request_list(); } int ae_np_list_count(void*) { return (ae_np_list_want_online() ? psx_lobby_list_count() : 0) + (ae_np_list_want_lan() ? ae_np_lan_list_extra_count() : 0); } int ae_np_list_get(void*, int index, RecompLauncherCNetplayLobby* out) { if (!out) return 0; const int remote_count = ae_np_list_want_online() ? psx_lobby_list_count() : 0; if (index >= remote_count) { const int lan_i = index - remote_count; if (!ae_np_list_want_lan()) return 0; ae_np_lan_prune_discovered(); if (g_lnch_lan_discovered_n > 0) return ae_np_lan_fill_lobby_from_discovered(lan_i, out); return (lan_i == 0 && ae_np_lan_list_visible()) ? ae_np_read_lan_lobby(out) : 0; } 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; out->latency_ms = row.latency_ms; std::snprintf(out->host_country, sizeof(out->host_country), "%s", row.host_country); out->allow_spectators = row.allow_spectators; out->max_spectators = row.max_spectators; out->spectator_count = row.spectator_count; return 1; } /* Players online: the hub's `players` list. LAN rooms have no hub, so * a LAN-only session reports nobody rather than guessing. */ int ae_np_online_count(void*) { return psx_lobby_connected() ? psx_lobby_online_count() : 0; } int ae_np_online_get(void*, int index, RecompLauncherCNetplayOnlinePlayer* out) { if (!out) return 0; PsxLobbyOnlinePlayer p{}; if (!psx_lobby_online_get(index, &p)) return 0; std::memset(out, 0, sizeof(*out)); std::snprintf(out->display_name, sizeof(out->display_name), "%s", p.display_name); std::snprintf(out->country, sizeof(out->country), "%s", p.country); #if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) std::snprintf(out->account, sizeof(out->account), "%s", p.account); #endif std::snprintf(out->lobby_name, sizeof(out->lobby_name), "%s", p.lobby_name); out->in_lobby = p.lobby_id[0] != '\0'; out->hosting = p.hosting; /* Which row is us: the hub tags each row with the first characters * of its connection id. A name match would mark every namesake. */ const char* me = psx_lobby_player_id(); out->is_local = me && me[0] && p.tag[0] && std::strncmp(me, p.tag, std::strlen(p.tag)) == 0; 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); #endif 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; #ifdef _WIN32 SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); if (s == INVALID_SOCKET) { freeaddrinfo(res); return 0; } DWORD timeout_ms = 3000; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout_ms, sizeof(timeout_ms)); setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout_ms, sizeof(timeout_ms)); #else int s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); if (s < 0) { freeaddrinfo(res); return 0; } timeval tv{}; tv.tv_sec = 3; tv.tv_usec = 0; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); #endif int ok = 0; #ifdef _WIN32 const int connected = connect(s, res->ai_addr, (int)res->ai_addrlen) != SOCKET_ERROR; #else const int connected = connect(s, res->ai_addr, (socklen_t)res->ai_addrlen) == 0; #endif if (connected) { const char req[] = "GET / HTTP/1.1\r\n" "Host: api.ipify.org\r\n" "User-Agent: psxrecomp-netplay/1.0\r\n" "Connection: close\r\n\r\n"; #ifdef _WIN32 (void)send(s, req, (int)strlen(req), 0); char resp[1024]; int n = recv(s, resp, sizeof(resp) - 1, 0); #else (void)send(s, req, strlen(req), 0); char resp[1024]; ssize_t n = recv(s, resp, sizeof(resp) - 1, 0); #endif 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; } } } #ifdef _WIN32 closesocket(s); #else close(s); #endif freeaddrinfo(res); return ok; } /* Collect non-loopback IPv4 addresses for local_address_get. */ static void ae_np_collect_local_addresses( std::vector* 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 buf(buf_len); IP_ADAPTER_ADDRESSES* addrs = reinterpret_cast(buf.data()); ULONG rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len); if (rc == ERROR_BUFFER_OVERFLOW) { buf.resize(buf_len); addrs = reinterpret_cast(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(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(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 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 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; } /* LAN/Direct IP rooms own membership via the local file registry. Server * lobbies use WebSocket lobby_update. Never mix: LAN mode wins if set. */ static bool ae_np_use_lan_members(void) { return g_lnch_hosting_lan || g_lnch_joined_lan; } static bool ae_np_use_ws_members(void) { return !ae_np_use_lan_members() && psx_lobby_in_lobby() != 0; } /* Joiner was cleared / file gone → treat as kicked or host left. */ static void ae_np_poll_lan_joiner_still_seated(void) { if (!g_lnch_joined_lan) return; if (g_lnch_remote_lan) { /* Remote seat cleared by UDP KICK/ERR/UPDATE in pump. */ return; } AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) { g_lnch_joined_lan = false; g_lnch_lan_endpoint.clear(); g_lnch_lan_my_slot = -1; return; } bool seated = false; const int by_id = ae_np_lan_find_slot_by_id(state, ae_np_lan_local_player_id()); if (by_id >= 0 && by_id != state.host_slot) { seated = true; g_lnch_lan_my_slot = by_id; } else if (g_lnch_lan_my_slot >= 0 && g_lnch_lan_my_slot < state.max_slots && g_lnch_lan_my_slot != state.host_slot && !state.slot_name[g_lnch_lan_my_slot].empty()) { seated = true; } else { std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; const int slot = ae_np_lan_find_guest_slot_by_name(state, me.c_str()); if (slot >= 0) { seated = true; g_lnch_lan_my_slot = slot; } } if (!seated) { g_lnch_joined_lan = false; g_lnch_lan_endpoint.clear(); g_lnch_lan_my_slot = -1; } } /* host_endpoint is in/out (capacity >= 64). Online may rewrite the UDP * port when the requested one is busy. Returns 0 ok, -4 port unavailable. */ int ae_np_create(void*, const char* lobby_name, char* host_endpoint, const char* password, const RecompLauncherCSettings* settings, int lan_only, int max_slots) { int game_max = g_lnch_game_players >= 2 ? g_lnch_game_players : 2; if (game_max > PSX_MAX_PLAYERS) game_max = PSX_MAX_PLAYERS; if (game_max > 8) game_max = 8; if (max_slots < 2) max_slots = 2; if (max_slots > game_max) max_slots = game_max; /* Lobby + delay-sync ceiling (party games up to 8). */ if (max_slots > RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS) max_slots = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS; g_lnch_host_max_slots = max_slots; 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"); const int want_port = ae_np_lan_endpoint_port(endpoint); /* LAN/Direct IP only: local registry + UDP membership (no lobby WS). * Online create always uses psx_lobby_create — even when the UI passes * a concrete LAN IPv4 for MotK-style host_bind rewrite. */ if (lan_only) { if (!g_lnch_mod_plan_error.empty()) return -1; /* LAN/Direct IP: exact port required — fail if busy. */ if (psx_lobby_in_lobby()) (void)psx_lobby_leave(); if (!ae_np_write_lan_lobby(lobby_name, endpoint, password, max_slots)) return -4; ae_np_push_match_caps(settings); if (host_endpoint) std::snprintf(host_endpoint, 96, "%s", endpoint); return 0; } /* Online: auto-pick a free UDP port starting at the requested one. */ const int free_port = ae_np_find_free_udp_port(want_port); if (free_port < 0) return -4; if (free_port != want_port && !ae_np_endpoint_replace_port(endpoint, sizeof(endpoint), free_port)) { return -4; } if (host_endpoint) std::snprintf(host_endpoint, 96, "%s", endpoint); if (!g_lnch_mod_plan_error.empty()) { std::fprintf(stderr, "psxrecomp: cannot create modded lobby: %s\n", g_lnch_mod_plan_error.c_str()); return -1; } if (g_lnch_hosting_lan) { std::error_code ec; std::filesystem::remove(ae_np_lan_file(), ec); } ae_np_lan_udp_close(); g_lnch_hosting_lan = false; g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); psx_lobby_set_max_slots(max_slots); ae_np_refresh_mod_offer(); /* Ensure TOC fp survives connect/reset before the lobby stores it. */ psx_lobby_set_disc_fp(g_session_disc_fp.c_str()); return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby", g_lnch_netplay_game_name.c_str(), psx_lobby_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(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); } ae_np_refresh_mod_offer(); if (lobby_id && strncmp(lobby_id, "lan:", 4) == 0) { const char* endpoint = lobby_id + 4; if (!endpoint[0]) return -1; if (psx_lobby_in_lobby()) (void)psx_lobby_leave(); /* Adopt host D/P/rollback from the last BEACON (if any) before * seating — START still re-asserts host-authoritative caps. */ ae_np_lan_apply_discovered_caps(endpoint); /* Must be a live LAN/Direct IP host (UDP PONG). Online-only hosts * never answer — refuse so we don't open a fake local room. */ if (!ae_np_lan_probe_host_ms(endpoint, 750u)) return -3; /* Peek the on-disk registry (ignore any prior remote seat). */ const bool prior_remote = g_lnch_remote_lan; g_lnch_remote_lan = false; AeLanLobbyState file{}; const bool have_file = ae_np_read_lan_file_state(&file); g_lnch_remote_lan = prior_remote; /* Same-machine / shared cwd: claim the local LAN file when the * endpoint matches. */ if (have_file && !g_lnch_hosting_lan && file.endpoint == endpoint) { if (file.password != (password ? password : "")) return -2; std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; const int slot = ae_np_lan_seat_guest(file, ae_np_lan_local_player_id(), me.c_str()); if (slot < 0) return -1; file.started = false; ae_np_lan_sync_legacy_names(file); g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; if (!ae_np_write_lan_state(file)) return -1; g_lnch_hosting_lan = false; g_lnch_joined_lan = true; g_lnch_lan_my_slot = slot; g_lnch_lan_endpoint = file.endpoint; return 0; } /* Cross-machine Join Direct: JOIN must be acked before we seat. */ ae_np_lan_udp_close(); AeLanLobbyState seated{}; seated.name = "Direct"; seated.game = g_lnch_netplay_game_name.empty() ? "PSX" : g_lnch_netplay_game_name; seated.endpoint = endpoint; seated.password = password ? password : ""; const int ack = ae_np_lan_wait_join_ack(endpoint, password, &seated); if (ack != 0) { ae_np_lan_udp_close(); g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); return ack; } g_lnch_remote_lan = true; g_lnch_remote_lan_state = seated; ae_np_lan_sync_legacy_names(g_lnch_remote_lan_state); g_lnch_hosting_lan = false; g_lnch_joined_lan = true; g_lnch_lan_endpoint = endpoint; g_lnch_lan_join_pulse_ms = SDL_GetTicks(); return 0; } /* Server join: leave any stale LAN-file room mode so membership follows WS. */ ae_np_lan_udp_close(); g_lnch_hosting_lan = false; g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); /* Match host create: send the verified mount fp, not a wiped "". */ psx_lobby_set_disc_fp(g_session_disc_fp.c_str()); return psx_lobby_join(lobby_id, password ? password : "", bind); } 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(psx_lobby_join_info()); if (ji) ji->last_error[0] = '\0'; } int ae_np_leave(void*) { if (g_netplay_content_negotiation && (g_lnch_hosting_lan || g_lnch_joined_lan)) psx_lobby_set_local_match_caps(nullptr); if (g_lnch_hosting_lan) { for (int i = 0; i < kAeLanMaxSlots; ++i) { if (g_lnch_lan_peer_ok[i]) ae_np_lan_udp_sendto(g_lnch_lan_peers[i], "MOTK1 KICK\n"); } std::error_code ec; std::filesystem::remove(ae_np_lan_file(), ec); g_lnch_hosting_lan = false; } else if (g_lnch_joined_lan) { std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; if (g_lnch_remote_lan) { char host[64]; if (ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host)) && g_lnch_lan_udp != kAeLanSockInvalid) { sockaddr_in to{}; to.sin_family = AF_INET; to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); if (inet_pton(AF_INET, host, &to.sin_addr) == 1) { char leave_msg[160]; std::snprintf(leave_msg, sizeof(leave_msg), "MOTK3 LEAVE\n%s\n", ae_np_lan_local_player_id()); ae_np_lan_udp_sendto(to, leave_msg); } } } else { AeLanLobbyState state; if (ae_np_read_lan_state(&state)) { int slot = ae_np_lan_find_slot_by_id(state, ae_np_lan_local_player_id()); if (slot < 0 && g_lnch_lan_my_slot >= 0 && g_lnch_lan_my_slot < state.max_slots && g_lnch_lan_my_slot != state.host_slot) { slot = g_lnch_lan_my_slot; } if (slot < 0) slot = ae_np_lan_find_guest_slot_by_name(state, me.c_str()); if (slot >= 0) { state.slot_name[slot].clear(); state.slot_id[slot].clear(); } state.started = false; ae_np_lan_sync_legacy_names(state); ae_np_write_lan_state(state); } } } ae_np_lan_udp_close(); g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); g_lnch_lan_my_slot = -1; g_lnch_pending_direct_launch = {}; return psx_lobby_leave(); } int ae_np_in_lobby(void*) { ae_np_poll_lan_joiner_still_seated(); if (g_lnch_hosting_lan) { AeLanLobbyState state; return ae_np_read_lan_state(&state) ? 1 : 0; } if (g_lnch_joined_lan) return 1; return psx_lobby_in_lobby(); } int ae_np_is_host(void*) { if (ae_np_use_lan_members()) return g_lnch_hosting_lan ? 1 : 0; if (ae_np_use_ws_members()) return psx_lobby_is_host(); return psx_lobby_is_host(); } int ae_np_member_count(void*) { if (ae_np_use_ws_members()) { const int n = psx_lobby_member_count(); return n > 0 ? n : 1; } if (ae_np_use_lan_members()) { AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return 1; const int n = ae_np_lan_occupied(state); return n > 0 ? n : 1; } return psx_lobby_member_count(); } /* ---- spectators ----------------------------------------------------- * The gallery lives on the lobby server, which is where "cannot affect * the game" is enforced (its UDP relay refuses to forward a spectator's * packets). A LAN / Direct-IP room has no server between the peers, so it * reports no gallery and the UI's spectator section stays hidden there -- * a spectator that is merely asked not to send is a promise, not a * spectator. */ int ae_np_allow_spectators_get(void*) { return psx_lobby_allow_spectators_pref(); } int ae_np_allow_spectators_set(void*, int allow) { /* A PREFERENCE, read back by the Create Lobby modal and sent with * `create` -- so it has to be settable before any room exists. * Gating this on being seated (as it was) refused the toggle in the * one place it is offered, and the modal, reading the unchanged * value back each frame, snapped the switch off again. Only a LAN / * Direct-IP room refuses, because it has no gallery to allow: same * rule as the SNES host (snes_host_lobby.c cb_allow_spectators_set). */ if (g_lnch_hosting_lan || g_lnch_joined_lan) return -1; psx_lobby_set_allow_spectators(allow); return 0; } int ae_np_lobby_allow_spectators(void*) { return ae_np_use_ws_members() ? psx_lobby_allow_spectators() : 0; } int ae_np_lobby_max_spectators(void*) { return ae_np_use_ws_members() ? psx_lobby_max_spectators() : 0; } int ae_np_lobby_spectator_count(void*) { return ae_np_use_ws_members() ? psx_lobby_spectator_count() : 0; } int ae_np_local_is_spectator(void*) { return ae_np_use_ws_members() ? psx_lobby_local_is_spectator() : 0; } int ae_np_spectator_slot(void*, int index) { return ae_np_use_ws_members() ? psx_lobby_spectator_slot(index) : -1; } int ae_np_member_get(void*, int index, RecompLauncherCNetplayMember* out) { if (!out) return 0; if (ae_np_use_ws_members()) { 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; const char* host_id = psx_lobby_host_player_id(); /* Prefer host_player_id; slot-0 fallback only when unknown (never * mark a guest as host after a seat swap). */ if (host_id && host_id[0] && mem.player_id[0]) out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; else out->is_host = (mem.slot == 0) ? 1 : 0; { const char* self_id = psx_lobby_player_id(); out->is_local = (self_id && self_id[0] && mem.player_id[0] && std::strcmp(self_id, mem.player_id) == 0) ? 1 : 0; } out->latency_ms = psx_lobby_member_latency_ms(mem.slot); out->bios_offer_valid = mem.bios_offer_valid; out->bios_can_scph1001 = mem.bios_can_scph1001; out->bios_prefer_openbios = mem.bios_prefer_openbios; out->is_spectator = mem.is_spectator; out->memcard_offer_valid = mem.memcard_offer_valid; out->memcard_has_card = mem.memcard_has_card; out->memcard_share = mem.memcard_share; std::snprintf(out->country, sizeof(out->country), "%s", mem.country); #if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) std::snprintf(out->account, sizeof(out->account), "%s", mem.account); #endif return 1; } if (ae_np_use_lan_members()) { AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return 0; int seen = 0; for (int slot = 0; slot < state.max_slots; ++slot) { if (state.slot_name[slot].empty()) continue; if (seen == index) { out->slot = slot; std::snprintf(out->display_name, sizeof(out->display_name), "%s", state.slot_name[slot].c_str()); out->ready = 1; out->is_host = (slot == state.host_slot) ? 1 : 0; out->is_local = (slot == g_lnch_lan_my_slot) ? 1 : 0; out->latency_ms = -1; if (out->is_local) { ae_np_lan_sync_local_slot_bios(); ae_np_lan_sync_local_slot_memcard(); } { const AeLanSlotBios& b = g_lnch_lan_slot_bios[slot]; out->bios_offer_valid = b.valid; out->bios_can_scph1001 = b.can_scph1001; out->bios_prefer_openbios = b.prefer_openbios; const AeLanSlotMemcard& mc = g_lnch_lan_slot_memcard[slot]; out->memcard_offer_valid = mc.valid; out->memcard_has_card = mc.has_card; out->memcard_share = mc.share; } return 1; } ++seen; } return 0; } 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; const char* host_id = psx_lobby_host_player_id(); if (host_id && host_id[0] && mem.player_id[0]) out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; else out->is_host = (mem.slot == 0) ? 1 : 0; { const char* self_id = psx_lobby_player_id(); out->is_local = (self_id && self_id[0] && mem.player_id[0] && std::strcmp(self_id, mem.player_id) == 0) ? 1 : 0; } out->latency_ms = psx_lobby_member_latency_ms(mem.slot); out->bios_offer_valid = mem.bios_offer_valid; out->bios_can_scph1001 = mem.bios_can_scph1001; out->bios_prefer_openbios = mem.bios_prefer_openbios; out->memcard_offer_valid = mem.memcard_offer_valid; out->memcard_has_card = mem.memcard_has_card; out->memcard_share = mem.memcard_share; std::snprintf(out->country, sizeof(out->country), "%s", mem.country); #if defined(RECOMP_LAUNCHER_HAS_PLAYER_ACCOUNT) std::snprintf(out->account, sizeof(out->account), "%s", mem.account); #endif return 1; } /* The lobby seat the host currently holds (player or gallery), or -1. */ static int ae_np_ws_host_seat(void) { const char* host_id = psx_lobby_host_player_id(); if (!host_id || !host_id[0]) return -1; const int mc = psx_lobby_member_count(); for (int i = 0; i < mc; ++i) { PsxLobbyMember mem{}; if (!psx_lobby_member_get(i, &mem)) continue; if (std::strcmp(mem.player_id, host_id) == 0) return mem.slot; } return -1; } int ae_np_move_member(void*, int from_slot, int to_slot) { if (from_slot < 0 || to_slot < 0 || from_slot == to_slot) return -1; /* Any seat may trade with any other, the host's included: the lobby * host is identified by id, and at launch it is always session slot * 0 whatever seat it holds (ae_np_plan_session_slots), so a guest in * lobby seat 0 is just a player on port 0, not the authority. */ (void)ae_np_ws_host_seat; if (g_lnch_hosting_lan) { AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return -1; if (from_slot < 0 || to_slot < 0 || from_slot >= state.max_slots || to_slot >= state.max_slots) return -1; std::swap(state.slot_name[from_slot], state.slot_name[to_slot]); std::swap(state.slot_id[from_slot], state.slot_id[to_slot]); if (state.host_slot == from_slot) state.host_slot = to_slot; else if (state.host_slot == to_slot) state.host_slot = from_slot; /* Swap peer bindings with seats. */ if (from_slot < kAeLanMaxSlots && to_slot < kAeLanMaxSlots) { std::swap(g_lnch_lan_peers[from_slot], g_lnch_lan_peers[to_slot]); std::swap(g_lnch_lan_peer_ok[from_slot], g_lnch_lan_peer_ok[to_slot]); } state.started = false; ae_np_lan_sync_legacy_names(state); if (!ae_np_write_lan_state(state)) return -1; ae_np_lan_send_update_to_peers(state); return 0; } if (ae_np_use_ws_members() && psx_lobby_is_host()) return psx_lobby_move_member(from_slot, to_slot); return -1; } int ae_np_kick_member(void*, int slot) { if (g_lnch_hosting_lan) { AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return -1; if (slot < 0 || slot >= state.max_slots || slot == state.host_slot) return -1; if (state.slot_name[slot].empty()) return -1; const std::string kicked_name = state.slot_name[slot]; state.slot_name[slot].clear(); state.slot_id[slot].clear(); ae_np_lan_clear_slot_bios(slot); state.started = false; ae_np_lan_sync_legacy_names(state); if (!ae_np_write_lan_state(state)) return -1; if (slot < kAeLanMaxSlots && g_lnch_lan_peer_ok[slot]) { ae_np_lan_udp_sendto(g_lnch_lan_peers[slot], "MOTK1 KICK\n"); ae_np_lan_clear_peer_slot(slot); } ae_np_lan_send_update_to_peers(state); ae_np_lan_chat_announce(kicked_name.c_str(), "was kicked."); return 0; } if (ae_np_use_ws_members() && psx_lobby_is_host()) return psx_lobby_kick(slot); return -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) { if (ready) { ae_np_refresh_bios_offer_from_disk(); ae_np_refresh_mod_offer(); } return psx_lobby_set_ready(ready); } static void ae_np_refresh_bios_offer_for(const RecompLauncherCSettings* settings) { if (settings && settings->bios_path[0]) ae_np_refresh_bios_offer(settings->bios_path); else ae_np_refresh_bios_offer_from_disk(); } /* The caps a lobby host starts the match with, settled from the seats it * sees: the player's settings, the seat-1 memory card, and the one BIOS * every seat can boot. The in-game PLAY and the Retro hub's (through * --netplay-query) both come here, so they cannot settle differently. * 0 = settled; else `why` says why no match can start. */ static int ae_np_settle_start_caps(const RecompLauncherCSettings* settings, PsxLobbyMatchCaps* caps, char* why, size_t why_cap) { *caps = ae_netplay_caps_from_settings(settings); caps->guest_memcard_active = ae_np_ws_guest_memcard_effective(); std::fprintf(stdout, "psxrecomp: lobby guest memcard (P2 card as slot 2) = %s\n", caps->guest_memcard_active ? "on" : "off"); return psx_lobby_settle_session_bios(caps->session_bios, sizeof(caps->session_bios), &caps->session_bios_crc, why, why_cap) == 1 ? -1 : 0; } /* ---- the Retro hub (retcomm-launcher docs/NETPLAY_HANDOFF.md) ----------- */ /* RECOMP_NETPLAY_LAUNCH: recomp-ui hands the hub's launch record here. */ int ae_np_ingest_launch(void*, const char* record, char* why, size_t why_cap) { return psx_lobby_ingest_record(record, why, why_cap); } static void ae_np_json_escaped(std::string& out, const char* text) { out += '"'; for (const char* c = text ? text : ""; *c; ++c) { if (*c == '"' || *c == '\\') { out += '\\'; out += *c; } else if ((unsigned char)*c < 0x20) out += ' '; else out += *c; } out += '"'; } /* --netplay-query : what this build would send from its own * lobby, so a seat the Retro hub holds is the same seat. The answer goes * to ".answer" (stdout carries the game's log). Request keys are * handoff_-prefixed: the reader takes a key's first occurrence, and a * replayed server message has its own "op". * handoff_query "identity" (+ handoff_disc): * join_fields (disc_fp, the server's join gate), the set_ready * message (the BIOS offer), and a start message with this player's * default caps (what a room it hosts publishes) * handoff_query "settle" (+ handoff_player_id, _seat, _room): * the start message the host sends, the BIOS settled from every * seat's offer -- or why no match can start */ static int ae_np_answer_query(const char* request_path, const RecompLauncherCSettings* settings, const std::string& initial_disc) { std::string req; { std::ifstream in(request_path, std::ios::binary); req.assign(std::istreambuf_iterator(in), std::istreambuf_iterator()); } char op[32] = {}; char disc[1024] = {}; psx_lobby_json_str(req.c_str(), "handoff_query", op, sizeof(op)); psx_lobby_json_str(req.c_str(), "handoff_disc", disc, sizeof(disc)); std::string ans = "{\"v\":1,\"engine\":\"psxrecomp\",\"handoff\":1"; char msg[1024] = {}; ae_np_refresh_bios_offer_for(settings); if (std::strcmp(op, "identity") == 0) { const std::string d = disc[0] ? std::string(disc) : initial_disc; RecompLauncherCDiscVerify dv{}; if (!d.empty()) ae_disc_verify(d.c_str(), &dv); /* join_fields: what create and join carry beyond the hub's own * fields. The server refuses a join whose disc_fp differs. */ ans += ",\"join_fields\":{\"disc_fp\":"; ae_np_json_escaped(ans, dv.disc_fp); ans += "}"; ans += dv.netplay_ok ? ",\"disc_ok\":true" : ",\"disc_ok\":false"; ans += ",\"disc_detail\":"; ae_np_json_escaped(ans, dv.netplay_detail); if (psx_lobby_ready_message(1, msg, sizeof(msg)) == 0) { ans += ",\"ready\":"; ans += msg; } const PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); if (psx_lobby_start_message(&caps, msg, sizeof(msg)) == 0) { ans += ",\"start\":"; ans += msg; } } else if (std::strcmp(op, "settle") == 0) { char why[256] = {}; PsxLobbyMatchCaps caps{}; if (psx_lobby_replay_room(req.c_str(), why, sizeof(why)) != 0 || ae_np_settle_start_caps(settings, &caps, why, sizeof(why)) != 0) { ans += ",\"error\":"; ae_np_json_escaped(ans, why[0] ? why : "the match cannot be settled"); } else if (psx_lobby_start_message(&caps, msg, sizeof(msg)) == 0) { ans += ",\"start\":"; ans += msg; } } else { ans += ",\"error\":"; ae_np_json_escaped(ans, "unknown query"); } ans += "}\n"; const std::string out_path = std::string(request_path) + ".answer"; std::ofstream out(out_path, std::ios::binary | std::ios::trunc); out << ans; return out.good() ? 0 : 1; } int ae_np_request_start(void*, const RecompLauncherCSettings* settings) { if (g_lnch_hosting_lan) { ae_np_push_match_caps(settings); if (!g_lnch_mod_plan_error.empty()) return -1; AeLanLobbyState state; if (!ae_np_read_lan_state(&state) || ae_np_lan_occupied(state) < 2) return -1; if (settings && settings->bios_path[0]) ae_np_refresh_bios_offer(settings->bios_path); else ae_np_refresh_bios_offer_from_disk(); char session_bios[16] = {}; uint32_t session_crc = 0; char why[192] = {}; if (ae_np_lan_settle_session_bios(session_bios, sizeof(session_bios), &session_crc, why, sizeof(why)) == 1) { /* No BIOS every seat can boot: starting would only end in a * boot-digest stall. Say why instead. */ std::fprintf(stdout, "psxrecomp: LAN start refused: %s\n", why); psx_lobby_set_last_error(why); ae_np_clear_session_bios_token(); return -1; } ae_np_set_session_bios_token(session_bios[0] ? session_bios : "openbios", session_crc); ae_np_log_settled_bios("LAN"); if (g_netplay_content_negotiation) { PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); caps.session_bios_crc = session_crc; char caps_wire[8192]; if (!g_lnch_mod_plan_error.empty() || !psx_lobby_encode_match_caps(caps_wire, sizeof(caps_wire), &caps)) return -1; state.mod_caps_json = caps_wire; psx_lobby_set_local_match_caps(&caps); } state.started = true; state.session_id += 1u; if (state.session_id == 0) state.session_id = 1; g_lnch_lan_session_id = state.session_id; if (!ae_np_write_lan_state(state)) return -1; ae_np_lan_send_update_to_peers(state); char start_msg[8448]; /* include the complete encoded plan */ int delay = g_lnch_lobby_input_delay; int pred = g_lnch_lobby_input_prediction; if (delay < 2) delay = 2; if (delay > 20) delay = 20; if (pred < 2) pred = 2; if (pred > 16) pred = 16; ae_np_lan_sync_local_slot_memcard(); g_lnch_lan_guest_memcard_active = ae_np_lan_guest_memcard_effective(state); std::fprintf(stdout, "psxrecomp: LAN guest memcard (P2 card as slot 2) = %s\n", g_lnch_lan_guest_memcard_active ? "on" : "off"); char session_crc_text[16]; netplay_bios_format_crc(g_lnch_session_bios_crc, session_crc_text, sizeof(session_crc_text)); if (g_netplay_content_negotiation) std::snprintf(start_msg, sizeof(start_msg), "MOTK6 START\n%u\n%d\n%d\n%d\n%s\n%d\n%s", (unsigned)state.session_id, delay, pred, g_lnch_rollback ? 1 : 0, g_lnch_session_bios[0] ? g_lnch_session_bios : "openbios", g_lnch_lan_guest_memcard_active ? 1 : 0, state.mod_caps_json.c_str()); else std::snprintf(start_msg, sizeof(start_msg), "MOTK1 START\n%u\n%d\n%d\n%d\n%s\n%d\n%s\n", (unsigned)state.session_id, delay, pred, g_lnch_rollback ? 1 : 0, g_lnch_session_bios[0] ? g_lnch_session_bios : "openbios", g_lnch_lan_guest_memcard_active ? 1 : 0, session_crc_text); for (int i = 0; i < kAeLanMaxSlots; ++i) { if (g_lnch_lan_peer_ok[i]) ae_np_lan_udp_sendto(g_lnch_lan_peers[i], start_msg); } return 0; } if (!psx_lobby_is_host()) return -1; /* Publish current BIOS offer, then ensure host seat is ready. */ ae_np_refresh_bios_offer_for(settings); ae_np_refresh_mod_offer(); (void)psx_lobby_set_ready(1); PsxLobbyMatchCaps caps{}; char why[192] = {}; if (ae_np_settle_start_caps(settings, &caps, why, sizeof(why)) != 0) { /* No BIOS every seat can boot: refuse, and say why. */ std::fprintf(stdout, "psxrecomp: lobby start refused: %s\n", why); psx_lobby_set_last_error(why); ae_np_clear_session_bios_token(); return -1; } if (!g_lnch_mod_plan_error.empty()) return -1; ae_np_set_session_bios_token(caps.session_bios[0] ? caps.session_bios : "openbios", caps.session_bios_crc); ae_np_log_settled_bios("lobby"); return psx_lobby_request_start(&caps); } static void ae_copy_need_mod(RecompLauncherCNetplayNeedMod* out, const PsxLobbyModPkg& pkg) { if (!out) return; std::memset(out, 0, sizeof(*out)); ae_copy_lobby_text(out->id, sizeof(out->id), pkg.id); ae_copy_lobby_text(out->version, sizeof(out->version), pkg.ver); ae_copy_lobby_text(out->name, sizeof(out->name), pkg.name); out->builtin = pkg.builtin; out->size = pkg.size; } int ae_np_need_mods_count(void*) { return psx_lobby_need_mods_count(); } int ae_np_need_mods_get(void*, int index, RecompLauncherCNetplayNeedMod* out) { PsxLobbyModPkg pkg{}; if (!out || !psx_lobby_need_mods_get(index, &pkg)) return 0; ae_copy_need_mod(out, pkg); return 1; } int ae_np_need_mods_can_transfer(void*) { return psx_lobby_need_mods_can_transfer(); } int ae_np_mod_xfer_start(void*) { return psx_lobby_mod_xfer_start(); } void ae_np_mod_xfer_cancel(void*) { psx_lobby_mod_xfer_cancel(); } int ae_np_mod_xfer_progress(void*) { return psx_lobby_mod_xfer_progress(); } int ae_np_mod_xfer_failed(void*, char* err, size_t err_cap) { return psx_lobby_mod_xfer_failed(err, err_cap); } void ae_np_push_match_caps_cb(void*) { ae_np_push_match_caps(nullptr); } int ae_np_lobby_mods_count(void*) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (!caps || !caps->valid || caps->mod_count <= 0) return 0; return caps->mod_count; } int ae_np_lobby_mods_get(void*, int index, RecompLauncherCNetplayLobbyMod* out) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (!out || !caps || !caps->valid || index < 0 || index >= caps->mod_count) return 0; const PsxLobbyModPkg& pkg = caps->mods[index]; std::memset(out, 0, sizeof(*out)); ae_copy_lobby_text(out->id, sizeof(out->id), pkg.id); ae_copy_lobby_text(out->version, sizeof(out->version), pkg.ver); ae_copy_lobby_text(out->name, sizeof(out->name), pkg.name); out->builtin = pkg.builtin; out->size = pkg.size; out->installed = ae_mod_pkg_installed(&pkg, out->reason, sizeof(out->reason)); ae_copy_lobby_text(out->options, sizeof(out->options), pkg.feats); return 1; } int ae_np_lobby_mods_missing(void*) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); int missing = 0; if (!caps || !caps->valid || caps->mod_count <= 0) return 0; for (int i = 0; i < caps->mod_count; ++i) if (!ae_mod_pkg_installed(&caps->mods[i], nullptr, 0)) missing++; return missing; } int ae_np_lobby_mods_download(void*) { return psx_lobby_mod_xfer_start(); } int ae_np_lobby_mods_can_download(void*) { return psx_lobby_need_mods_can_transfer(); } 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) { /* Guests may see started=1 on UPDATE before MOTK1 START * delivers session_bios — wait so both peers boot the same BIOS. */ if (g_lnch_joined_lan && !g_lnch_session_bios[0]) return g_lnch_pending_direct_launch.enabled || psx_lobby_launch_pending(); if (g_netplay_content_negotiation && g_lnch_remote_lan && g_lnch_lan_start_plan_session != state.session_id) return 0; if (g_netplay_content_negotiation && !state.mod_caps_json.empty()) { PsxLobbyMatchCaps caps{}; if (!psx_lobby_decode_match_caps(state.mod_caps_json.c_str(), &caps)) return 0; psx_lobby_set_local_match_caps(&caps); } /* Free the lobby UDP port before delay-sync binds it. */ ae_np_lan_udp_close(); g_lnch_lan_session_id = state.session_id ? state.session_id : 1u; g_lnch_pending_direct_launch = {}; g_lnch_pending_direct_launch.enabled = 1; AeSlotPlan lan_plan; { int seats[kAeLanMaxSlots]; int n = 0; for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) { if (state.slot_name[i].empty() && state.slot_id[i].empty()) continue; seats[n++] = i; } int my_seat = g_lnch_hosting_lan ? state.host_slot : g_lnch_lan_my_slot; if (my_seat < 0) my_seat = g_lnch_hosting_lan ? state.host_slot : 1; ae_np_plan_session_slots(seats, n, state.host_slot, my_seat, g_lnch_hosting_lan, &lan_plan); g_lnch_pending_direct_launch.local_slot = lan_plan.local_slot >= 0 ? lan_plan.local_slot : (g_lnch_hosting_lan ? 0 : 1); g_lnch_pending_direct_launch.slot_port_valid = 1; for (int i = 0; i < RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS + 1; ++i) g_lnch_pending_direct_launch.slot_port[i] = lan_plan.port[i]; } /* -1 => resolve at netplay start (prefer NETPLAY/P1 card). */ g_lnch_pending_direct_launch.input_player = -1; g_lnch_pending_direct_launch.session_id = g_lnch_lan_session_id; g_lnch_pending_direct_launch.input_delay = g_lnch_lobby_input_delay; g_lnch_pending_direct_launch.input_prediction = g_lnch_lobby_input_prediction; g_lnch_pending_direct_launch.max_slots = state.max_slots >= 2 ? state.max_slots : (g_lnch_host_max_slots >= 2 ? g_lnch_host_max_slots : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2)); if (g_lnch_pending_direct_launch.max_slots > PSX_MAX_PLAYERS) g_lnch_pending_direct_launch.max_slots = PSX_MAX_PLAYERS; if (g_lnch_pending_direct_launch.max_slots > kAeLanMaxSlots) g_lnch_pending_direct_launch.max_slots = kAeLanMaxSlots; g_lnch_pending_direct_launch.force_input_relay = 0; g_lnch_pending_direct_launch.force_turn = 0; g_lnch_pending_direct_launch.rollback = g_lnch_rollback ? 1 : 0; g_lnch_pending_direct_launch.guest_memcard = g_lnch_lan_guest_memcard_active ? 1 : 0; /* Compact session slots (host first, then seat order): the * plan's count and mask, not the lobby's seat indices. */ g_lnch_pending_direct_launch.player_count = lan_plan.slot_count; g_lnch_pending_direct_launch.occupied_mask = lan_plan.occupied; 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); /* 3+ host-as-relay: empty peer → lan_hub. 2P: accept-first. */ g_lnch_pending_direct_launch.peer_hostport[0] = '\0'; } else { std::snprintf(g_lnch_pending_direct_launch.bind_hostport, sizeof(g_lnch_pending_direct_launch.bind_hostport), "0.0.0.0:0"); 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(); } /* After a match soft-exit: keep seats, clear started/ready, rebind LAN UDP. */ void ae_np_prepare_lobby_rematch(void) { g_lnch_pending_direct_launch = {}; ae_np_clear_session_bios_token(); psx_lobby_set_ready(0); psx_lobby_clear_launch_pending(); psx_lobby_clear_signals(); psx_lobby_set_ice_signal_accept(0); psx_lobby_resume_waiting_room_rtt(); if (!(g_lnch_hosting_lan || g_lnch_joined_lan)) return; AeLanLobbyState st; if (ae_np_read_lan_state(&st)) { st.started = false; (void)ae_np_write_lan_state(st); } if (g_lnch_hosting_lan && !g_lnch_lan_endpoint.empty()) { ae_np_lan_udp_close(); (void)ae_np_lan_udp_ensure(true, ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); if (ae_np_read_lan_state(&st)) ae_np_lan_send_update_to_peers(st); } else if (g_lnch_remote_lan) { ae_np_lan_udp_close(); (void)ae_np_lan_udp_ensure(false, 0); g_lnch_lan_join_pulse_ms = 0; } } const char* ae_np_lan_endpoint_cstr(void) { return g_lnch_lan_endpoint.empty() ? nullptr : g_lnch_lan_endpoint.c_str(); } 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(); /* Host match_caps are required online — do not silently default D/relay. */ if (!caps || !caps->valid) return 0; out->enabled = 1; out->local_slot = ji->local_slot; out->is_spectator = ji->local_is_spectator ? 1 : 0; out->spectator_wire_slot = 0; /* Host in the gallery: it keeps session slot 0 with a muted pad (the * seat every host-only path keys on), so it is NOT a spectator to the * engine, and every player seat sits one session slot higher. The * server said so once at start; every peer applies the same rule. */ const int host_spectates = ji->host_spectates ? 1 : 0; out->host_spectates = host_spectates; if (host_spectates && psx_lobby_is_host()) { out->local_slot = 0; out->is_spectator = 0; } if (out->is_spectator) { const int wire = psx_lobby_local_wire_slot(); if (wire <= 0) { /* No relay base published, so there is no slot we could send * from that the relay would recognise as a spectator. Falling * back to a player slot is the one thing a spectator must * never do -- the relay would forward it and the peers would * take it as that seat's input. Refuse instead. */ std::fprintf(stderr, "netplay: refusing to launch as a spectator - the " "host published no spectator relay slot (lobby " "seat %d)\n", ji->local_slot); return 0; } out->spectator_wire_slot = wire; } /* -1 => resolve at netplay start (prefer NETPLAY/P1 card). */ out->input_player = -1; 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->input_delay; out->input_prediction = caps->input_prediction; if (out->input_prediction < 2) out->input_prediction = 2; if (out->input_prediction > 16) out->input_prediction = 16; out->max_slots = ji->max_slots >= 2 ? ji->max_slots : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2); if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; out->max_slots += host_spectates; /* the host's silent slot 0 */ /* Session slots from the seat table the start delivered: host first, * then the players by seat; ports by lobby seat. */ { int seats[RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS]; int n = 0; int host_seat = -1; int my_seat = out->is_spectator ? -1 : ji->local_slot; const char* host_id = psx_lobby_host_player_id(); const char* self_id = psx_lobby_player_id(); const int mc = psx_lobby_member_count(); for (int i = 0; i < mc; ++i) { PsxLobbyMember mem{}; if (!psx_lobby_member_get(i, &mem)) continue; if (mem.is_spectator) continue; /* gallery seats are not session slots */ if (mem.slot < 0 || mem.slot >= RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS) continue; if (n < RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS) seats[n++] = mem.slot; if (host_id && host_id[0] && std::strcmp(mem.player_id, host_id) == 0) host_seat = mem.slot; if (self_id && self_id[0] && std::strcmp(mem.player_id, self_id) == 0) my_seat = mem.slot; } AeSlotPlan plan; ae_np_plan_session_slots(seats, n, host_seat, my_seat, psx_lobby_is_host(), &plan); if (!out->is_spectator) { if (plan.local_slot < 0) { std::fprintf(stderr, "netplay: this peer (seat %d) is not in the " "start's seat table - refusing to launch\n", ji->local_slot); return 0; } out->local_slot = plan.local_slot; } out->player_count = plan.slot_count; if (out->player_count > out->max_slots) out->max_slots = out->player_count; out->occupied_mask = plan.occupied; out->slot_port_valid = 1; for (int i = 0; i < RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS + 1; ++i) out->slot_port[i] = plan.port[i]; } /* Online launch: the server's relay (SFU) unless the launch said * transport "host" (the host carries the match; 2026-10-01 amends * §108's "always SFU"). Prefer caps.force_input_relay from the * relay_endpoint rewrite; also infer when host==guest advertise. */ out->force_input_relay = (g_lnch_hosting_lan || g_lnch_joined_lan || ji->transport_host) ? 0 : (caps->force_input_relay ? 1 : 0); #if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY) out->transport_host = ji->transport_host ? 1 : 0; #endif if (!out->force_input_relay && !ji->transport_host && !g_lnch_hosting_lan && !g_lnch_joined_lan && ji->host_endpoint[0] && ji->guest_endpoint[0] && std::strcmp(ji->host_endpoint, ji->guest_endpoint) == 0) { out->force_input_relay = 1; std::fprintf(stderr, "psxrecomp: launch force_input_relay inferred " "(host_endpoint==guest_endpoint=%s)\n", ji->host_endpoint); } out->force_turn = caps->force_turn ? 1 : 0; out->rollback = (caps && caps->valid && caps->rollback) ? 1 : 0; /* From the START caps, not this peer's seat table: the host settled * it once, so a toggle racing the start cannot split the room. */ out->guest_memcard = caps->guest_memcard_active ? 1 : 0; if (caps->session_bios[0]) ae_np_set_session_bios_token(caps->session_bios, caps->session_bios_crc); return 1; } 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, ae_np_kick_member, ae_np_last_error, ae_np_clear_last_error, ae_np_input_delay_get, ae_np_input_delay_set, ae_np_force_input_relay_get, ae_np_force_input_relay_set, ae_np_lobby_max_slots, ae_np_force_turn_get, ae_np_force_turn_set, ae_np_rollback_get, ae_np_rollback_set, ae_np_input_prediction_get, ae_np_input_prediction_set, ae_np_multitap_analog_get, ae_np_multitap_analog_set, ae_np_connecting, }; /* Shared by first-boot launcher and soft-return rematch UI so capability * flags (View mode / perspective / Skip FMVs / renderer labels) cannot * drift apart — a bare launcher_profile_apply("psx") hides those rows. */ std::string g_rui_keybinds_path; std::string g_rui_config_ini_path; static const char* const kPsxRendererLabels[] = { "Software", "OpenGL (Recommended)", "Vulkan", }; static const char* const kPsxHostShortcutLabels[] = { "Rewind", "Save states", "Fast-forward", "Fast-forward toggle", }; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) /* Internal resolution vocabulary for the launcher's Display card: the * presets, plus the player's current legacy factor as its own "Nx (L * lines)" entry when no preset matches it, so nothing changes silently. */ static const char* g_ir_labels[PSX_IR_PRESET_COUNT + 1]; static int g_ir_values[PSX_IR_PRESET_COUNT + 1]; static int g_ir_count = 0; static char g_ir_custom_label[48]; static const char kIrNote[] = "Above 4x: OpenGL only. Past 16x on Apple GPUs only the displayed " "frame is kept at full resolution."; static void build_internal_resolution_vocab(int current) { g_ir_count = 0; for (int i = 0; i < PSX_IR_PRESET_COUNT; i++) { g_ir_labels[g_ir_count] = k_psx_ir_presets[i].label; g_ir_values[g_ir_count] = k_psx_ir_presets[i].value; g_ir_count++; } if (current >= PSX_IR_MIN_LINES && !psx_ir_id_for(current)) { int s = psx_ir_scale_for(current, g_video_ref_lines, 0); std::snprintf(g_ir_custom_label, sizeof g_ir_custom_label, "%dx (%d lines)", s, current); g_ir_labels[g_ir_count] = g_ir_custom_label; g_ir_values[g_ir_count] = current; g_ir_count++; } } #endif void ae_rui_set_sidecar_paths(const char* argv0) { const auto exe = exe_dir_from_argv(argv0 ? argv0 : ""); g_rui_keybinds_path = (exe / "keybinds.ini").string(); g_rui_config_ini_path = (exe / "config.ini").string(); } void ae_fill_psx_launcher_game_info( RecompLauncherCGameInfo* gi, const char* game_name_c, const char* region_c, int game_players_n, bool ws_offered_b, bool ws_ultrawide_offered_b, bool skip_fmv_offered_b, bool turbo_loads_offered_b, bool vulkan_offered_b, bool ctrl_lock_mode_b, bool ctrl_lock_device_b, int locked_pad_mode_i, const char* const* language_labels, int num_languages, int resume_netplay_room) { if (!gi) return; (void)ws_offered_b; (void)ws_ultrawide_offered_b; launcher_profile_apply("psx", gi); gi->name = game_name_c; gi->region = region_c; gi->keybinds_path = g_rui_keybinds_path.empty() ? nullptr : g_rui_keybinds_path.c_str(); gi->config_path = g_rui_config_ini_path.empty() ? nullptr : g_rui_config_ini_path.c_str(); gi->has_expected_crc = 0; gi->num_known_sha256 = 0; /* PSX display-view controls are owned by trusted mods. Do not expose * the generic Display -> View mode launcher row. */ gi->widescreen_supported = 0; gi->num_players = game_players_n; gi->msu1_supported = 0; gi->sram_path = nullptr; gi->has_bios = psx_bios_has_selectable(); gi->pad_mode_selectable = ctrl_lock_mode_b ? 0 : 1; gi->locked_pad_mode = locked_pad_mode_i; gi->lock_device = ctrl_lock_device_b ? 1 : 0; gi->aspect_mask = 0; gi->renderer_labels = kPsxRendererLabels; gi->num_renderers = vulkan_offered_b ? 3 : 2; gi->settings_bindings = 1; gi->assist_binding_labels = kPsxHostShortcutLabels; gi->assist_binding_count = PSX_ASSIST_BIND_COUNT; #if defined(RECOMP_LAUNCHER_HAS_DIRECT_ASSIST_BIND) /* Title-declared direct shortcut: captured as one button, defaulted * to the title's button; the other shortcuts keep their defaults. */ if (g_title_direct_shortcut >= 0) { /* recomp-ui copies RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS entries. */ static int pad_defaults[RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS]; static const int key_defaults[RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS] = {}; pad_defaults[PSX_ASSIST_BIND_REWIND] = PSX_HOTKEY_PAD_SELECT_R3; pad_defaults[PSX_ASSIST_BIND_SAVE_STATE_MENU] = PSX_HOTKEY_PAD_SELECT_R1; pad_defaults[PSX_ASSIST_BIND_FAST_FORWARD] = PSX_HOTKEY_PAD_SELECT_L1; pad_defaults[PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE] = 0; pad_defaults[g_title_direct_shortcut] = PSX_HOTKEY_PAD_BUTTON_COMBO( (uint32_t)1u << g_title_direct_button); gi->assist_default_pad_bind = pad_defaults; gi->assist_default_key_bind = key_defaults; gi->assist_direct_pad_bind_action = g_title_direct_shortcut + 1; } #endif gi->has_skip_fmv = skip_fmv_offered_b ? 1 : 0; gi->has_turbo_loads = turbo_loads_offered_b ? 1 : 0; /* The Perspective textures row. Hidden for a title that ships PGXP * through the psx.enhancement.pgxp mod ([video] pgxp_mod_only): the * Mods page is then its one switch, and the row's value is not * applied, so showing it would be a second control that does * nothing. */ gi->has_geometry_precision = g_video_pgxp_mod_only ? 0 : 1; gi->has_rewind_depth = 1; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) /* The Supersampling row becomes an Internal resolution list. */ build_internal_resolution_vocab(internal_resolution_for_launcher()); gi->internal_resolution_labels = g_ir_labels; gi->internal_resolution_values = g_ir_values; gi->num_internal_resolutions = g_ir_count; gi->internal_resolution_note = kIrNote; #endif #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) gi->has_dynamic_resolution = 1; #endif if (language_labels && num_languages > 0) { gi->language_labels = language_labels; gi->num_languages = num_languages; } else { gi->language_labels = nullptr; gi->num_languages = 0; } gi->disc_verify = ae_disc_verify; #if defined(RECOMP_LAUNCHER_HAS_SBI_STATUS) gi->import_sbi = ae_import_sbi; #endif gi->memcard_inspect = ae_memcard_inspect; gi->mods = PSXRecompV4::mod_runtime_launcher_provider(); gi->bios_verify = ae_bios_verify; /* Launcher window icon: the SAME file psx_apply_window_icon() puts on * the game window (assets/psxrecomp.png beside the exe, which * runtime.cmake stages from APP_ICON's directory). Resolved through * the shared helper rather than re-derived here, so the launcher and * the game can never end up on different art. The pointer is process- * lifetime; "" when the build shipped no PNG, which recomp-ui treats * as "leave the toolkit default". */ gi->window_icon_path = psx_window_icon_path(g_lnch_argv0); #if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_game_players = game_players_n; /* ae_disc_verify only fills netplay_ok/disc_fp when this is true. */ g_lnch_netplay_available = game_players_n >= 2 && game_players_n <= PSX_MAX_PLAYERS; gi->netplay_supported = g_lnch_netplay_available ? 1 : 0; /* Append-only members past the positional initializer. */ #if defined(RECOMP_LAUNCHER_HAS_NETPLAY_HANDOFF) g_lnch_netplay_callbacks.ingest_launch = ae_np_ingest_launch; #endif #if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY) g_lnch_netplay_callbacks.relay_host_get = ae_np_relay_host_get; g_lnch_netplay_callbacks.relay_host_set = ae_np_relay_host_set; g_lnch_netplay_callbacks.relay_status = ae_np_relay_status; #endif g_lnch_netplay_callbacks.memcard_offer_set = ae_np_memcard_offer_set; g_lnch_netplay_callbacks.guest_memcard_get = ae_np_guest_memcard_get; g_lnch_netplay_callbacks.guest_memcard_set = ae_np_guest_memcard_set; g_lnch_netplay_callbacks.chat_send = ae_np_chat_send; g_lnch_netplay_callbacks.chat_count = ae_np_chat_count; g_lnch_netplay_callbacks.chat_get = ae_np_chat_get; g_lnch_netplay_callbacks.host_can_spectate = ae_np_host_can_spectate; g_lnch_netplay_callbacks.online_count = ae_np_online_count; g_lnch_netplay_callbacks.online_get = ae_np_online_get; g_lnch_netplay_callbacks.server_chat_send = ae_np_server_chat_send; g_lnch_netplay_callbacks.server_chat_count = ae_np_server_chat_count; g_lnch_netplay_callbacks.server_chat_get = ae_np_server_chat_get; #if defined(RECOMP_LAUNCHER_HAS_ACCOUNT) /* Optional Discord sign-in. Guarded on the launcher ABI macro so this * runtime still builds against a recomp-ui that predates it -- the UI * and the runner can land in either order. */ g_lnch_netplay_callbacks.account_available = ae_np_account_available; g_lnch_netplay_callbacks.account_login_begin = ae_np_account_login_begin; g_lnch_netplay_callbacks.account_state = ae_np_account_state; g_lnch_netplay_callbacks.account_handle = ae_np_account_handle; g_lnch_netplay_callbacks.account_username = ae_np_account_username; g_lnch_netplay_callbacks.account_error = ae_np_account_error; g_lnch_netplay_callbacks.account_sign_out = ae_np_account_sign_out; g_lnch_netplay_callbacks.account_set_handle = ae_np_account_set_handle; #endif #if defined(RECOMP_LAUNCHER_HAS_LIST_SCOPE) g_lnch_netplay_callbacks.list_scope_set = ae_np_list_scope_set; #endif #if defined(RECOMP_LAUNCHER_HAS_CHAT_REPORT) g_lnch_netplay_callbacks.chat_report = ae_np_chat_report; #endif #if defined(RECOMP_LAUNCHER_HAS_SET_BLOCKS) g_lnch_netplay_callbacks.set_blocks = ae_np_set_blocks; #endif #if defined(RECOMP_LAUNCHER_HAS_AUTOMATCH) /* Server-run pairing. Same guard discipline as the account block: * the runner and the UI land in either order. */ g_lnch_netplay_callbacks.automatch_available = ae_np_automatch_available; g_lnch_netplay_callbacks.automatch_ruleset_count = ae_np_automatch_ruleset_count; g_lnch_netplay_callbacks.automatch_ruleset_get = ae_np_automatch_ruleset_get; g_lnch_netplay_callbacks.automatch_queue = ae_np_automatch_queue; g_lnch_netplay_callbacks.automatch_cancel = ae_np_automatch_cancel; g_lnch_netplay_callbacks.automatch_state = ae_np_automatch_state; g_lnch_netplay_callbacks.automatch_queued_secs = ae_np_automatch_queued_secs; g_lnch_netplay_callbacks.automatch_pool = ae_np_automatch_pool; g_lnch_netplay_callbacks.automatch_found_get = ae_np_automatch_found_get; g_lnch_netplay_callbacks.automatch_accept = ae_np_automatch_accept; g_lnch_netplay_callbacks.automatch_error = ae_np_automatch_error; #endif g_lnch_netplay_callbacks.seat_move_self = ae_np_seat_move_self; g_lnch_netplay_callbacks.seat_swap_request = ae_np_seat_swap_request; g_lnch_netplay_callbacks.seat_swap_incoming = ae_np_seat_swap_incoming; g_lnch_netplay_callbacks.seat_swap_respond = ae_np_seat_swap_respond; g_lnch_netplay_callbacks.seat_swap_outgoing = ae_np_seat_swap_outgoing; g_lnch_netplay_callbacks.seat_swap_clear = ae_np_seat_swap_clear; g_lnch_netplay_callbacks.allow_spectators_get = ae_np_allow_spectators_get; g_lnch_netplay_callbacks.allow_spectators_set = ae_np_allow_spectators_set; g_lnch_netplay_callbacks.lobby_allow_spectators = ae_np_lobby_allow_spectators; g_lnch_netplay_callbacks.lobby_max_spectators = ae_np_lobby_max_spectators; g_lnch_netplay_callbacks.lobby_spectator_count = ae_np_lobby_spectator_count; g_lnch_netplay_callbacks.local_is_spectator = ae_np_local_is_spectator; g_lnch_netplay_callbacks.spectator_slot = ae_np_spectator_slot; /* Lobby mod-plan callbacks exist only for content-negotiation titles. */ if (g_netplay_content_negotiation) { g_lnch_netplay_callbacks.need_mods_count = ae_np_need_mods_count; g_lnch_netplay_callbacks.need_mods_get = ae_np_need_mods_get; g_lnch_netplay_callbacks.need_mods_can_transfer = ae_np_need_mods_can_transfer; g_lnch_netplay_callbacks.mod_xfer_start = ae_np_mod_xfer_start; g_lnch_netplay_callbacks.mod_xfer_cancel = ae_np_mod_xfer_cancel; g_lnch_netplay_callbacks.mod_xfer_progress = ae_np_mod_xfer_progress; g_lnch_netplay_callbacks.mod_xfer_failed = ae_np_mod_xfer_failed; g_lnch_netplay_callbacks.push_match_caps = ae_np_push_match_caps_cb; g_lnch_netplay_callbacks.lobby_mods_count = ae_np_lobby_mods_count; g_lnch_netplay_callbacks.lobby_mods_get = ae_np_lobby_mods_get; g_lnch_netplay_callbacks.lobby_mods_missing = ae_np_lobby_mods_missing; g_lnch_netplay_callbacks.lobby_mods_download = ae_np_lobby_mods_download; g_lnch_netplay_callbacks.lobby_mods_can_download = ae_np_lobby_mods_can_download; } gi->netplay = g_lnch_netplay_available ? &g_lnch_netplay_callbacks : nullptr; #else g_lnch_netplay_available = false; gi->netplay_supported = 0; gi->netplay = nullptr; #endif gi->resume_netplay_room = resume_netplay_room ? 1 : 0; #if defined(PSX_HAS_LOBBY_CLIENT) gi->resume_netplay_endpoint = resume_netplay_room ? ae_np_lan_endpoint_cstr() : nullptr; #else gi->resume_netplay_endpoint = nullptr; #endif } } // namespace #endif int main(int argc, char** argv) { /* Force line-buffered output so messages appear even if killed. */ std::setvbuf(stdout, nullptr, _IOLBF, BUFSIZ); std::setvbuf(stderr, nullptr, _IOLBF, BUFSIZ); std::fprintf(stderr, "psxrecomp: main() entered\n"); std::fflush(stderr); #if defined(RECOMP_LAUNCHER) launcher_boot_timing_mark("host:main_enter"); #endif /* --audit-mod-plugins ...: build-time check, no emulation. * Every trusted implementation this executable registered must be named * by some authored manifest [[plugin]] id, or the plan can never activate * it. Exit 0 when all are declared, 1 otherwise (ctest per title). */ for (int i = 1; i < argc; i++) { if (std::strcmp(argv[i], "--audit-mod-plugins") != 0) continue; std::vector roots; for (int j = i + 1; j < argc && std::strncmp(argv[j], "--", 2) != 0; j++) roots.emplace_back(argv[j]); const PSXRecompV4::ModPluginAudit audit = PSXRecompV4::mod_audit_registered_plugins(roots); const std::vector registered = PSXRecompV4::mod_registered_plugin_ids(); std::fprintf(stdout, "mod plugin audit: %zu manifest(s), %zu declared id(s), " "%zu registered id(s)\n", audit.manifests, audit.declared.size(), registered.size()); for (const std::string& e : audit.errors) std::fprintf(stdout, " manifest error: %s\n", e.c_str()); for (const std::string& id : audit.undeclared) std::fprintf(stdout, " UNDECLARED (can never run): %s\n", id.c_str()); const bool ok = roots.size() > 0 && audit.errors.empty() && audit.undeclared.empty(); std::fprintf(stdout, "mod plugin audit: %s\n", ok ? "PASS" : "FAIL"); return ok ? 0 : 1; } /* Setup-host zip-root exe: after Generate & rebuild, hand off to the * product binary under build-release/ (bios/, mods/, assets/, settings). */ #if defined(PSX_HAS_CODEGEN_SETUP_HOST) psx_game_codegen_forward_if_built(argc, argv); #endif /* 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(); #if defined(RECOMP_LAUNCHER) launcher_boot_timing_mark("host:crash_handlers"); #endif 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 */ /* Did the PLAYER choose this BIOS (CLI or settings), as opposed to it * being the compile-time default? Only a real choice overrides the * bundled OpenBIOS — see docs/BIOS_SELECTION.md. */ bool bios_explicit = false; /* Launcher overrides (mirrors snesrecomp): --launcher forces the GUI back on * even when [launcher] skip_launcher = true is set; --no-launcher (and the * PSX_NO_LAUNCHER env) forces it off. --launcher wins if both are given. */ 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_netplay_query = nullptr; /* --netplay-query */ const char* cli_memcard_dir = nullptr; /* isolate writable state in a fleet */ std::vector cli_path_arg_storage; cli_path_arg_storage.reserve((size_t)argc); auto is_cli_option = [](const char* arg) { return arg && arg[0] == '-' && arg[1] == '-'; }; auto consume_path_arg = [&](int& i) -> const char* { const int first = i + 1; std::string combined; std::string best; int best_index = first; for (int j = first; j < argc; ++j) { if (j != first && is_cli_option(argv[j])) break; if (!combined.empty()) combined += " "; combined += argv[j]; std::error_code ec; if (std::filesystem::exists(std::filesystem::path(combined), ec)) { best = combined; best_index = j; } } if (best.empty()) { best = argv[first]; best_index = first; } i = best_index; cli_path_arg_storage.push_back(std::move(best)); return cli_path_arg_storage.back().c_str(); }; PsxNetplayConfig net_cfg; psx_netplay_config_defaults(&net_cfg); psx_netplay_apply_env(&net_cfg); /* CLI flags below win over env */ /* Parse args. * --bios override the compile-time BIOS path * --game load a game config (single source of truth for * disc / memcard / window title / debug port) * --disc override the game config disc path * --debug-port override the TCP debug-server port (multi-instance) * --memcard-dir override card/save/options state (multi-instance) * --renderer 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 * --hidden-window full presenter in a never-shown window (TCP-driven * tests of interpolation / render passes) * --headless-opengl hidden GL context, no audio/controllers; require actual GL * --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) * 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 = consume_path_arg(i); bios_from_cli = true; bios_explicit = true; } else if (std::strcmp(argv[i], "--game") == 0 && i + 1 < argc) { game_config_path = consume_path_arg(i); } else if (std::strcmp(argv[i], "--disc") == 0 && i + 1 < argc) { disc_override_path = consume_path_arg(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 = consume_path_arg(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], "--netplay-query") == 0 && i + 1 < argc) { /* Answered where the launcher would open: everything it would be * seeded with is resolved by then. */ cli_netplay_query = argv[++i]; force_launcher = true; } 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-opengl") == 0) { g_headless = 1; g_headless_opengl = 1; 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], "--hidden-window") == 0) { g_hidden_window = 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; bios_explicit = 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 (!PSXRecompV4::host_path_is_absolute(std::filesystem::path(game_config_path))) { // 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; bool multitap_enabled = true; /* [controller] multitap; 3+ seats offline */ bool multitap_analog = true; /* DualShock-on-tap hack; game.toml/settings */ /* [controller] device routing (defaults: P1 keyboard/digital, P2 none). */ /* Dev builds default Player 1 to the first connected controller ("auto"). * PSX_DEV_INPUT=1 additionally merges every other controller and the * keyboard for diagnostic convenience; normal routing stays exclusive. * Release keeps "keyboard" as its pre-launch default (the launcher assigns * the selected physical device). */ std::string player_device[PSX_MAX_PLAYERS]; int player_mode[PSX_MAX_PLAYERS]; int player_deadzone[PSX_MAX_PLAYERS]; int ctrl_locked_mode[PSX_MAX_PLAYERS]; for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { #if defined(PSX_DEBUG_TOOLS) player_device[i] = (i == 0) ? "auto" : "none"; #else player_device[i] = (i == 0) ? "keyboard" : "none"; #endif player_mode[i] = PSXRecompV4::PAD_MODE_ANALOG; player_deadzone[i] = kDefaultDeadzoneRaw; ctrl_locked_mode[i] = PSXRecompV4::PAD_MODE_ANALOG; } 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 */ /* Widescreen/View mode and Skip FMVs are mod-owned on PSX. Their legacy * game.toml offer flags remain parseable for old projects but deliberately * cannot expose generic launcher controls or activate the features. Trusted * activation plugins apply them after launcher/settings resolution. */ constexpr bool ws_offered = false; constexpr bool ws_ultrawide_offered = false; constexpr bool frame_interpolation_offered = false; constexpr bool skip_fmv_offered = false; /* Load acceleration is likewise mod-owned (Fast Loading / CD Speed). The * former game.toml `offer_turbo_loads` opt-out is deprecated and ignored: * offering the generic switch is no longer possible for any title, so a * config that forgot to migrate can no longer force turbo on. */ constexpr bool turbo_loads_offered = false; bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ /* Legacy single deadzone (<0 => keep per-slot / input.ini defaults). */ int resolved_deadzone = -1; /* 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 lang_menu_options; std::filesystem::path resolved_disc; /* game.toml [game] discs, in disc order -- the images this build was made * from. Single-disc titles leave one entry (or none). This is the roster * the launcher's Disc Selection dropdown offers and the list [disc] * selected indexes into; it is NOT a scan of the player's folder, so a * player who moved an image still browses for it. */ std::vector game_discs; /* 1-based selected disc, from settings.toml [disc] selected. Kept at main * scope because it round-trips through both launcher entry points. */ int selected_disc_index = 1; 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 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; /* Overlay cache init is deferred until after the launcher window so ABI * preflight / resident DLL loads do not delay first paint. */ bool deferred_overlay_cache = false; std::filesystem::path deferred_overlay_project_root; std::vector deferred_overlay_native_block; uint32_t deferred_overlay_config_hash = 0; std::string deferred_overlay_backend; bool deferred_has_overlay_ac = false; std::string deferred_overlay_ac; bool deferred_has_overlay_ac_tcc = false; std::string deferred_overlay_ac_tcc; bool deferred_overlay_capture_history = false; std::string deferred_overlay_capture_persist_dir; 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; if (gc.runtime.has_multitap_default) multitap_enabled = gc.runtime.multitap_default; apply_offline_pad_count(game_players, multitap_enabled); game_has_disc_crc = gc.has_disc_crc; game_disc_crc = gc.disc_crc; g_netplay_disc_expect.require_cue = gc.netplay_require_cue; g_netplay_disc_expect.required_tracks = gc.netplay_required_tracks; g_netplay_disc_expect.has_required_leadout = gc.has_netplay_required_leadout; g_netplay_disc_expect.required_leadout_lba = gc.netplay_required_leadout_lba; g_netplay_disc_expect.required_disc_fp = gc.netplay_required_disc_fp; g_netplay_local_viewport = (gc.netplay_local_viewport == "vertical_split") ? 1 : 0; g_netplay_local_viewport_aspect = (gc.netplay_local_viewport_aspect == "16:9") ? 1 : (gc.netplay_local_viewport_aspect == "21:9") ? 2 : (gc.netplay_local_viewport_aspect == "fixed") ? 4 : (gc.netplay_local_viewport_aspect == "adaptive") ? 3 : 0; gpu_ws_set_local_viewport_width_sites(gc.netplay_local_viewport_width_sites.data(), (int)gc.netplay_local_viewport_width_sites.size()); g_netplay_local_viewport_projection = (gc.netplay_local_viewport_renderer == "projection") ? 1 : 0; gpu_ws_set_local_viewport_native_wide( g_netplay_local_viewport == 1 && !g_netplay_local_viewport_projection); gpu_ws_set_local_viewport_state_gate( gc.netplay_local_viewport_state_addr, gc.netplay_local_viewport_state_values.data(), (int)gc.netplay_local_viewport_state_values.size()); g_netplay_content_negotiation = gc.netplay_content_negotiation ? 1 : 0; PSXRecompV4::mod_runtime_set_netplay_content_negotiation( g_netplay_content_negotiation != 0); game_discs = gc.discs; /* Per-disc serial gate, shared by the launch-time disc check and * the launcher's disc verdict. Keyed by the image's uppercased * stem so a .cue and its .bin agree. */ g_disc_serials.clear(); for (size_t i = 0; i < gc.discs.size() && i < gc.disc_serials.size(); ++i) { if (gc.disc_serials[i].empty()) continue; g_disc_serials[uppercase_ascii(gc.discs[i].stem().string())] = gc.disc_serials[i]; } /* Same keying for the per-disc netplay TOC fingerprints. */ g_disc_netplay_fps.clear(); for (size_t i = 0; i < gc.discs.size() && i < gc.netplay_required_disc_fps.size(); ++i) { if (gc.netplay_required_disc_fps[i].empty()) continue; g_disc_netplay_fps[uppercase_ascii(gc.discs[i].stem().string())] = gc.netplay_required_disc_fps[i]; } 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; /* turbo_loads / offer_turbo_loads are deprecated and ignored. Load * acceleration is owned by the Mods catalog (Fast Loading / CD * Speed), which ships with every title and defaults off, so the * legacy config key no longer enables anything. Say so loudly: * MMX6 shipped `turbo_loads = true` in v1.0.4/v1.0.5 and players * had no UI to turn it back off. */ if (gc.runtime.has_turbo_loads || gc.runtime.has_offer_turbo_loads) { std::fprintf(stdout, "psxrecomp: game.toml [runtime] %s%s%s is DEPRECATED and " "ignored; load acceleration is off unless the player " "enables the \"Fast Loading (host pacing)\" mod. Remove " "the key from game.toml.\n", gc.runtime.has_turbo_loads ? "turbo_loads" : "", (gc.runtime.has_turbo_loads && gc.runtime.has_offer_turbo_loads) ? " / " : "", gc.runtime.has_offer_turbo_loads ? "offer_turbo_loads" : ""); } if (gc.runtime.turbo_audio_sink) { g_turbo_audio_sink_config_enabled = 1; 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_depth24_trailing_margin = gc.runtime.video_depth24_trailing_margin; g_video_internal_res = gc.runtime.video_internal_resolution; g_video_ref_lines = gc.runtime.video_resolution_reference_lines; g_video_dynres = gc.runtime.video_dynamic_resolution ? 1 : 0; g_video_dynres_min = gc.runtime.video_dynamic_resolution_min; if (gc.runtime.video_window_width > 0) { g_video_win_w = gc.runtime.video_window_width; } g_video_aa = gc.runtime.video_antialiasing; g_video_texfilter = gc.runtime.video_texture_filter; g_video_fmv_filter = gc.runtime.video_fmv_filter; g_video_geometry_correction = gc.runtime.video_geometry_correction ? 1 : 0; g_video_perspective_texturing = gc.runtime.video_perspective_texturing ? 1 : 0; g_video_pgxp_cpu_mode = gc.runtime.video_pgxp_cpu_mode ? 1 : 0; g_video_pgxp_tolerance = (float)gc.runtime.video_pgxp_tolerance; g_video_pgxp_position_fallback = gc.runtime.video_pgxp_position_fallback ? 1 : 0; g_video_pgxp_preserve_projection = gc.runtime.video_pgxp_preserve_projection ? 1 : 0; g_video_pgxp_mod_only = gc.runtime.video_pgxp_mod_only ? 1 : 0; g_video_renderer = gc.runtime.video_renderer; g_video_screen = gc.runtime.video_screen_kind; g_video_scanlines = gc.runtime.video_scanlines; g_video_scanline_strength = (float)gc.runtime.video_scanline_strength; g_video_aspect_num = gc.runtime.video_aspect_num; g_video_aspect_den = gc.runtime.video_aspect_den; g_fov_scale = psx_projection_scale_load_config(game_config_path); g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; gl_renderer_set_texture_window_batching( gc.runtime.video_texture_window_batching ? 1 : 0); g_render_thread = gc.runtime.video_render_thread ? 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; gpu_ws_set_auto_ui_squash(gc.ws_auto_ui_squash ? 1 : 0); gpu_ws_set_auto_ui_in_place(gc.ws_auto_ui_in_place ? 1 : 0); /* [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); gpu_ws_set_precise_nclip(gc.ws_precise_nclip ? 1 : 0); gpu_ws_set_gameplay_state_gate( gc.ws_gameplay_state_addr, gc.ws_gameplay_state_values.data(), (int)gc.ws_gameplay_state_values.size()); /* 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 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_activation_guard_pixels( gc.ws_cull_activation_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_slti_lower_cull_sites( gc.ws_cull_slti_lower_sites.data(), (int)gc.ws_cull_slti_lower_sites.size()); gpu_ws_set_bias_lower_cull_sites( gc.ws_cull_bias_lower_sites.data(), (int)gc.ws_cull_bias_lower_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()); gpu_ws_set_branch_cull_sites( gc.ws_cull_bltz_sites.data(), (int)gc.ws_cull_bltz_sites.size(), gc.ws_cull_bgez_sites.data(), (int)gc.ws_cull_bgez_sites.size(), gc.ws_cull_branch_keep_sites.data(), (int)gc.ws_cull_branch_keep_sites.size()); gpu_ws_set_clip_edge_x_load_sites( gc.ws_cull_clip_edge_x_load_sites.data(), (int)gc.ws_cull_clip_edge_x_load_sites.size(), PSXRecompV4::ws_cull_clip_edge_width(gc)); { std::vector addresses, expected, results; addresses.reserve(gc.ws_cull_keep_sites.size()); expected.reserve(gc.ws_cull_keep_sites.size()); results.reserve(gc.ws_cull_keep_sites.size()); for (const auto& site : gc.ws_cull_keep_sites) { addresses.push_back(site.address); expected.push_back(site.expected); results.push_back(site.result); } gpu_ws_set_cull_keep_sites( addresses.data(), expected.data(), results.data(), (int)addresses.size()); } { std::vector addresses, expected, masks; for (const auto& site : gc.ws_cull_masked_reject_sites) { addresses.push_back(site.address); expected.push_back(site.expected); masks.push_back(site.reject_mask); } gpu_ws_set_masked_reject_sites(addresses.data(), expected.data(), masks.data(), (int)addresses.size()); } { std::vector addresses, expected, operands, halves; for (const auto& site : gc.ws_cull_scale_sites) { addresses.push_back(site.address); expected.push_back(site.expected); operands.push_back(site.operand); halves.push_back(site.half_extent); } gpu_ws_set_cull_scale_sites( addresses.data(), expected.data(), operands.data(), halves.data(), (int)addresses.size()); } { std::vector addresses, expected; addresses.reserve(gc.ws_cull_angle_sites.size()); expected.reserve(gc.ws_cull_angle_sites.size()); for (const auto& site : gc.ws_cull_angle_sites) { addresses.push_back(site.address); expected.push_back(site.expected); } gpu_ws_set_angle_sites( addresses.data(), expected.data(), (int)addresses.size()); } { /* [[draw_distance.clamp]]: stored for the interpreter; the * switch stays off until a mod's activation turns it on. */ std::vector sites; sites.reserve(gc.draw_distance_clamp_sites.size()); for (const auto& site : gc.draw_distance_clamp_sites) sites.push_back({site.address, site.expected, site.reg, site.max}); (void)psx_draw_distance_set_clamp_sites(sites.data(), (int)sites.size()); } { std::vector addresses, expected, thresholds; std::vector object_regs, x_regs, z_regs, y_regs; std::vector queue_guards; addresses.reserve(gc.ws_aspect_cone.sites.size()); expected.reserve(gc.ws_aspect_cone.sites.size()); thresholds.reserve(gc.ws_aspect_cone.sites.size()); object_regs.reserve(gc.ws_aspect_cone.sites.size()); x_regs.reserve(gc.ws_aspect_cone.sites.size()); z_regs.reserve(gc.ws_aspect_cone.sites.size()); y_regs.reserve(gc.ws_aspect_cone.sites.size()); queue_guards.reserve(gc.ws_aspect_cone.sites.size()); const auto effective_reg = [](uint32_t site_reg, uint32_t default_reg) { return site_reg == 0xFFFFFFFFu ? default_reg : site_reg; }; for (const auto& site : gc.ws_aspect_cone.sites) { addresses.push_back(site.address); expected.push_back(site.expected); thresholds.push_back(site.cosine_threshold); object_regs.push_back(effective_reg( site.object_reg, gc.ws_aspect_cone.object_reg)); x_regs.push_back(effective_reg( site.x_reg, gc.ws_aspect_cone.x_reg)); z_regs.push_back(effective_reg( site.z_reg, gc.ws_aspect_cone.z_reg)); y_regs.push_back(effective_reg( site.y_reg, gc.ws_aspect_cone.y_reg)); queue_guards.push_back(site.queue_guard ? 1u : 0u); } gpu_ws_set_aspect_cone( addresses.data(), expected.data(), thresholds.data(), object_regs.data(), x_regs.data(), z_regs.data(), y_regs.data(), queue_guards.data(), (int)addresses.size(), gc.ws_aspect_cone.forward_addr, gc.ws_aspect_cone.object_type_offset, gc.ws_aspect_cone.hysteresis_pixels, gc.ws_aspect_cone.queue_reserve, gc.ws_aspect_cone.queue_count_addrs.data(), gc.ws_aspect_cone.queue_capacities.data(), gc.ws_aspect_cone.queue_type_masks.data()); } 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()); /* gc.runtime.offer_turbo_loads is deprecated and ignored — see * turbo_loads_offered above. Nothing to assign. */ 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.direct_shortcut.empty()) apply_title_direct_shortcut(gc.runtime.direct_shortcut, gc.runtime.direct_shortcut_button); if (gc.runtime.has_default_mode) { for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { player_mode[i] = (i == 0) ? gc.runtime.default_p1_mode : gc.runtime.default_p2_mode; /* Beyond P2, reuse default_mode (same as P1 when set via default_mode). */ if (i >= 2) player_mode[i] = gc.runtime.default_p1_mode; } } if (gc.runtime.has_default_p1_device) player_device[0] = gc.runtime.default_p1_device; if (PSX_MAX_PLAYERS >= 2 && gc.runtime.has_default_p2_device) player_device[1] = gc.runtime.default_p2_device; for (int i = 0; i < PSX_MAX_PLAYERS; ++i) ctrl_locked_mode[i] = player_mode[i]; 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; for (int i = 0; i < PSX_MAX_PLAYERS; ++i) player_deadzone[i] = gc.runtime.deadzone; } /* [controller] anti_deadzone — raises the minimum reported stick * magnitude to compensate for a game's own internal deadzone. */ if (gc.runtime.has_anti_deadzone) controller_anti_deadzone = gc.runtime.anti_deadzone; /* Console port for SCPH-1070 when offline/netplay arms multitap. * Most titles use Port 1; Bomberman Party Edition needs Port 2. */ if (gc.runtime.has_multitap_port) { const int phys = (gc.runtime.multitap_port == 2) ? 1 : 0; sio_set_multitap_port(phys); } if (gc.runtime.has_multitap_analog) { multitap_analog = gc.runtime.multitap_analog; sio_set_multitap_analog(multitap_analog ? 1 : 0); #if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_multitap_analog = multitap_analog ? 1 : 0; #endif } /* 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; #if defined(RECOMP_LAUNCHER) PSXRecompV4::mod_runtime_set_hide_hidden_features( gc.runtime.hide_hidden_mod_features); #endif /* Developer compatibility finding, applied before BIOS selection. * Not exposed to settings.toml on purpose — see BIOS_SELECTION.md. */ s_openbios_allowed = gc.runtime.openbios; /* Let the dispatch layer distinguish "dirty because text was * loaded" from "diverged because runtime wrote different code over * the original EXE image". Packed/self-modifying games can rewrite * 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; extern uint32_t g_text_image_lo; 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; /* Pin the text BASE too. The floor alone assumes the boot EXE * sits at the bottom of RAM; a high-loading EXE (Klonoa * 0x180000, SFA3 0x113B00) streams its overlays into the RAM * BELOW itself, which must be overlay region, not text. */ uint32_t text_lo = gc.load_address & 0x1FFFFFFFu; if (text_lo > 0x00010000u && text_lo < g_overlay_region_floor) g_text_image_lo = text_lo; /* PSX_OVERLAY_REGION_FLOOR: per-title override for games whose TEXT * range is itself partially overwritten by streamed level data * (Driver 2 streams mission code over pages inside its static text * range). Lowering the floor routes those regions through local-flow * chaining and makes them overlay-cache candidates, so live-byte * closures can own them instead of single-instruction dispatch * thrash. Clamped to stay above the kernel window. */ if (gc.runtime.has_overlay_region_floor) { uint32_t v = gc.runtime.overlay_region_floor & 0x1FFFFFFFu; if (v >= 0x00010000u) g_overlay_region_floor = v; } { const char* fenv = std::getenv("PSX_OVERLAY_REGION_FLOOR"); if (fenv && fenv[0]) { uint32_t v = (uint32_t)strtoul(fenv, nullptr, 0) & 0x1FFFFFFFu; if (v >= 0x00010000u) g_overlay_region_floor = v; } } std::fprintf(stdout, "psxrecomp: overlay_region_floor = 0x%05X (game text end), " "text_image_lo = 0x%05X\n", g_overlay_region_floor, g_text_image_lo); } /* Overlay DLL cache (Layer A): stash config now; heavy init * (cache scan / ABI preflight / resident LoadLibrary) runs after * the launcher window so first UI paint is not blocked. */ if (gc.runtime.overlay_cache) { deferred_overlay_cache = true; deferred_overlay_project_root = gc.project_root; deferred_overlay_native_block = gc.runtime.overlay_native_block; deferred_overlay_config_hash = PSXRecompV4::overlay_codegen_config_hash(gc); deferred_overlay_backend = gc.runtime.overlay_backend; deferred_has_overlay_ac = gc.runtime.has_overlay_autocompile_cmd; deferred_overlay_ac = gc.runtime.overlay_autocompile_cmd; deferred_has_overlay_ac_tcc = gc.runtime.has_overlay_autocompile_cmd_tcc; deferred_overlay_ac_tcc = gc.runtime.overlay_autocompile_cmd_tcc; deferred_overlay_capture_history = gc.runtime.overlay_capture_history; deferred_overlay_capture_persist_dir = gc.runtime.overlay_capture_persist_dir; } 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 defined(RECOMP_LAUNCHER) launcher_boot_timing_mark("host:game_config_done"); #endif 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; bool user_settings_has_renderer = 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); user_settings_has_renderer = us.has_renderer; 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 /* A player's legacy factor outranks the game's shipped preset; their * own preset (written by this build) outranks both. */ if (us.has_supersampling) { g_video_scale = us.supersampling; g_video_internal_res = PSX_IR_UNSET; } if (us.has_internal_resolution) g_video_internal_res = us.internal_resolution; if (us.has_dynamic_resolution) g_video_dynres = us.dynamic_resolution ? 1 : 0; if (us.has_dynamic_resolution_min) g_video_dynres_min = us.dynamic_resolution_min; 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_fmv_filter) g_video_fmv_filter = us.fmv_filter; if (us.has_geometry_correction) g_video_geometry_correction = us.geometry_correction ? 1 : 0; if (us.has_perspective_texturing) g_video_perspective_texturing = us.perspective_texturing ? 1 : 0; if (us.has_screen_kind) g_video_screen = us.screen_kind; if (us.has_scanlines) g_video_scanlines = us.scanlines; if (us.has_scanline_strength) g_video_scanline_strength = (float)us.scanline_strength; /* fov_scale precedence: game.toml < settings.toml < PSX_GTE_FOV_SCALE. * Bad values warn and leave the lower layer's value in place. */ if (us.has_fov_scale) { if (psx_projection_scale_valid(us.fov_scale)) g_fov_scale = us.fov_scale; else std::fprintf(stderr, "psxrecomp: settings.toml [video] fov_scale %g ignored " "(must be > 0 and <= 8)\n", us.fov_scale); } if (const char* fov_env = std::getenv("PSX_GTE_FOV_SCALE")) { double value; if (psx_projection_scale_parse(fov_env, &value)) g_fov_scale = value; else std::fprintf(stderr, "psxrecomp: PSX_GTE_FOV_SCALE=\"%s\" ignored " "(must be a number > 0 and <= 8)\n", fov_env); } if (us.has_auto_skip_fmv) g_auto_skip_fmv = us.auto_skip_fmv ? 1 : 0; /* turbo_loads is deliberately NOT restored from settings.toml. It is a * write-only latch: the launcher stopped drawing a Turbo loads row when * load acceleration moved to the Mods catalog, so a persisted `true` * was simultaneously authoritative and unreachable — one run of a build * whose game.toml said true latched it forever and no later config * change could undo it (MegaManX6Recomp#14). Report it and move on; * write_user_settings no longer emits the key, so the stale row is * dropped on the next save. */ if (us.has_turbo_loads && us.turbo_loads) std::fprintf(stdout, "psxrecomp: settings.toml [video] turbo_loads = true is " "DEPRECATED and ignored; enable the \"Fast Loading (host " "pacing)\" mod instead. The stale key is dropped on the next " "settings save.\n"); 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_audio_freq) g_audio_freq = us.audio_freq; if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; if (us.has_rewind) g_rewind_enabled = us.rewind ? 1 : 0; if (us.has_rewind_depth) g_rewind_depth = us.rewind_depth; if (us.has_rewind_interval) g_rewind_interval = us.rewind_interval; if (us.has_hotkey_pad_rewind) g_hotkey_pad_rewind = normalize_hotkey_pad_binding( us.hotkey_pad_rewind, PSX_HOTKEY_PAD_SELECT_R3); if (us.has_hotkey_pad_save_state_menu) g_hotkey_pad_save_state_menu = normalize_hotkey_pad_binding( us.hotkey_pad_save_state_menu, PSX_HOTKEY_PAD_SELECT_R1); if (us.has_hotkey_pad_fast_forward) g_hotkey_pad_fast_forward = normalize_hotkey_pad_binding( us.hotkey_pad_fast_forward, PSX_HOTKEY_PAD_SELECT_L1); if (us.has_hotkey_pad_fast_forward_toggle) g_hotkey_pad_fast_forward_toggle = normalize_hotkey_pad_binding( us.hotkey_pad_fast_forward_toggle, 0); if (us.has_bios_path && !bios_from_cli && !us.bios_path.empty()) { settings_bios_storage = anchor_on_exe_dir(argv[0], us.bios_path).string(); bios_path = settings_bios_storage.c_str(); bios_explicit = true; } if (us.has_disc_path && !disc_override_path) resolved_disc = resolve_persisted_disc_path(us.disc_path, exe_dir_from_argv(argv[0])); /* Multi-disc precedence. [disc] selected is authoritative ONLY WHEN * PRESENT; absent it, [disc] path decides and the index is derived * from it. * * Getting this backwards is a live bug, not a hypothetical: an * external launcher that knows the path but cannot work out the roster * position writes `path` alone, and treating a missing key as * "selected = 1" then overrode a correct disc-2 path back to disc 1 on * every launch. `path` alone worked before the index existed and must * keep working -- a new field may add a way to choose a disc, it may * not take away the old one. */ if (!game_discs.empty() && us.has_disc_index) selected_disc_index = std::min(std::max(us.disc_index, 1), (int)game_discs.size()); if (!disc_override_path && game_discs.size() > 1) { if (us.has_disc_index) { const auto sel = resolve_selected_disc(game_discs, selected_disc_index, resolved_disc); /* Never normalize an empty path -- fs::absolute("") is the * cwd, which would turn "no disc yet" into a bogus mount. */ if (!sel.empty()) resolved_disc = normalize_disc_path_for_launch(sel); } else { /* Derive the index so the launcher still preselects the right * row and a later save writes a consistent pair. An unknown * path (the player browsed to something off-roster) leaves the * default; it is not evidence for any disc. */ const int idx = roster_index_for_disc(game_discs, resolved_disc); if (idx >= 0) selected_disc_index = idx + 1; } } /* Relative [memcard] values anchor on the exe directory; the * per-game options file also lives in memcard_dir. */ if (us.has_memcard_dir) memcard_dir = anchor_on_exe_dir(argv[0], us.memcard_dir); if (us.has_memcard1_path) memcard1_path = anchor_on_exe_dir(argv[0], us.memcard1_path); if (us.has_memcard2_path) memcard2_path = anchor_on_exe_dir(argv[0], 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_multitap_enabled) multitap_enabled = us.multitap_enabled; if (us.has_multitap_analog) { multitap_analog = us.multitap_analog; sio_set_multitap_analog(multitap_analog ? 1 : 0); #if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_multitap_analog = multitap_analog ? 1 : 0; #endif } if (us.has_language) resolved_language = us.language; { const int n = std::min(PSX_MAX_PLAYERS, PSXRecompV4::UserSettings::kMaxControllerPlayers); for (int i = 0; i < n; ++i) { if (us.has_p_device[i]) player_device[i] = us.p_device[i]; if (us.has_p_mode[i]) player_mode[i] = us.p_mode[i]; if (us.has_p_deadzone[i]) player_deadzone[i] = us.p_deadzone[i]; } if (us.has_deadzone) resolved_deadzone = us.deadzone; } apply_offline_pad_count(game_players, multitap_enabled); 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. */ g_force_digital_pads = 0; if (ctrl_lock_mode) { int all_digital = 1; for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { player_mode[i] = ctrl_locked_mode[i]; if (ctrl_locked_mode[i] != PSXRecompV4::PAD_MODE_DIGITAL) all_digital = 0; } /* Digital-only titles (e.g. BPE): DualShock-on-tap cannot be armed from * settings.toml or Lobby Settings either. */ if (all_digital) { g_force_digital_pads = 1; multitap_analog = false; sio_set_multitap_analog(0); #if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_multitap_analog = 0; #endif } } /* Skip FMVs is mod-owned on PSX. Clamp stale generic settings before * seeding recomp-ui; an enabled activation plugin applies the feature * after the final mod-plan commit. */ if (!skip_fmv_offered && g_auto_skip_fmv) { std::fprintf(stdout, "psxrecomp: Skip FMVs is mod-owned on PSX; " "ignoring the legacy Settings value\n"); g_auto_skip_fmv = 0; } /* Load acceleration is mod-owned on PSX, unconditionally. Nothing upstream * of this point is allowed to have enabled it (the game.toml and * settings.toml keys are both deprecated and ignored), so this is a * belt-and-braces clamp rather than the migration path it used to be — an * enabled Fast Loading / CD Speed plugin turns it on further down, after * the final mod-plan commit. */ if (g_turbo_loads_enabled) { std::fprintf(stdout, "psxrecomp: Turbo loads is mod-owned on PSX; " "ignoring the legacy value\n"); g_turbo_loads_enabled = 0; } /* Treat presentation interpolation the same way when a title moves it to * Mods. Both the boolean and target rate are cleared so launcher-less * starts cannot revive an old generic Settings selection. */ if (!frame_interpolation_offered && (g_frame_interpolation || g_frame_interpolation_fps)) { std::fprintf(stdout, "psxrecomp: Frame interpolation is mod-owned for this title; " "ignoring the legacy Settings value\n"); g_frame_interpolation = 0; g_frame_interpolation_fps = 0; } /* Widescreen/View mode is mod-owned on PSX. Clamp the generic display * aspect to native 4:3 so neither a legacy game.toml offer/default nor a * stale settings.toml value can engage it before trusted mod activation. */ if (!ws_offered && (g_video_aspect_num != 4 || g_video_aspect_den != 3)) { std::fprintf(stdout, "psxrecomp: widescreen is mod-owned on PSX; " "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; } /* 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) * (vsync XOR wall-clock pacer — vsync clocks only ~60 Hz panels); * 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 * /.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 go_addrs; std::vector go_sizes; std::vector go_names; std::vector go_vmins; std::vector 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()); } } /* Scan /mods and load persisted enable/option state. Everything * downstream — the launcher Mods tab (gi.mods), disc patching, the * psx_mod_set_* callbacks — is inert until this runs. */ { std::string mod_error; if (!PSXRecompV4::mod_runtime_initialize( exe_dir_from_argv(argv[0]) / "mods", game_id, game_entry_pc, text_guard_exe_path, &mod_error)) { std::fprintf(stderr, "psxrecomp: mods unavailable: %s\n", mod_error.c_str()); } } #if defined(RECOMP_LAUNCHER) launcher_boot_timing_mark("host:pre_overlay_worker"); #endif /* Overlay cache: run ABI preflight / resident DLL loads on a worker so the * launcher can open immediately and init overlaps with UI time. Join before * guest boot. When the launcher is skipped, the join still runs below. */ std::thread overlay_init_thread; std::exception_ptr overlay_init_exc; auto run_deferred_overlay_init = [&]() { PSXRecompV4::HostLaunchTimingScope timing(HOST_LAUNCH_OVERLAY_WORKER); 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(deferred_overlay_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 (deferred_overlay_capture_history && !deferred_overlay_capture_persist_dir.empty()) { std::filesystem::path persist = deferred_overlay_project_root / deferred_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( deferred_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(), deferred_overlay_config_hash); for (uint32_t addr : deferred_overlay_native_block) { overlay_loader_native_block_add(addr); } if (!deferred_overlay_native_block.empty()) { std::fprintf(stdout, "psxrecomp: overlay native blocklist seeded with %zu entr%s\n", deferred_overlay_native_block.size(), deferred_overlay_native_block.size() == 1 ? "y" : "ies"); } overlay_autocapture_set_enabled(0); const char *cfg_backend = deferred_overlay_backend.empty() ? nullptr : deferred_overlay_backend.c_str(); /* The bundled overlay_toolchain/ (embedded Python + compile_overlays.py + * recompiler + headers + tcc) can drive EITHER compiler: tcc from the * bundle, or a real gcc when one is on PATH. So a gcc toolchain counts * as available when there is a configured gcc command OR the bundle is * present to build one - the same package then yields gcc shards on a * dev box and tcc shards on a toolchain-less player box. */ extern int g_psx_cps_mode; const std::filesystem::path tk_xd = exe_dir_from_argv(argv[0]); const std::filesystem::path tk_dir = tk_xd / "overlay_toolchain"; /* The bundle's layout is identical on every platform; only the file * NAMES differ. These were hardcoded to the Windows spellings * ("python/python.exe", "psxrecomp-game.exe", "tcc/tcc.exe"), so on * Linux the gate below could never be true no matter what a packager * staged — a bundled Linux toolchain would have been dead weight the * runtime never looked at. (No Linux packager staged one either, so * this had no observable symptom to report: measured 2026-09-02, * `grep -c overlay_toolchain` was 0 in all three forked * tools/package_appimage.sh. Bead beads-eio.3.102.) * * python-build-standalone, the pinned relocatable CPython that * tools/release_stage.py stages on Linux, puts the interpreter at * python/bin/python3; python.org's embeddable zip puts it at * python/python.exe. Keep these in lockstep with * release_stage.TOOLCHAIN_PY_REL / TOOLCHAIN_RECOMPILER. */ #ifdef _WIN32 const std::filesystem::path tk_py = tk_dir / "python" / "python.exe"; const char *tk_recompiler = "psxrecomp-game.exe"; const char *tk_tcc = "tcc.exe"; #else const std::filesystem::path tk_py = tk_dir / "python" / "bin" / "python3"; const char *tk_recompiler = "psxrecomp-game"; const char *tk_tcc = "tcc"; #endif const bool tk_present = std::filesystem::exists(tk_py); /* Wave-5 F2: the CLI names the compiler it already has (the wizard's pack on * Windows, the native cc on POSIX) in overlay_toolchain/compiler.txt, so a player * with nothing on PATH still gets optimised shards instead of tcc's. */ std::string tk_compiler; { std::ifstream cf(tk_dir / "compiler.txt"); std::string line; if (cf.is_open() && std::getline(cf, line)) { while (!line.empty() && (line.back() == '\r' || line.back() == '\n' || line.back() == ' ')) line.pop_back(); std::error_code cec; if (!line.empty() && std::filesystem::exists(line, cec)) tk_compiler = line; } } auto build_toolchain_cmd = [&](const char *compiler) { auto cmd_quote = [](const std::string& s) { return std::string("\"") + s + "\""; }; std::string c = cmd_quote(tk_py.string()) + " " + cmd_quote((tk_dir / "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_dir / tk_recompiler).string()) + " --runtime-include " + cmd_quote((tk_dir / "include").string()) + " --project-root " + cmd_quote(tk_dir.string()) + " --out-dir " + cmd_quote((tk_xd / "cache").string()) + (g_psx_cps_mode ? " --cps" : "") + " --compiler " + compiler; if (std::string(compiler) == "gcc" && !tk_compiler.empty()) c += " --gcc " + cmd_quote(tk_compiler); if (std::string(compiler) == "tcc") c += " --tcc " + cmd_quote((tk_dir / "tcc" / tk_tcc).string()); return c; }; int gcc_avail = (deferred_has_overlay_ac || tk_present) && (autocompile_toolchain_available() || !tk_compiler.empty()); OverlayBackend eff = overlay_backend_resolve(cfg_backend, gcc_avail); std::string built_tcc_cmd; std::string built_gcc_cmd; std::string env_ac_cmd; const std::string *ac_cmd = nullptr; if (eff == OVERLAY_BACKEND_TCC) { if (deferred_has_overlay_ac_tcc) { ac_cmd = &deferred_overlay_ac_tcc; } else if (tk_present) { built_tcc_cmd = build_toolchain_cmd("tcc"); ac_cmd = &built_tcc_cmd; std::fprintf(stdout, "psxrecomp: tcc tier using bundled toolchain (%s)\n", tk_dir.string().c_str()); } else { std::fprintf(stdout, "psxrecomp: tcc tier active but no bundled toolchain at %s " "(overlay gaps -> interpreter)\n", tk_dir.string().c_str()); } } else { if (deferred_has_overlay_ac) { ac_cmd = &deferred_overlay_ac; } else if (tk_present) { /* gcc on PATH + bundled toolchain: gcc shards from the same bundle. */ built_gcc_cmd = build_toolchain_cmd("gcc"); ac_cmd = &built_gcc_cmd; std::fprintf(stdout, "psxrecomp: gcc tier using bundled toolchain (%s) with %s\n", tk_dir.string().c_str(), tk_compiler.empty() ? "gcc from PATH" : tk_compiler.c_str()); } } 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) { autocompile_set_cache_paths(cache_dir.c_str(), captures_path.string().c_str()); std::string ac_cwd = deferred_overlay_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); timing.success(); }; if (deferred_overlay_cache) { overlay_init_thread = std::thread([&]() { try { run_deferred_overlay_init(); } catch (...) { overlay_init_exc = std::current_exception(); } }); } #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/GL context is destroyed afterward, but SDL subsystems * stay initialized so the game window can open without a second SDL_Init. * * 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) { launcher_boot_timing_mark("host:before_sdl_init"); /* Per-monitor DPI awareness, BEFORE any SDL_Init. * * Without it Windows virtualises everything this process sees: on a * 7680x4320 panel at 400% scaling SDL_GetDisplayUsableBounds reports * 1920x1032, so the window is clamped to roughly 1376 LOGICAL pixels and * opens as a small box, while the desktop compositor then upscales it. * The internal render resolution is unaffected -- which is the trap: the * game renders at supersampling 16 and the result is thrown away scaling * a 1376-wide window up to an 8K display. * * permonitorv2 makes SDL report physical pixels, so the window sizes * against the real panel and the drawable matches it 1:1. */ #ifdef SDL_HINT_WINDOWS_DPI_AWARENESS SDL_SetHint(SDL_HINT_WINDOWS_DPI_AWARENESS, "permonitorv2"); #endif #ifdef SDL_HINT_WINDOWS_DPI_SCALING SDL_SetHint(SDL_HINT_WINDOWS_DPI_SCALING, "0"); #endif if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { launcher_boot_timing_mark("host:after_sdl_init"); recomp_launcher_set_preserve_sdl(1); int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ const bool bios_choice_supported = psx_bios_has_selectable() != 0 || psx_bios_registry_count == 0 || psx_bios_module_supported( exe_dir_from_argv(argv[0]).string().c_str()) != 0; PSXRecompV4::UserSettings seed; /* Netplay session BIOS is match-only; never overwrite seed/bios.cfg. */ std::filesystem::path match_session_bios_path; bool match_session_bios_set = false; seed.renderer = g_video_renderer; seed.has_renderer = true; /* Internal resolution round trip (internal_resolution.h): only a * launcher with the row seeds and returns the preset. An older one * gets the legacy Supersampling row, and a pick there wins. */ const int ir_preset_seeded = g_video_internal_res; const int ir_ss_seeded = psx_ir_launcher_seed_supersampling( kLauncherHasInternalResolution, g_video_internal_res, g_video_scale, g_video_ref_lines, psx_sdl_display_pixel_height(nullptr)); seed.supersampling = ir_ss_seeded; seed.has_supersampling = true; int ir_row_result = PSX_IR_UNSET; /* the row's pick, when it has one */ #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) seed.internal_resolution = internal_resolution_for_launcher(); seed.has_internal_resolution = true; #endif #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) seed.dynamic_resolution = dynres_requested() != 0; seed.has_dynamic_resolution = true; seed.dynamic_resolution_min = dynres_min_value(); seed.has_dynamic_resolution_min = true; #endif seed.antialiasing = g_video_aa; seed.has_antialiasing = true; seed.texture_filter = g_video_texfilter; seed.has_texture_filter = true; seed.fmv_filter = g_video_fmv_filter; seed.has_fmv_filter = true; /* Seeded (and marked present) so a launcher save round-trips the * player's hand-edited value instead of dropping the key. */ seed.geometry_correction = (g_video_geometry_correction != 0); seed.has_geometry_correction = true; seed.perspective_texturing = (g_video_perspective_texturing != 0); seed.has_perspective_texturing = true; seed.screen_kind = g_video_screen; seed.has_screen_kind = true; seed.scanlines = g_video_scanlines; seed.has_scanlines = true; seed.scanline_strength = g_video_scanline_strength; seed.has_scanline_strength = true; seed.auto_skip_fmv = (g_auto_skip_fmv != 0); seed.has_auto_skip_fmv = skip_fmv_offered; seed.turbo_loads = (g_turbo_loads_enabled != 0); seed.has_turbo_loads = turbo_loads_offered; 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.audio_freq = g_audio_freq; seed.has_audio_freq = true; seed.spu_hq = g_audio_spu_hq; seed.has_spu_hq = true; seed.rewind = g_rewind_enabled != 0; seed.has_rewind = true; seed.rewind_depth = g_rewind_depth; seed.has_rewind_depth = true; seed.rewind_interval = g_rewind_interval; seed.has_rewind_interval = true; seed.hotkey_pad_rewind = g_hotkey_pad_rewind; seed.has_hotkey_pad_rewind = true; seed.hotkey_pad_save_state_menu = g_hotkey_pad_save_state_menu; seed.has_hotkey_pad_save_state_menu = true; seed.hotkey_pad_fast_forward = g_hotkey_pad_fast_forward; seed.has_hotkey_pad_fast_forward = true; seed.hotkey_pad_fast_forward_toggle = g_hotkey_pad_fast_forward_toggle; seed.has_hotkey_pad_fast_forward_toggle = 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_choice_supported && bios_explicit && bios_path && bios_path[0]) { seed.bios_path = bios_path; seed.has_bios_path = true; } /* Hydrate launcher BIOS from bios.cfg when settings.toml has none, * and rewrite relative paths to absolute so reopen after generate * does not depend on cwd. */ if (bios_choice_supported && !seed.has_bios_path) { std::filesystem::path cached = read_cached_path(argv[0], "bios.cfg"); if (!cached.empty()) { seed.bios_path = cached; seed.has_bios_path = true; } } if (seed.has_bios_path) { std::filesystem::path resolved = resolve_bios_path(seed.bios_path.string().c_str(), argv[0]); std::error_code ec; if (!resolved.empty() && std::filesystem::exists(resolved, ec)) { seed.bios_path = std::filesystem::weakly_canonical(resolved, ec); if (ec) seed.bios_path = PSXRecompV4::host_absolute(resolved, ec); seed.has_bios_path = true; } else { seed.bios_path.clear(); seed.has_bios_path = false; } } /* First-run setup: if nothing remembered, adopt a retail dump next * to the install (SCPH1001…). Missing → leave empty (OpenBIOS). * Never override an existing bios.cfg (including cleared OpenBIOS). */ if (bios_choice_supported && !seed.has_bios_path) { std::error_code ec; const auto cfg = sidecar_cfg_path(argv[0], "bios.cfg"); if (!std::filesystem::exists(cfg, ec)) { std::filesystem::path found = discover_retail_bios_near(argv[0]); if (!found.empty()) { seed.bios_path = found; seed.has_bios_path = true; write_cached_path(argv[0], "bios.cfg", found); std::fprintf(stderr, "psxrecomp: setup adopted retail BIOS %s\n", found.string().c_str()); } } } 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; seed.multitap_enabled = multitap_enabled; seed.has_multitap_enabled = true; seed.multitap_analog = multitap_analog; seed.has_multitap_analog = 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; { const int n = std::min(PSX_MAX_PLAYERS, PSXRecompV4::UserSettings::kMaxControllerPlayers); for (int i = 0; i < n; ++i) { seed.p_device[i] = player_device[i]; seed.has_p_device[i] = true; seed.p_mode[i] = player_mode[i]; seed.has_p_mode[i] = true; seed.p_deadzone[i] = player_deadzone[i]; seed.has_p_deadzone[i] = true; } seed.deadzone = player_deadzone[0]; 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(); /* Same keybinds.ini / config.ini the runtime reads — never cwd. */ ae_rui_set_sidecar_paths(argv[0]); /* A CLI --disc must seed the launcher's initial disc. Without this, * the override suppresses the remembered settings/disc.cfg pick (the * has_disc_path gate above) while contributing nothing itself, so the * launcher opens with "No disc selected" and forces a manual pick on * every launch. resolve_disc_for_runtime still applies the override * authoritatively after the launcher returns. */ if (disc_override_path && disc_override_path[0]) { std::filesystem::path cli_disc = normalize_disc_path_for_launch( std::filesystem::path(disc_override_path)); std::error_code cli_ec; if (std::filesystem::exists(cli_disc, cli_ec)) resolved_disc = cli_disc; } 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 = seed.audio_freq; ls.volume = host_volume_get(); { const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); for (int i = 0; i < n; ++i) { const std::string& d = player_device[i]; ls.player_src[i] = PSXRecompV4::launcher_source_from_device(d); /* Round to the nearest launcher percent. Truncation turned a * saved 20% value (6553/32767) into 19%, which the launcher's * 5% normalization then silently reduced to 15%. */ ls.deadzone[i] = (player_deadzone[i] * 100 + 32767 / 2) / 32767; /* The seat's configured mode, verbatim. A keyboard seat * is NOT rewritten to DIGITAL on the way in: that told the * launcher a lie about what the seat is configured for, * and the launcher then handed the lie back for us to * persist. The keyboard's runtime behaviour does not * depend on this value (effective_player_mode). */ ls.pad_mode[i] = player_mode[i]; ls.player_gamepad_guid[i][0] = '\0'; if (ls.player_src[i] == 2 && !d.empty() && d != "auto" && d != "gamepad" && d != "controller") { std::snprintf(ls.player_gamepad_guid[i], sizeof(ls.player_gamepad_guid[i]), "%s", d.c_str()); } } } 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() : ""); /* aspect_index: 0 = 4:3, 1 = 16:9, 2 = 21:9 (see RecompLauncherCSettings). */ ls.aspect_index = (seed.aspect_num * 9 == seed.aspect_den * 21) ? 2 : (seed.aspect_num == 16 && seed.aspect_den == 9) ? 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; /* Fresh / invalid seed → OpenGL (DEFAULT_VIDEO_RENDERER), never * Software — unless the user explicitly saved software. */ if (ls.renderer < 0 || ls.renderer > (vulkan_offered ? 2 : 1)) ls.renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; if (ls.renderer == 0 && !user_settings_has_renderer && PSXRecompV4::DEFAULT_VIDEO_RENDERER != 0) ls.renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; ls.supersampling = seed.supersampling; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) ls.internal_resolution = seed.internal_resolution; #endif #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) ls.dynamic_resolution = seed.dynamic_resolution ? 1 : 0; ls.dynamic_resolution_min = seed.dynamic_resolution_min; #endif ls.antialiasing = seed.antialiasing ? 1 : 0; ls.texture_filter = seed.texture_filter; ls.fmv_filter = cfg_fmv_filter_to_launcher(seed.fmv_filter); ls.geometry_correction = seed.geometry_correction ? 1 : 0; ls.perspective_texturing = seed.perspective_texturing ? 1 : 0; ls.screen_kind = seed.screen_kind; #if defined(RECOMP_LAUNCHER_HAS_SCANLINES) ls.scanlines = seed.scanlines ? 1 : 0; ls.scanline_strength_pct = seed.has_scanline_strength ? (int)(seed.scanline_strength * 100.0 + 0.5) : 50; #endif 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.rewind_enabled = seed.rewind ? 1 : 0; ls.rewind_depth = seed.rewind_depth > 0 ? seed.rewind_depth : 50; ls.rewind_interval = seed.rewind_interval > 0 ? seed.rewind_interval : 15; ls.assist_pad_bind[PSX_ASSIST_BIND_REWIND] = normalize_hotkey_pad_binding(seed.hotkey_pad_rewind, PSX_HOTKEY_PAD_SELECT_R3); ls.assist_pad_bind[PSX_ASSIST_BIND_SAVE_STATE_MENU] = normalize_hotkey_pad_binding(seed.hotkey_pad_save_state_menu, PSX_HOTKEY_PAD_SELECT_R1); ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD] = normalize_hotkey_pad_binding(seed.hotkey_pad_fast_forward, PSX_HOTKEY_PAD_SELECT_L1); ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE] = normalize_hotkey_pad_binding(seed.hotkey_pad_fast_forward_toggle, 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 * /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()); } /* -1 = disabled. To the launcher 0 means "unset" (a host that * predates the field) and defaults to enabled, so a card the * user switched off used to show — and then persist — as on. */ ls.memcard_enabled[0] = seed.memcard1_enabled ? 1 : -1; ls.memcard_enabled[1] = seed.memcard2_enabled ? 1 : -1; /* Which disc the dropdown opens on (1-based; ignored when the * game is single-disc and gi.discs is empty). */ ls.disc_index = selected_disc_index; #if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ENABLED) ls.multitap_enabled = seed.multitap_enabled ? 1 : 0; #endif #if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) ls.multitap_analog = seed.multitap_analog ? 1 : 0; g_lnch_multitap_analog = seed.multitap_analog ? 1 : 0; #endif /* 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 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()); /* Real disc-verify + memcard-inspect expect serial/CRC on file-scope * statics (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; g_lnch_argv0 = argv[0]; /* Multi-disc roster for the launcher's Disc Selection dropdown. * The ABI BORROWS every pointer, so this storage has to outlive * recomp_launcher_run_window below -- hence plain locals in this * scope rather than a temporary. Paths go through the same * normalization as every other mount so a roster entry and the * initial disc are spelled identically. Labels are left null: * recomp-ui formats "Disc N" from the number itself, and one * source of that string beats two. */ std::vector rui_disc_paths; std::vector rui_discs; if (game_discs.size() > 1) { rui_disc_paths.reserve(game_discs.size()); for (const auto& d : game_discs) rui_disc_paths.push_back( normalize_disc_path_for_launch(d).string()); rui_discs.reserve(rui_disc_paths.size()); for (size_t i = 0; i < rui_disc_paths.size(); ++i) rui_discs.push_back(RecompLauncherCDisc{ (int)i + 1, nullptr, rui_disc_paths[i].c_str()}); } RecompLauncherCGameInfo gi{}; ae_fill_psx_launcher_game_info( &gi, game_name.empty() ? nullptr : game_name.c_str(), rui_region.empty() ? nullptr : rui_region.c_str(), game_players, ws_offered, ws_ultrawide_offered, skip_fmv_offered, turbo_loads_offered, vulkan_offered, ctrl_lock_mode, ctrl_lock_device, ctrl_locked_mode[0], rui_lang_labels.empty() ? nullptr : rui_lang_labels.data(), (int)rui_lang_labels.size(), /*resume_netplay_room=*/0); gi.discs = rui_discs.empty() ? nullptr : rui_discs.data(); gi.num_discs = (int)rui_discs.size(); #if defined(PSX_HAS_SETUP_WIZARD) /* MotK ships tools/prepare_disc.py (2448→2352). Offer it in the * first-run wizard so players need not run the script by hand. */ { const auto root = find_upward(exe_dir_from_argv(argv[0]), "tools/prepare_disc.py"); if (!root.empty()) { gi.prepare_disc = ae_prepare_disc; gi.prepare_disc_label = "Convert 2448-byte dump…"; gi.prepare_disc_note = "If you have a 2448-byte/sector MotK ISO dump, convert it " "to a MODE2/2352 .bin/.cue under motk/ (same as " "tools/prepare_disc.py)."; } } /* Quiet first-run detection: missing/unreadable BIOS or disc opens * the setup wizard inside recomp-ui (cross-platform file pickers). */ { bool bios_ok = false; RecompLauncherCBiosVerify bv{}; if (ls.bios_path[0]) { if (ae_bios_verify(ls.bios_path, &bv) && bv.ok) { bios_ok = true; /* Keep an absolute path in the model / settings writeback. */ std::filesystem::path resolved = resolve_bios_path(ls.bios_path, argv[0]); std::error_code ec; if (!resolved.empty() && std::filesystem::exists(resolved, ec)) { auto abs = std::filesystem::weakly_canonical(resolved, ec); if (ec) abs = PSXRecompV4::host_absolute(resolved, ec); std::snprintf(ls.bios_path, sizeof(ls.bios_path), "%s", abs.string().c_str()); } } else { ls.bios_path[0] = '\0'; } } if (!bios_ok) { if (ae_bios_verify("", &bv) && bv.ok) bios_ok = true; } bool disc_ok = false; if (!rui_initial_disc.empty()) { std::error_code ec; if (std::filesystem::exists(rui_initial_disc, ec)) { const DiscValidation dv = validate_disc_image(rui_initial_disc, game_id); disc_ok = dv.opened && dv.has_header; } if (!disc_ok) rui_initial_disc.clear(); } gi.needs_setup = (!bios_ok || !disc_ok) ? 1 : 0; } #if defined(PSX_HAS_CODEGEN_SETUP_HOST) /* Local codegen: missing generated/ or MOTK_FORCE_SETUP opens the * generate & rebuild wizard (may also set prepare_required). */ psx_game_codegen_setup_apply(&gi); /* The BIOS row exists when a choice can mean something: a retail * backend is linked, nothing is linked yet (setup host), or the * host wired Generate so a dump can be ingested and compiled in. * A shipped bundled build is none of these; forcing the row there * offered "Generate & rebuild" with no CLI or toolchain to run it. */ g_lnch_can_regen = gi.prepare_with_progress != nullptr || psx_bios_registry_count == 0; { const bool module_ok = psx_bios_module_supported( exe_dir_from_argv(argv[0]).string().c_str()) != 0; gi.has_bios = (psx_bios_has_selectable() || g_lnch_can_regen || module_ok) ? 1 : 0; /* The launcher resolves a buildable dump with this job instead * of Generate & rebuild (which a bundled build does not have). */ if (module_ok && !gi.prepare_with_progress) { gi.bios_prepare_with_progress = ae_bios_prepare; gi.bios_prepare_title = "Prepare this BIOS"; gi.bios_prepare_note = "This BIOS is not part of the build yet. Preparing it compiles " "a backend from your own image on this machine, once (about a " "minute); no disc or rebuild is needed. Or use OpenBIOS to play now."; gi.bios_prepare_button = "Prepare BIOS"; gi.bios_prepare_busy_status = "Compiling your BIOS for this build…"; gi.bios_prepare_success_status = "BIOS ready — continue to the launcher."; } } #endif #endif /* PSX_HAS_SETUP_WIZARD */ launcher_boot_timing_mark("host:setup_checks_done"); #if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name; apply_offline_pad_count(game_players, multitap_enabled); psx_lobby_set_max_slots(game_players); #endif #if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) if (cli_netplay_query) { const int qrc = ae_np_answer_query(cli_netplay_query, &ls, rui_initial_disc); if (overlay_init_thread.joinable()) overlay_init_thread.join(); SDL_Quit(); return qrc; } #endif #if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) // Once per offline preboot: verified receipts make the selected plan // ready before the launcher opens. A miss leaves preparation to UI. if (!net_cfg.enabled && !ls.netplay_launch.enabled && !rui_initial_disc.empty()) PSXRecompV4::mod_runtime_try_prepare_cached(rui_initial_disc); #endif char rui_out_disc[1024] = {0}; launcher_boot_timing_mark("host:before_run_window"); 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)); launcher_boot_timing_mark("host:after_run_window"); lr = rui_rc; /* The launcher hands back the player's edits on Quit as well as on * Play (recomp_launcher.h: "*io still holds the edits"), so both * fold them into seed. Disc choice stays Play-only: Quit launches * nothing. */ if (lr == 0 || lr == 1) { seed.netplay_player_name = ls.netplay_player_name; seed.has_netplay_player_name = true; if (lr == 0 && rui_out_disc[0]) { seed.disc_path = rui_out_disc; seed.has_disc_path = true; } /* Persist the Disc Selection choice next to the disc path, so * the next session opens on the same disc and an external * launcher sees it as an ordinary settings row. Written for * multi-disc titles only -- a single-disc game has nothing to * select and should not grow a meaningless key. */ if (lr == 0 && game_discs.size() > 1 && ls.disc_index > 0) { selected_disc_index = ls.disc_index; seed.disc_index = ls.disc_index; seed.has_disc_index = true; } seed.fullscreen = ls.fullscreen; seed.has_fullscreen = true; seed.skip_launcher = ls.skip_launcher != 0; seed.has_skip_launcher = true; /* aspect_index round-trips 0/1/2 -> 4:3 / 16:9 / 21:9, superseding the * legacy ls.widescreen bool (still set above for older callers). */ 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; /* 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; seed.fmv_filter = launcher_fmv_filter_to_cfg(ls.fmv_filter); seed.has_fmv_filter = true; { const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); const int un = std::min(n, PSXRecompV4::UserSettings::kMaxControllerPlayers); for (int i = 0; i < n; ++i) { if (ls.player_src[i] == 1) { player_device[i] = "keyboard"; } else if (ls.player_src[i] == 0) { player_device[i] = "none"; } else if (ls.player_gamepad_guid[i][0]) { player_device[i] = ls.player_gamepad_guid[i]; } else if (PSXRecompV4::launcher_source_from_device( player_device[i]) <= 1) { player_device[i] = "gamepad"; } /* Mode is resolved separately from the device, because * the launcher round-trip is the SECOND way a locked * game could boot an unsupported pad type: the clamp at * the top of main() runs BEFORE the launcher, so * ls.pad_mode[] (seeded from settings.toml, or from a * selector the player never saw because lock_mode hides * it) would otherwise win here -- and then be persisted * into seed.p_mode[] a few lines down. Defense in depth: * the launcher itself no longer corrupts a locked mode * (recomp-ui launcher_model.c), but the host must not * depend on that to boot the declared pad type. */ player_mode[i] = PSXRecompV4::resolve_player_mode_after_launcher( ls.pad_mode[i], ctrl_lock_mode, ctrl_locked_mode[i], g_mod_controller_mode_override[i]); player_deadzone[i] = ls.deadzone[i] * 32767 / 100; if (i < un) { seed.p_device[i] = player_device[i]; seed.has_p_device[i] = true; seed.p_mode[i] = player_mode[i]; seed.has_p_mode[i] = true; seed.p_deadzone[i] = player_deadzone[i]; seed.has_p_deadzone[i] = true; } } seed.deadzone = player_deadzone[0]; seed.has_deadzone = 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; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) ir_row_result = ls.internal_resolution; #endif #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) seed.dynamic_resolution = ls.dynamic_resolution != 0; seed.has_dynamic_resolution = true; seed.dynamic_resolution_min = ls.dynamic_resolution_min; seed.has_dynamic_resolution_min = true; #endif seed.antialiasing = ls.antialiasing != 0; seed.has_antialiasing = true; seed.geometry_correction = ls.geometry_correction != 0; seed.has_geometry_correction = true; seed.perspective_texturing = ls.perspective_texturing != 0; seed.has_perspective_texturing = true; seed.fmv_filter = launcher_fmv_filter_to_cfg(ls.fmv_filter); seed.has_fmv_filter = true; seed.screen_kind = ls.screen_kind; seed.has_screen_kind = true; #if defined(RECOMP_LAUNCHER_HAS_SCANLINES) seed.scanlines = ls.scanlines != 0; seed.has_scanlines = true; if (ls.scanline_strength_pct >= 0) { seed.scanline_strength = ls.scanline_strength_pct / 100.0; seed.has_scanline_strength = true; } #endif 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.audio_freq = ls.audio_freq; seed.has_audio_freq = true; seed.spu_hq = ls.spu_hq != 0; seed.has_spu_hq = true; seed.rewind = ls.rewind_enabled != 0; seed.has_rewind = true; seed.rewind_depth = ls.rewind_depth > 0 ? ls.rewind_depth : 50; seed.has_rewind_depth = true; seed.rewind_interval = ls.rewind_interval > 0 ? ls.rewind_interval : 15; seed.has_rewind_interval = true; seed.hotkey_pad_rewind = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_REWIND], PSX_HOTKEY_PAD_SELECT_R3); seed.has_hotkey_pad_rewind = true; seed.hotkey_pad_save_state_menu = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_SAVE_STATE_MENU], PSX_HOTKEY_PAD_SELECT_R1); seed.has_hotkey_pad_save_state_menu = true; seed.hotkey_pad_fast_forward = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD], PSX_HOTKEY_PAD_SELECT_L1); seed.has_hotkey_pad_fast_forward = true; seed.hotkey_pad_fast_forward_toggle = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE], 0); seed.has_hotkey_pad_fast_forward_toggle = true; seed.auto_skip_fmv = ls.auto_skip_fmv != 0; seed.has_auto_skip_fmv = skip_fmv_offered; seed.turbo_loads = ls.turbo_loads != 0; seed.has_turbo_loads = turbo_loads_offered; host_volume_set(ls.volume); /* Bundled-BIOS builds ignore any launcher-supplied path (the * picker is hidden, but a stale settings file could still * carry one) — resolve_bios_for_runtime would reject it and * the identity gate makes it meaningless. */ if (bios_choice_supported && ls.bios_path[0]) { std::filesystem::path resolved = resolve_bios_path(ls.bios_path, argv[0]); std::error_code ec; if (!resolved.empty() && std::filesystem::exists(resolved, ec)) { seed.bios_path = std::filesystem::weakly_canonical(resolved, ec); if (ec) seed.bios_path = PSXRecompV4::host_absolute(resolved, ec); } else { seed.bios_path = ls.bios_path; } seed.has_bios_path = true; } else { seed.bios_path.clear(); seed.has_bios_path = false; } /* 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 defined(RECOMP_LAUNCHER_HAS_MULTITAP_ENABLED) seed.multitap_enabled = ls.multitap_enabled != 0; seed.has_multitap_enabled = true; multitap_enabled = seed.multitap_enabled; apply_offline_pad_count(game_players, multitap_enabled); #endif #if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) seed.multitap_analog = (!g_force_digital_pads && ls.multitap_analog != 0); seed.has_multitap_analog = true; multitap_analog = seed.multitap_analog; sio_set_multitap_analog(multitap_analog ? 1 : 0); if (game_config_path && game_config_path[0] && !g_force_digital_pads) { (void)PSXRecompV4::upsert_game_toml_controller_bool( game_config_path, "multitap_analog", multitap_analog); } #endif 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) { /* Host-authoritative DualShock-on-tap for the match. */ multitap_analog = (!g_force_digital_pads && caps->multitap_analog != 0); seed.multitap_analog = multitap_analog; seed.has_multitap_analog = true; sio_set_multitap_analog(multitap_analog ? 1 : 0); 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; if (turbo_loads_offered) { seed.turbo_loads = caps->turbo_loads != 0; seed.has_turbo_loads = true; } if (skip_fmv_offered) { 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; } /* Match BIOS settle (lobby bios_offer → session_bios). * Ephemeral for this boot only — seed / bios.cfg keep * the player's offline preference. */ if (caps->session_bios[0]) ae_np_set_session_bios_token(caps->session_bios, caps->session_bios_crc); } } /* LAN START / lobby token — apply even when match_caps absent. */ const char* match_bios_id = nullptr; if (ls.netplay_launch.enabled && g_lnch_session_bios[0] && resolve_match_session_bios_path( g_lnch_session_bios, g_lnch_session_bios_crc, seed.has_bios_path ? seed.bios_path : std::filesystem::path{}, ls.bios_path, argv[0], &match_session_bios_path, &match_bios_id)) { match_session_bios_set = true; if (std::strcmp(g_lnch_session_bios, "scph1001") == 0 && match_session_bios_path.empty()) { std::fprintf(stderr, "psxrecomp: session BIOS scph1001 (crc %08x) required " "but no validated dump found — aborting launch (mixed " "BIOS images would desync)\n", (unsigned)g_lnch_session_bios_crc); match_session_bios_set = false; ls.netplay_launch.enabled = 0; g_lnch_pending_direct_launch = {}; ae_np_clear_session_bios_token(); if (overlay_init_thread.joinable()) overlay_init_thread.join(); SDL_Quit(); return 1; } else if (match_session_bios_path.empty()) { std::fprintf(stdout, "psxrecomp: netplay session BIOS = OpenBIOS " "(match only; preference unchanged)\n"); } else { std::fprintf(stdout, "psxrecomp: netplay session BIOS = %s " "(%s; match only; preference unchanged)\n", match_bios_id ? match_bios_id : "retail", match_session_bios_path.string().c_str()); } } #endif } if (lr == 1) { /* Keep settings changed and then dismissed with Quit. */ PSXRecompV4::save_user_settings( exe_dir_from_argv(argv[0]) / "settings.toml", seed); std::fprintf(stdout, "psxrecomp: launcher closed; exiting.\n"); if (overlay_init_thread.joinable()) overlay_init_thread.join(); SDL_Quit(); return 0; } #if defined(PSX_HAS_CODEGEN_SETUP_HOST) if (lr == RECOMP_LAUNCHER_RESULT_RELAUNCH) { const char* disc_for_relaunch = rui_out_disc[0] ? rui_out_disc : (rui_initial_disc.empty() ? "" : rui_initial_disc.c_str()); std::fprintf(stdout, "psxrecomp: relaunch after generate/rebuild\n"); if (overlay_init_thread.joinable()) overlay_init_thread.join(); psx_game_codegen_relaunch_or_exit(disc_for_relaunch); /* Does not return on success. */ SDL_Quit(); return 1; } #endif if (lr == 0) { if (ls.netplay_launch.enabled) { net_cfg.enabled = 1; net_cfg.local_slot = ls.netplay_launch.local_slot; net_cfg.spectator = ls.netplay_launch.is_spectator ? 1 : 0; net_cfg.spectator_wire_slot = ls.netplay_launch.spectator_wire_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; net_cfg.input_prediction = ls.netplay_launch.input_prediction; net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; #if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY) net_cfg.transport_host = ls.netplay_launch.transport_host ? 1 : 0; #endif net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0; net_cfg.rollback = ls.netplay_launch.rollback ? 1 : 0; net_cfg.guest_memcard = ls.netplay_launch.guest_memcard ? 1 : 0; net_cfg.player_count = ls.netplay_launch.player_count; net_cfg.host_spectates = ls.netplay_launch.host_spectates ? 1 : 0; net_cfg.port_map_valid = ls.netplay_launch.slot_port_valid ? 1 : 0; for (int i = 0; i < 9; ++i) net_cfg.port_of_slot[i] = (net_cfg.port_map_valid && i <= RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS) ? ls.netplay_launch.slot_port[i] : -1; net_cfg.slot_count = ae_np_session_slot_count( ls.netplay_launch.player_count, ls.netplay_launch.max_slots, ls.netplay_launch.local_slot, game_players, PSX_MAX_PLAYERS + (net_cfg.host_spectates ? 1 : 0)); if (net_cfg.player_count <= 0) net_cfg.player_count = net_cfg.slot_count; net_cfg.occupied_mask = ls.netplay_launch.occupied_mask; 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); /* A match the Retro hub started ends this process: the * hub holds the room, so there is no lobby to return to. */ g_netplay_from_lobby = psx_lobby_handoff_active() ? 0 : 1; std::fprintf(stdout, "psxrecomp: launcher netplay slot=%d slots=%d mask=0x%x bind=%s peer=%s session=%u\n", net_cfg.local_slot, net_cfg.slot_count, (unsigned)net_cfg.occupied_mask, 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; { const PsxIrAdopted ir = psx_ir_adopt_launcher( kLauncherHasInternalResolution, ir_preset_seeded, ir_ss_seeded, seed.supersampling, ir_row_result, g_video_ref_lines, psx_sdl_display_pixel_height(nullptr), video_scale_ceiling()); g_video_internal_res = ir.preset; g_video_scale = ir.scale; seed.supersampling = ir.save_ss; seed.has_internal_resolution = ir.save_ir != PSX_IR_UNSET; seed.internal_resolution = ir.save_ir; } #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) g_video_dynres = seed.dynamic_resolution ? 1 : 0; g_video_dynres_min = seed.dynamic_resolution_min; #endif g_video_aa = seed.antialiasing; g_video_texfilter = seed.texture_filter; g_video_fmv_filter = seed.fmv_filter; g_video_geometry_correction = seed.geometry_correction ? 1 : 0; g_video_perspective_texturing = seed.perspective_texturing ? 1 : 0; g_video_screen = seed.screen_kind; if (seed.has_scanlines) g_video_scanlines = seed.scanlines; if (seed.has_scanline_strength) g_video_scanline_strength = (float)seed.scanline_strength; gl_renderer_set_scanlines(g_video_scanlines ? 1 : 0, g_video_scanline_strength); g_auto_skip_fmv = skip_fmv_offered && seed.auto_skip_fmv ? 1 : 0; g_turbo_loads_enabled = turbo_loads_offered && 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_audio_freq = seed.audio_freq; g_audio_spu_hq = seed.spu_hq; g_rewind_enabled = seed.has_rewind ? (seed.rewind ? 1 : 0) : 0; g_rewind_depth = seed.has_rewind_depth && seed.rewind_depth > 0 ? seed.rewind_depth : 50; g_rewind_interval = seed.has_rewind_interval && seed.rewind_interval > 0 ? seed.rewind_interval : 15; g_hotkey_pad_rewind = seed.has_hotkey_pad_rewind ? seed.hotkey_pad_rewind : PSX_HOTKEY_PAD_SELECT_R3; g_hotkey_pad_save_state_menu = seed.has_hotkey_pad_save_state_menu ? seed.hotkey_pad_save_state_menu : PSX_HOTKEY_PAD_SELECT_R1; g_hotkey_pad_fast_forward = seed.has_hotkey_pad_fast_forward ? seed.hotkey_pad_fast_forward : PSX_HOTKEY_PAD_SELECT_L1; g_hotkey_pad_fast_forward_toggle = seed.has_hotkey_pad_fast_forward_toggle ? seed.hotkey_pad_fast_forward_toggle : 0; 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(); bios_explicit = true; bios_from_cli = false; /* launcher pick supersedes argv --bios */ write_cached_path(argv[0], "bios.cfg", seed.bios_path); } else { /* OpenBIOS: always clear, even when launched with --bios. * Write an empty bios.cfg (do not delete — missing triggers * first-run retail rediscovery). */ bios_from_cli = false; bios_explicit = false; settings_bios_storage.clear(); bios_path = PSX_BUNDLED_BIOS_PATH; write_cached_path(argv[0], "bios.cfg", {}); } /* Session settle overrides runtime BIOS only — preference already * written above. Use bios_explicit so resolve_bios_for_runtime * does not re-read bios.cfg and undo an OpenBIOS match. */ if (match_session_bios_set) { bios_from_cli = false; bios_explicit = true; if (match_session_bios_path.empty()) { settings_bios_storage = PSX_BUNDLED_BIOS_PATH; bios_path = settings_bios_storage.c_str(); } else { settings_bios_storage = match_session_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; write_cached_path(argv[0], "disc.cfg", resolved_disc); } memcard1_enabled = seed.memcard1_enabled; memcard2_enabled = seed.memcard2_enabled; if (seed.has_memcard1_path) memcard1_path = anchor_on_exe_dir(argv[0], seed.memcard1_path); if (seed.has_memcard2_path) memcard2_path = anchor_on_exe_dir(argv[0], seed.memcard2_path); if (seed.has_language) resolved_language = seed.language; { const int n = std::min(PSX_MAX_PLAYERS, PSXRecompV4::UserSettings::kMaxControllerPlayers); for (int i = 0; i < n; ++i) { if (seed.has_p_device[i]) player_device[i] = seed.p_device[i]; if (seed.has_p_mode[i]) player_mode[i] = seed.p_mode[i]; if (seed.has_p_deadzone[i]) player_deadzone[i] = seed.p_deadzone[i]; } 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 if (overlay_init_thread.joinable()) { PSXRecompV4::HostLaunchTimingScope timing(HOST_LAUNCH_OVERLAY_JOIN); overlay_init_thread.join(); if (overlay_init_exc) { try { std::rethrow_exception(overlay_init_exc); } catch (const std::exception& ex) { std::fprintf(stderr, "psxrecomp: overlay cache init failed: %s\n", ex.what()); return 1; } } timing.success(); } 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; } } { /* Netplay is vanilla unless the title opted into content negotiation * ([netplay] content_negotiation); either way the user's persisted * offline mod selection is left untouched. */ PSXRecompV4::HostLaunchTimingScope timing(HOST_LAUNCH_RUNTIME_COMMIT); std::string mod_error; if (net_cfg.enabled) { if (!netplay_commit_mods(resolved_disc, &mod_error)) { std::fprintf(stderr, g_netplay_content_negotiation ? "psxrecomp: cannot apply netplay mods: %s\n" : "psxrecomp: cannot clear mods for netplay: %s\n", mod_error.c_str()); return 1; } } else if (!PSXRecompV4::mod_runtime_commit(resolved_disc, &mod_error)) { std::fprintf(stderr, "psxrecomp: cannot launch with selected mods: %s\n", mod_error.c_str()); return 1; } timing.success(); } /* Session start: every session runs this after its mod commit or netplay * clear -- the first boot here, and the lobby rematch, which re-enters at * session_reboot below this block and so calls it itself. Both paths reach * renderer and window creation only after session_reboot, so activation * precedes them either way. A netplay clear leaves no plan, so nothing * activates and the session stays vanilla. */ auto start_mod_session = [&](bool netplay) { /* Clear game-owned controller overrides/policies and load/disc-speed * choices first so disabling a package cannot leave its prior state * latched across a soft return. */ g_mod_controller_mode_override.fill(-1); mod_controller_source_reset(); mod_pad_transform_reset(); g_direct_shortcut_allowed = 0; g_p1_claimed_buttons = 0; psx_rewind_set_title_blocked(0); for (auto& policy : g_mod_controller_policy) policy = ModControllerPresentationPolicy{}; g_mod_load_wall_multiplier = -1; g_mod_load_release_frames = -1; g_mod_disc_speed_divisor = -1; g_mod_disc_instant_rate = -1; g_turbo_audio_sink_enabled = g_turbo_audio_sink_config_enabled; g_turbo_load_wall_multiplier = 0; g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; if (!turbo_loads_offered) g_turbo_loads_enabled = 0; /* The helper restores interpolation and Skip FMVs to their * pre-activation values, which is only right while no launcher * control owns them. */ static_assert(!frame_interpolation_offered && !skip_fmv_offered, "reset_mod_owned_presentation() would clobber a launcher " "setting; restore only when the feature is mod-owned"); reset_mod_owned_presentation(); /* Netplay own-view mods: the widescreen margin follows them into the * sandboxed own view only; the shared game keeps the stock cull. */ gpu_ws_set_local_view_only( netplay && PSXRecompV4::mod_runtime_netplay_view_active() ? 1 : 0); mod_runtime_activate_plugins(); apply_netplay_local_viewport_aspect(netplay); for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { if (g_mod_controller_mode_override[i] >= 0) player_mode[i] = g_mod_controller_mode_override[i]; } if (g_mod_load_wall_multiplier >= 0) { g_turbo_loads_enabled = 1; g_turbo_load_wall_multiplier = g_mod_load_wall_multiplier; g_turbo_load_release_frames = g_mod_load_release_frames; /* Fast Loading advances the guest at a host rate greater than real * time. Keep the canonical SPU/CD stream running, but discard the * accelerated presentation-side audio until pacing resumes; * otherwise the SDL bridge overflows and the load becomes * observably unstable. */ g_turbo_audio_sink_enabled = g_turbo_load_wall_multiplier > 1; if (g_turbo_load_wall_multiplier) { std::fprintf(stdout, "psxrecomp: mod selected %dx load acceleration " "(%d release frames)\n", g_turbo_load_wall_multiplier, g_turbo_load_release_frames); } else { std::fprintf(stdout, "psxrecomp: mod selected uncapped load acceleration " "(%d release frames)\n", g_turbo_load_release_frames); } } }; start_mod_session(net_cfg.enabled); /* 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], bios_explicit); if (resolved_bios.empty()) { std::fprintf(stderr, "psxrecomp: no BIOS 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 = session_disc_path(resolved_disc); 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(); /* Rematch ≈ cold start for netplay sim residue a cold peer lacks * (pad edges, dig0 latch, tip densify, FMV flags, IRQ resume). * Idempotent with BYE teardown; device *_init still runs below. */ psx_netplay_cold_reset(); { extern uint64_t s_frame_count; extern uint32_t g_debug_current_func_addr; extern uint32_t g_debug_last_store_pc; extern int g_call_unit_depth; extern int g_psx_dispatch_depth; s_frame_count = 0; g_debug_current_func_addr = 0; g_debug_last_store_pc = 0; /* Soft-exit mid-call can skip the depth restore if the longjmp path * was not taken; sticky depth suppresses overlay IRQ delivery and * freezes rematch after lockstep armed. */ g_call_unit_depth = 0; g_psx_dispatch_depth = 0; } /* Cold start activates via resolve_bios_for_runtime before this label. * Rematch only rewrites bios_path_str — without re-activate, memory_init * loads new ROM bytes against the prior match's linked backend (e.g. * SCPH-1001 dump + sticky OPENBIOS) and dig0 never publishes cleanly. */ if (!validate_bios_for_launch(std::filesystem::path(bios_path_str))) { std::fprintf(stderr, "psxrecomp: BIOS activate failed for %s%s\n", bios_path_str.c_str(), rematch_session ? " (rematch)" : ""); return 1; } if (rematch_session) { std::fprintf(stdout, "psxrecomp: rematch BIOS activated id=%s bundled=%d " "deliver_event_ret=0x%x shell_entry=0x%x\n", psx_bios_image.image_id ? psx_bios_image.image_id : "?", psx_bios_image.image_bundled ? 1 : 0, (unsigned)psx_bios_image.deliver_event_ret, (unsigned)psx_bios_image.shell_entry_phys); std::fflush(stdout); } memory_init(bios_path_str.c_str()); if (g_headless_opengl) g_video_renderer = 1; #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 /* Netplay: CPU VRAM is digest/snap authority. Prefer dual-raster OpenGL * (SW@1× + GL@settings SSAA FBO present, never glReadPixels). Vulkan * present not yet cpu-auth — fall back to a software window. */ s_netplay_gl_present = 0; s_netplay_sim_native_scale = 0; gl_renderer_set_cpu_auth_dual(0); if (net_cfg.enabled) { s_netplay_sim_native_scale = 1; if (g_video_renderer == 1) { s_netplay_gl_present = 1; g_gl_fbo_present = 1; gl_renderer_set_cpu_auth_dual(1); gr_set_backend(GR_BACKEND_OPENGL); std::fprintf(stdout, "psxrecomp: netplay — dual-raster " "(SW@1x CPU-auth + OpenGL present quality; no FBO readback)\n"); std::fprintf(stdout, "psxrecomp: renderer backend requested: opengl " "(netplay dual-raster)\n"); } else if (g_video_renderer == 2) { std::fprintf(stdout, "psxrecomp: netplay — Vulkan present not yet CPU-auth; " "using software window (was vulkan)\n"); g_video_renderer = 0; gr_set_backend(GR_BACKEND_SOFTWARE); std::fprintf(stdout, "psxrecomp: renderer backend requested: software " "(netplay sim)\n"); } else { gr_set_backend(GR_BACKEND_SOFTWARE); std::fprintf(stdout, "psxrecomp: renderer backend requested: software " "(netplay sim)\n"); } } else { /* 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. * Ordinary headless has no context and uses software. The explicit * hidden-OpenGL mode creates a context below and refuses fallback. */ const int backend_renderer = g_headless && !g_headless_opengl ? 0 : g_video_renderer; gr_set_backend(backend_renderer == 2 ? GR_BACKEND_VULKAN : backend_renderer == 1 ? GR_BACKEND_OPENGL : GR_BACKEND_SOFTWARE); std::fprintf(stdout, "psxrecomp: renderer backend requested: %s%s\n", backend_renderer == 2 ? "vulkan" : backend_renderer == 1 ? "opengl" : "software", g_headless_opengl ? " (hidden context)" : g_headless && g_video_renderer != 0 ? " (headless)" : ""); } gpu_init(); /* Internal-resolution supersampling (SSAA). Must follow gpu_init. * Dual-raster: gr_set_scale(N) arms GL hr FBO @ N× while glb_set_scale * keeps SW at 1×. SW-only netplay: force scale 1. Offline: full SSAA. */ /* Internal resolution preset -> scale (no-op while unset). Match display * knows the monitor only once SDL video is up (launcher path); otherwise it * is re-resolved from the game window's display before GL context init. * PSX_INTERNAL_RESOLUTION (a preset id or a line count) overrides every * config layer for one run, like the other validation env overrides: two * local netplay peers share one settings.toml but may differ here. */ if (const char* e = std::getenv("PSX_INTERNAL_RESOLUTION")) { int v = 0; if (psx_ir_parse(e, &v)) g_video_internal_res_env = v; else std::fprintf(stdout, "psxrecomp: PSX_INTERNAL_RESOLUTION=%s not understood " "(native, 720p, 1080p, 1440p, 4k, 5k, 8k, display, or lines)\n", e); } apply_internal_resolution(psx_sdl_display_pixel_height(nullptr)); if (g_video_scale < 1) g_video_scale = 1; if (const char* e = std::getenv("PSX_DYNRES")) g_video_dynres_env = (*e && *e != '0') ? 1 : 0; if (const char* e = std::getenv("PSX_DYNRES_MIN")) { int v = 0; if (psx_ir_parse(e, &v) && v != PSX_IR_DISPLAY) g_video_dynres_min_env = v; else std::fprintf(stdout, "psxrecomp: PSX_DYNRES_MIN=%s not understood " "(native, 720p, 1080p, 1440p, 4k, 5k, 8k, or lines)\n", e); } { /* Per-backend ceiling. OpenGL allocates its hr surface at context init * and clamps there to the driver's texture limits and a memory budget * (gl_scale_limits.h), so only its compile-time bound applies here. * Software keeps a CPU mirror of 1 MiB*S^2 and Vulkan accepts 1..4. */ const int ceiling = (g_video_renderer == 1) ? GL_MAX_INTERNAL_SCALE : SW_MAX_INTERNAL_SCALE; if (g_video_scale > ceiling) { std::fprintf(stdout, "psxrecomp: supersampling %dx clamped to %dx " "(%s maximum)\n", g_video_scale, ceiling, g_video_renderer == 2 ? "vulkan" : "software"); g_video_scale = ceiling; } } if (net_cfg.enabled && s_netplay_gl_present && gl_renderer_cpu_auth_dual()) { gr_set_scale(g_video_scale); if (g_video_scale > 1) { std::fprintf(stdout, "psxrecomp: netplay GL present supersampling %dx " "(SW authority stays 1x)\n", g_video_scale); } } else if (net_cfg.enabled || s_netplay_sim_native_scale) { gr_set_scale(1); if (g_video_scale > 1) { std::fprintf(stdout, "psxrecomp: netplay sim supersampling clamped to 1x " "(settings %dx kept for offline)\n", g_video_scale); } } else { gr_set_scale(g_video_scale); } /* The scale we asked the backend for. Read it before the reflection below: * the GL backend's gr_scale() reports its REAL internal scale, which is * still 0/1 until the GL context comes up later, so g_video_scale does not * hold the effective factor at this point. */ const int requested_scale = g_video_scale; g_video_requested_scale = requested_scale; g_video_scale_applies = !(net_cfg.enabled || s_netplay_sim_native_scale) || (net_cfg.enabled && s_netplay_gl_present && gl_renderer_cpu_auth_dual()); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ gr_set_texture_filter(g_mod_texfilter < 0 ? g_video_texfilter : g_mod_texfilter); /* Sub-pixel vertex precision + perspective-correct UVs. Both default off; * with both off every setter below leaves the tracking caches disabled and * the draw path is the faithful integer one, unchanged. */ /* Env overrides (debug/validation path, like PSX_BIOS_HLE): arm the * corrections from process start so free-running (headless) boots can be * measured from the first projected vertex — a TCP toggle always arrives * after the interesting window. '0' = off, anything else = on. They win * over the PGXP mod too, so an A/B run can switch it off. */ /* The [video] baseline plus the psx.enhancement.pgxp request this * session's activation recorded (start_mod_session ran above, for the * first boot and for a rematch); psx_pgxp_session_arm takes that request, * so it cannot outlive this session. Applying the baseline alone here * used to switch an enabled mod straight back off. The g_video_* values * stay the player's settings: the mod never leaks into a launcher seed. * With [video] pgxp_mod_only the mod is the one switch (pgxp_session.h). */ PSXPgxpSessionConfig pgxp_cfg{}; pgxp_cfg.video_geometry = g_video_geometry_correction; pgxp_cfg.video_texture = g_video_perspective_texturing; pgxp_cfg.video_cpu_mode = g_video_pgxp_cpu_mode; pgxp_cfg.tolerance = g_video_pgxp_tolerance; pgxp_cfg.position_fallback = g_video_pgxp_position_fallback; pgxp_cfg.preserve_projection = g_video_pgxp_preserve_projection; pgxp_cfg.mod_only = g_video_pgxp_mod_only; /* Precise culling changes guest NCLIP results from host-only shadows; a * netplay plan may carry the PGXP mod, so the arming holds it off. */ pgxp_cfg.netplay = net_cfg.enabled ? 1 : 0; PSXPgxpSessionInputs pgxp_in{}; const PSXPgxpSessionArm pgxp_arm = psx_pgxp_session_arm(&pgxp_cfg, &pgxp_in); if (pgxp_in.env_geometry >= 0) g_video_geometry_correction = pgxp_in.env_geometry; if (pgxp_in.env_texture >= 0) g_video_perspective_texturing = pgxp_in.env_texture; if (pgxp_in.env_cpu_mode >= 0) g_video_pgxp_cpu_mode = pgxp_in.env_cpu_mode; /* [video] texture_window_batching A/B (same image, fewer GL draws). */ if (const char* e = std::getenv("PSX_GL_TEXWIN_BATCH")) gl_renderer_set_texture_window_batching((*e && *e != '0') ? 1 : 0); /* [video] render_thread A/B; PSX_RENDER_THREAD_FRAMES bounds frames in * flight (default 2). */ if (const char* e = std::getenv("PSX_RENDER_THREAD")) g_render_thread = (*e && *e != '0') ? 1 : 0; if (const char* e = std::getenv("PSX_RENDER_THREAD_FRAMES")) g_render_thread_frames = std::atoi(e) > 0 ? std::atoi(e) : 2; /* Scanlines: env override wins over config, same as the corrections above, * so a headless/free-run boot can be captured with the effect armed from the * first present. PSX_SCANLINES=0/1; PSX_SCANLINE_STRENGTH=0..1. Pushed to the * GL renderer, which holds the state and applies it per-draw. */ if (const char* e = std::getenv("PSX_SCANLINES")) g_video_scanlines = (*e && *e != '0'); if (const char* e = std::getenv("PSX_SCANLINE_STRENGTH")) { float s = (float)atof(e); if (s >= 0.f && s <= 1.f) g_video_scanline_strength = s; } gl_renderer_set_scanlines(g_video_scanlines ? 1 : 0, g_video_scanline_strength); if (pgxp_arm.geometry || pgxp_arm.texture) { std::fprintf(stdout, "psxrecomp: geometry correction %s, perspective texturing %s%s%s%s\n", pgxp_arm.geometry ? "on" : "off", pgxp_arm.texture ? "on" : "off", pgxp_arm.culling ? ", precise culling" : "", pgxp_in.mod_enabled ? " (PGXP mod)" : "", (pgxp_arm.geometry && requested_scale < 2) ? " (needs [video] supersampling >= 2 to be visible)" : ""); } /* 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); vk_renderer_set_display_aspect(g_video_aspect_num, g_video_aspect_den); if (g_video_aspect_num * 3 != g_video_aspect_den * 4) { /* 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; 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" : ""); } /* 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 (const int ir = effective_internal_resolution(); ir != PSX_IR_UNSET) { const char* lbl = psx_ir_label_for(ir); char custom[32]; if (!lbl) { std::snprintf(custom, sizeof custom, "%d lines", ir); lbl = custom; } if (ir == PSX_IR_DISPLAY && requested_scale <= 1) std::fprintf(stdout, "psxrecomp: internal resolution %s (reference %d lines): " "measured when the game window opens\n", lbl, g_video_ref_lines); else std::fprintf(stdout, "psxrecomp: internal resolution %s (reference %d lines): " "%dx requested\n", lbl, g_video_ref_lines, requested_scale); } if (requested_scale > 1 || g_video_texfilter) std::fprintf(stdout, "psxrecomp: supersampling %dx (antialiasing %s, texture filter %s)\n", requested_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. */ for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { set_player_device(g_players[s], player_device[s], player_mode[s]); g_players[s].deadzone = player_deadzone[s]; } /* Multitap stays OFF through BIOS boot: SCPH-1070 on port 1 breaks shell / * LoadExe pad bring-up for titles that expect a lone digital pad. Offline * 3+ player builds arm it after game entry (see vblank path); netplay arms * it from psx_netplay when slot_count >= 3. */ if (net_cfg.enabled) { /* Netplay BIOS boot must not mirror per-peer DualShock vs keyboard — * that forked dig0 snap sio/pads (baseline ext) on rematch. Session * start also re-canonicalizes; seed here so early boot is identical. */ int np_slots = net_cfg.slot_count; if (np_slots < 2) np_slots = 2; if (np_slots > PSX_MAX_PLAYERS) np_slots = PSX_MAX_PLAYERS; sio_netplay_canonicalize_session_pads(np_slots); } else { for (int s = 0; s < PSX_MAX_PLAYERS; 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); const int mode = effective_player_mode_for_sio(g_players[s], s); sio_set_pad_connected(s, (g_players[s].kind != 0 || dev_p1) ? 1 : 0); sio_set_pad_analog(s, pad_mode_boot_analog(mode), 0x80, 0x80, 0x80, 0x80); sio_set_pad_config_capable(s, mode != PSXRecompV4::PAD_MODE_DIGITAL); } } /* 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()); /* A disc was requested but nothing mounted. cdrom_init() is non-fatal here * (BIOS-only targets run with an empty drive on purpose), so without this * check the game would boot into an empty drive and render NOTHING -- the * "black screen on a .cue that works as a .bin" symptom. The earlier * validate_disc_for_launch() pass cannot catch it: identify_disc() reads * the data track FILE directly, so a cue whose sheet the mounting reader * rejects still shows a green "Disc verified" badge. Report the actual * failure instead of leaving the player staring at black. */ if (!disc_path_str.empty() && !cdrom_has_disc()) { const PSXRecompV4::DiscPathResolution r = PSXRecompV4::resolve_disc_path(disc_path_str); std::string detail = "The disc image was found and verified, but the CD-ROM drive could " "not mount it, so the game would boot with an empty drive.\n\n" "Selected:\n" + disc_path_str; if (r.mount != r.picked) detail += "\nMounted as:\n" + r.mount.string(); if (!r.note.empty()) detail += "\n\n" + r.note; detail += "\n\nIf this is a .cue, check that every FILE line it names " "exists next to it; selecting the .bin directly also works."; launcher_warning("Disc Could Not Be Mounted", detail); return 1; } /* Register the roster so the mounted image can still be changed once the * game runs (debug server `disc_select`). Take the current index from what * actually mounted rather than the requested one: a --disc override can * mount an image the persisted index does not name. */ if (game_discs.size() > 1) { std::vector roster_storage; std::vector roster; roster_storage.reserve(game_discs.size()); roster.reserve(game_discs.size()); for (const auto& d : game_discs) roster_storage.push_back(normalize_disc_path_for_launch(d).string()); for (const auto& s : roster_storage) roster.push_back(s.c_str()); const int mounted = roster_index_for_disc(game_discs, disc_path_str); cdrom_disc_roster_set(roster.data(), (int)roster.size(), mounted >= 0 ? mounted + 1 : selected_disc_index); /* --disc names a disc of the set, but the earlier index derivation is * skipped for an override, so selected_disc_index kept its default of * 1. That put the savestate disc token at _disc1 whatever was mounted, * which is the mix-up savestate_set_disc_scope() exists to prevent. */ if (mounted >= 0) selected_disc_index = mounted + 1; } 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); } /* A PAL console's BIOS starts the GPU in PAL; any other disc (or none) * starts NTSC. The game's own GP1(08h) writes decide the rate from then * on: VBlank, Timer 1 HBlank and host pacing all follow * psx_video_timing. Re-derived on every session reboot (disc swap). */ int power_on_pal = 0; 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. */ #if defined(RECOMP_LAUNCHER) const std::string& expected_serial = g_lnch_expected_serial; const uint32_t expected_crc = g_lnch_expected_crc; const bool has_crc = g_lnch_has_crc; #else const std::string& expected_serial = game_id; const uint32_t expected_crc = game_disc_crc; const bool has_crc = game_has_disc_crc; #endif const auto ident = PSXRecompV4::identify_disc( disc_path_str, expected_serial, expected_crc, has_crc, /*compute_crc*/false); if (ident.region == "PAL") { cdrom_set_disc_scex("SCEE"); power_on_pal = 1; } 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()); } gpu_set_power_on_video_mode(power_on_pal); sync_guest_cadence_to_video_standard(); /* 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); /* Executable/overlay patches from enabled mods, applied once the guard is * armed so a patched image is never mistaken for a divergent one. */ mod_runtime_enable_disc_patches(); { 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; if (g_mod_disc_speed_divisor >= 0) divisor = g_mod_disc_speed_divisor; /* 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 (g_mod_disc_instant_rate > 0) cdrom_set_instant_rate(g_mod_disc_instant_rate); else if (instant_rate > 0) cdrom_set_instant_rate(instant_rate); if (divisor != 1) std::fprintf(stdout, "psxrecomp: disc speed divisor=%d (applied post-BIOS, " "instant budget %d/frame)\n", divisor, 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); /* Headless still runs the SPU as a guest-cycle device. Register and prime * the pump before the frontend split so deterministic gates exercise the * same sample-deadline path as an operator run. Without the cycle-zero * prime, the first deadline service establishes a shifted epoch and the * headless smoke cannot detect the resulting audio/cutscene regression. */ #ifndef PSX_SDL_NO_AUDIO if (g_headless) { audio_trace_init(); psx_set_midframe_audio_pump(sdl_audio_pump_midframe); sdl_audio_pump_midframe(); } #endif if (g_headless) { if (g_headless_opengl) { /* VIDEO only: no audio device, game-controller enumeration or host * input sampling. Keep sdl_window null so adaptive sizing and hotkeys * cannot use this invisible validation surface as a player window. */ if (SDL_Init(SDL_INIT_VIDEO) != 0) { std::fprintf(stderr, "psxrecomp: hidden OpenGL SDL init failed: %s\n", SDL_GetError()); return 1; } configure_core_gl_context_attributes(); s_headless_gl_window = SDL_CreateWindow("psxrecomp hidden OpenGL validation", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 640, 480, SDL_WINDOW_HIDDEN | SDL_WINDOW_OPENGL); if (!s_headless_gl_window) { std::fprintf(stderr, "psxrecomp: hidden OpenGL window failed: %s\n", SDL_GetError()); SDL_Quit(); return 1; } gl_renderer_set_swap_interval(0); g_gl_active = gl_renderer_init_context(s_headless_gl_window) != 0; if (!g_gl_active || gr_backend() != GR_BACKEND_OPENGL || !(SDL_GetWindowFlags(s_headless_gl_window) & SDL_WINDOW_HIDDEN)) { std::fprintf(stderr, "psxrecomp: hidden OpenGL validation unavailable; refusing fallback\n"); if (g_gl_active) gl_renderer_shutdown(); SDL_DestroyWindow(s_headless_gl_window); s_headless_gl_window = nullptr; SDL_Quit(); return 1; } if (!netplay_cpu_auth_gpu()) g_video_scale = gr_scale(); gl_renderer_set_interpolation(0, 0, 0, 59.94, 0); latency_ring_set_backend("opengl"); latency_ring_set_present_mode(0); std::fprintf(stdout, "psxrecomp: hidden OpenGL headless enabled (backend=opengl hidden=1 audio=0 controllers=0)\n"); } else { 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"); /* SDL3 aliases the SDL2-era PS5 rumble hint to enhanced reports. Enabling * it also preserves DualSense rumble on the explicit SDL2 fallback. */ SDL_SetHintWithPriority(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1", SDL_HINT_OVERRIDE); /* ...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)) { /* Per-monitor DPI awareness, BEFORE any SDL_Init. * * Without it Windows virtualises everything this process sees: on a * 7680x4320 panel at 400% scaling SDL_GetDisplayUsableBounds reports * 1920x1032, so the window is clamped to roughly 1376 LOGICAL pixels and * opens as a small box, while the desktop compositor then upscales it. * The internal render resolution is unaffected -- which is the trap: the * game renders at supersampling 16 and the result is thrown away scaling * a 1376-wide window up to an 8K display. * * permonitorv2 makes SDL report physical pixels, so the window sizes * against the real panel and the drawable matches it 1:1. */ #ifdef SDL_HINT_WINDOWS_DPI_AWARENESS SDL_SetHint(SDL_HINT_WINDOWS_DPI_AWARENESS, "permonitorv2"); #endif #ifdef SDL_HINT_WINDOWS_DPI_SCALING SDL_SetHint(SDL_HINT_WINDOWS_DPI_SCALING, "0"); #endif 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)); else controller_deadzone = kDefaultDeadzoneRaw; for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { if (player_deadzone[s] >= 0) g_players[s].deadzone = std::max(0, std::min(32767, player_deadzone[s])); else g_players[s].deadzone = controller_deadzone; } controller_deadzone = g_players[0].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) { PsxSdlAudioSpec want = {}; PsxSdlAudioSpec have = {}; want.freq = g_audio_freq; want.format = AUDIO_S16SYS; want.channels = 2; want.samples = 1024; const bool legacy = audio_legacy_mode(); want.allow_frequency_change = legacy ? 0 : 1; if (!legacy) want.callback = sdl_drc_callback; /* pull model: bridge resamples + DRC */ sdl_audio_device = psx_sdl_audio_open(&want, &have); 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); (void)psx_sdl_audio_resume(sdl_audio_device); } } /* Always register: SPU advance is guest-cycle budgeted and must not * depend on a successful host open (Win↔Linux aux/spu fork). Routed * through the gated wrapper so turbo mute/sink still apply. */ psx_set_midframe_audio_pump(sdl_audio_pump_midframe); /* Establish the cycle-zero audio epoch before the first device deadline. * Otherwise the first D-1 quiet-prefix service becomes the epoch and shifts * every nominal 768-cycle sample boundary. */ sdl_audio_pump_midframe(); #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; /* Opt-in high-pixel-density drawable: only when the player asked for * more than native resolution, so the default window, OSD and bezel * maths stay exactly as they were. */ g_video_hidpi_window = g_video_scale_applies && (g_video_requested_scale > 1 || effective_internal_resolution() == PSX_IR_DISPLAY); if (g_video_hidpi_window) win_flags |= PSX_SDL_WINDOW_HIGH_DENSITY; } if (g_video_renderer == 2) win_flags |= SDL_WINDOW_VULKAN; if (g_hidden_window) { win_flags &= ~(Uint32)SDL_WINDOW_SHOWN; win_flags |= SDL_WINDOW_HIDDEN; g_fullscreen = 0; } /* 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. */ /* Open at the user-chosen window size (default: see window_size.h) * instead of the old hardcoded 640x480. 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; } psx_apply_window_icon(sdl_window, argv[0]); s_window_fullscreen = {}; if (psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, g_fullscreen) != 0) std::fprintf(stderr, "Fullscreen setup failed: %s\n", SDL_GetError()); /* No maximise: the default size (window_size.h) leaves a third of the * usable height free, so the client plus title bar and borders fits on * screen without asking the window manager to fill the work area. */ /* Host refresh: if the window's current panel matches the guest cadence, * record it so driver vsync can own cadence. Re-probed while running so * mixed-refresh multi-monitor moves cannot leave this latched at startup. */ refresh_host_display_cadence(1, 1); /* OpenGL backend: create the GL context now. On failure, relabel the * facade back to software (rasterization already runs through software in * this phase) and fall through to the SDL_Renderer present path below. */ if (g_video_renderer == 1) { /* Match display: the monitor's pixel height, now that the window says * which monitor. glb_set_scale only records the request; the hr * surface is allocated at context init below. */ if (effective_internal_resolution() == PSX_IR_DISPLAY && g_video_scale_applies) { const int dh = psx_sdl_display_pixel_height(sdl_window); const int s = psx_resolve_internal_scale(PSX_IR_DISPLAY, g_video_ref_lines, dh, GL_MAX_INTERNAL_SCALE); gr_set_scale(s); g_video_requested_scale = s; std::fprintf(stdout, "psxrecomp: internal resolution Match display: " "%d px -> %dx requested\n", dh, s); } gl_renderer_set_swap_interval(present_effective_swap_interval()); /* applied at context init */ /* Dynamic resolution: the surfaces are allocated at the scale above * (the ceiling) and the level steps under it (dynres_setup). */ gl_renderer_set_dynamic_resolution( (dynres_requested() && g_video_scale_applies) ? 1 : 0); g_gl_active = (gl_renderer_init_context(sdl_window) != 0); dynres_setup(); /* Bezel artwork (Mods): load after the GL context exists. */ if (!g_bezel_path.empty() && g_gl_active) { /* A relative artwork path names a file shipped beside the * executable (e.g. a title's staged bezels/), so anchor it on the * exe directory -- never cwd (see exe_dir_from_argv). An absolute * path, such as the builtin bezel's owner-selected resource, is * used unchanged. */ const std::filesystem::path bp = PSXRecompV4::host_resolve( exe_dir_from_argv(argv[0]), std::filesystem::path(g_bezel_path)); std::vector file; /* fs::path stream: the exe directory is a wide Windows path, which * a narrow fopen() of bp.string() cannot open when it is non-ASCII. */ std::ifstream bf(bp, std::ios::binary | std::ios::ate); if (bf.is_open()) { const std::streamoff len = bf.tellg(); if (len > 0) { file.resize((size_t)len); bf.seekg(0, std::ios::beg); if (!bf.read(reinterpret_cast(file.data()), (std::streamsize)file.size())) file.clear(); } } int bw = 0, bh = 0, bc = 0; unsigned char *px = file.empty() ? nullptr : stbi_load_from_memory(file.data(), (int)file.size(), &bw, &bh, &bc, 4); if (px) { gl_renderer_set_bezel(px, bw, bh); stbi_image_free(px); std::fprintf(stdout, "psxrecomp: bezel artwork %dx%d from %s\n", bw, bh, bp.string().c_str()); } else { std::fprintf(stdout, "psxrecomp: bezel artwork not loaded: %s\n", bp.string().c_str()); } } if (!g_gl_active) { gr_set_backend(GR_BACKEND_SOFTWARE); gl_renderer_set_cpu_auth_dual(0); g_gl_fbo_present = 0; s_netplay_gl_present = 0; if (net_cfg.enabled) { gr_set_scale(1); s_netplay_sim_native_scale = 1; } } else if (net_cfg.enabled || s_netplay_gl_present) { /* Dual-raster: FBO present at settings SSAA; SW@1× authority. * Scale was applied via s_req_scale before init_gpu_raster. */ gl_renderer_set_cpu_auth_dual(1); g_gl_fbo_present = 1; s_netplay_gl_present = 1; s_netplay_sim_native_scale = 1; } /* The GL backend establishes its real internal scale HERE (raster init), * which is AFTER the earlier offline `g_video_scale = gr_scale()` sync. * Re-sync offline so staging matches. Netplay keeps g_video_scale as * the settings preference (equals gr_scale() under dual-raster). */ if (!netplay_cpu_auth_gpu()) g_video_scale = gr_scale(); gl_renderer_set_interpolation(g_frame_interpolation, g_host_refresh_hz, (double)g_frame_interpolation_fps, g_frame_period_ms > 0.0 ? 1000.0 / g_frame_period_ms : 59.94, g_frame_interpolation_blend); gl_renderer_set_interpolation_source(g_frame_interpolation_source); } /* 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(present_effective_swap_interval()); g_vk_active = (vk_renderer_init_context(sdl_window) != 0); if (!g_vk_active) gr_set_backend(GR_BACKEND_SOFTWARE); if (!netplay_cpu_auth_gpu()) g_video_scale = gr_scale(); } latency_ring_set_backend(g_vk_active ? "vulkan" : g_gl_active ? "opengl" : "software"); latency_ring_set_present_mode(present_effective_swap_interval()); /* 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 /* PRESENTVSYNC only when driver vsync owns cadence; otherwise the * wall-clock pacer holds 59.94Hz (may tear). */ Uint32 rflags = SDL_RENDERER_ACCELERATED | (present_effective_swap_interval() != 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. Netplay CPU-auth: always 1× (sim has no hi-res mirror). */ { const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; g_logical_w = 480 * g_video_aspect_num * tex_scale / g_video_aspect_den; SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * tex_scale); } } /* Staging buffer + backing texture preserve the 576-row interlaced PAL * canvas, times the supersampling factor. Netplay: 1×. */ { /* The CPU present path only runs above 1x on the software backend * (<= SW_MAX_INTERNAL_SCALE); under OpenGL it is native (see * active_scale), so an 8K GL scale never sizes this buffer by S^2. */ int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; if (tex_scale > SW_MAX_INTERNAL_SCALE) tex_scale = SW_MAX_INTERNAL_SCALE; if (!ensure_sdl_pixel_buf_capacity( (size_t)640 * tex_scale * PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale)) { std::fprintf(stderr, "failed to allocate %dx staging buffer\n", tex_scale); return 1; } } if (!g_gl_active && !g_vk_active) { const int tex_scale = netplay_cpu_auth_gpu() ? 1 : g_video_scale; if (ensure_sdl_present_texture( 640 * tex_scale, (int)PSX_DISPLAY_PRESENT_MAX_HEIGHT * tex_scale) != 0) { std::fprintf(stderr, "SDL_CreateTexture failed: %s\n", SDL_GetError()); return 1; } } log_present_cadence(); } /* 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) { /* Refuse mismatched / incomplete mounts before opening transport. * Launcher already gates create/join; this covers CLI --netplay and * any path that skipped the UI verify. */ { const std::filesystem::path disc_check = resolved_disc; if (!disc_check.empty()) { #if defined(RECOMP_LAUNCHER) const std::string expect_serial = expected_serial_for_disc(disc_check, g_lnch_expected_serial); const uint32_t expect_crc = g_lnch_expected_crc; const bool has_crc = g_lnch_has_crc; #else const std::string expect_serial = expected_serial_for_disc(disc_check, game_id); const uint32_t expect_crc = game_disc_crc; const bool has_crc = game_has_disc_crc; #endif const PSXRecompV4::NetplayDiscExpect np_expect = netplay_expect_for_disc(disc_check); PSXRecompV4::DiscIdentity nid = PSXRecompV4::identify_disc( disc_check, expect_serial, expect_crc, has_crc, /*compute_crc*/ false, &np_expect); g_session_disc_fp = nid.disc_fp; g_session_netplay_disc_ok = nid.netplay_ok && !nid.disc_fp.empty(); psx_lobby_set_disc_fp(nid.disc_fp.c_str()); if (!g_session_netplay_disc_ok) { std::fprintf(stderr, "psxrecomp: netplay refused — disc mount not valid for " "online (%s)\n", nid.netplay_detail.empty() ? "TOC fingerprint missing or policy failed" : nid.netplay_detail.c_str()); return 1; } } else if (!g_session_netplay_disc_ok || g_session_disc_fp.empty()) { std::fprintf(stderr, "psxrecomp: netplay refused — no verified disc TOC " "fingerprint (mount the supported .cue dump)\n"); return 1; } } /* 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 (-1): always prefer dashboard P1 ("PLAYER N / NETPLAY") — that * pad is published as lobby local_slot. Seat-card P2/P3… are only used * when P1 is empty (legacy same-PC layout). Else sole assigned / P1. */ if (net_cfg.input_player < 0 || net_cfg.input_player >= PSX_MAX_PLAYERS) { const int seat = net_cfg.local_slot; int sole = -1, n_assigned = 0; for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { if (g_players[i].kind == 0) continue; ++n_assigned; sole = i; } if (g_players[0].kind != 0) net_cfg.input_player = 0; else if (seat >= 0 && seat < PSX_MAX_PLAYERS && g_players[seat].kind != 0) net_cfg.input_player = seat; else if (n_assigned == 1) net_cfg.input_player = sole; else net_cfg.input_player = 0; } if (net_cfg.slot_count < 2) net_cfg.slot_count = game_players >= 2 ? game_players : 2; if (net_cfg.slot_count > PSX_MAX_PLAYERS) net_cfg.slot_count = PSX_MAX_PLAYERS; s_netplay_present_sim_watermark = 0; /* §74: sim restarts per session */ // LAN has no host-published caps. With content negotiation, require // the full portable SHA-256 to agree before either side executes even // its first guest frame. Off: no content gate (vanilla sessions). net_cfg.content_fingerprint[0] = '\0'; if (g_netplay_content_negotiation && (net_cfg.transport == 2 || !psx_lobby_match_caps() || !psx_lobby_match_caps()->valid)) std::snprintf(net_cfg.content_fingerprint, sizeof(net_cfg.content_fingerprint), "%s", PSXRecompV4::mod_runtime_session_plan_fp().c_str()); const int nrc = psx_netplay_start(&net_cfg); if (nrc != 0) { const char* const why = netplay_start_failure(nrc, net_cfg); if (!g_netplay_from_lobby) return 1; /* A match from the launcher returns to the room, where the * status line shows the sentence. No session is open and the * guest is not entered: see the scheduler. */ netplay_soft_exit("netplay_start_failed"); g_netplay_exit_reason_text = why; } else { apply_netplay_local_viewport_aspect(net_cfg.enabled); std::printf("psxrecomp: netplay transport=%s slot=%d input_player=%d delay=%d " "force_turn=%d bind=%s peer=%s session=%u\n", psx_netplay_transport_name(), net_cfg.local_slot, net_cfg.input_player, net_cfg.input_delay, net_cfg.force_turn ? 1 : 0, net_cfg.bind_hostport, (std::strcmp(psx_netplay_transport_name(), "ice") == 0) ? "(ice)" : 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'); /* The two axes (kernel-call HLE, boot-skip) have DIFFERENT per-image * requirements, so they are decided in ONE pure place — * psx_bios_hle_plan(), runtime/src/bios_hle_plan.c. Conflating them * broke boot-skip on OpenBIOS: call-HLE needs deliver_event_ret, the * boot-skip needs only shell_entry_phys, so refusing the former must * not silently cancel the latter. */ PsxBiosHleRequest req; req.bios_hle = bios_hle ? 1 : 0; req.keep_intro = bios_hle_keep_intro ? 1 : 0; req.fast_boot = fast_boot ? 1 : 0; req.have_deliver_event_ret = (psx_bios_image.deliver_event_ret != 0); req.have_shell_entry = (psx_bios_image.shell_entry_phys != 0); req.have_game_entry = (game_entry_pc != 0); const PsxBiosHlePlan plan = psx_bios_hle_plan(req); /* Call-HLE is a per-image capability, not just a preference: an image * exporting no DeliverEvent anchor (OpenBIOS until validated) declares * that axis STRUCTURALLY UNAVAILABLE — servicing B0 events with * mismatched semantics wedges the guest in event waits. */ if (plan.call_hle_denied) { std::fprintf(stdout, "psxrecomp: bios_hle kernel-call tier unavailable on %s (no " "DeliverEvent anchor); kernel calls stay LLE\n", psx_bios_image.image_id); } if (plan.boot_skip_denied) { std::fprintf(stdout, "psxrecomp: BIOS boot-skip unavailable on %s (no shell entry " "anchor); playing the real intro\n", psx_bios_image.image_id); } psx_bios_hle_configure(plan.call_hle, plan.boot_skip); std::fprintf(stdout, "psxrecomp: bios_backend=%s bios_boot=%s image=%s\n", psx_bios_hle_backend_name(), psx_bios_hle_boot_skip_enabled() ? "HLE (shell skipped)" : "LLE (real intro)", psx_bios_image.image_id ? psx_bios_image.image_id : "?"); } /* R3000A reset state. */ cpu.pc = 0xBFC00000u; cpu.cop0[12] = 0x00400000u; /* SR: BEV=1 (boot exception vectors) */ /* Host hotkeys: config.ini [KeyMap] next to the exe (VolumeUp/Down). * Prefer exe-dir file; if missing, fall back to cwd config.ini so a * launcher edit made before config_path was wired still applies. */ { namespace fs = std::filesystem; const fs::path exe_cfg = exe_dir_from_argv(argv[0]) / "config.ini"; std::error_code ec; if (fs::is_regular_file(exe_cfg, ec)) { host_keymap_load(exe_cfg.string().c_str()); } else if (fs::is_regular_file(fs::path("config.ini"), ec)) { host_keymap_load("config.ini"); } else { host_keymap_load(nullptr); } } /* User save states: slots live under * //, keyed by entry_pc + boot_state integrity * (codegen hash/abi/ver). Memory cards stay in the memcard root. */ if (game_entry_pc != 0) { const char* bios_token = psx_bios_image.image_bundled ? "openbios" : "scph1001"; uint32_t openbios_ws = 0; if (const PsxBiosBackend* bundled = psx_bios_bundled()) { if (bundled->image) openbios_ws = bundled->image->image_wordsum; } /* Multi-disc sets tag savestates with the disc, inside the existing * BIOS directory. A savestate is whole-machine state, so one taken on * disc 2 and restored while disc 1 is mounted resumes a guest that * believes it is still reading disc 2 -- and nothing in the slot list * would say so, because every disc of a set shares one entry_pc, which * is the key the slot files already use. Single-disc titles pass 0 and * keep their existing filenames untouched. */ savestate_set_disc_scope(game_discs.size() > 1 ? selected_disc_index : 0); savestate_configure(memcard_dir.string().c_str(), memory_get_bios_checksum(), game_entry_pc, bios_token, openbios_ws); psx_rewind_set_enabled(g_rewind_enabled); psx_rewind_set_depth((uint32_t)g_rewind_depth); psx_rewind_set_interval((uint32_t)g_rewind_interval); psx_rewind_configure(memory_get_bios_checksum(), game_entry_pc); /* Headless / agent load: PSX_LOAD_SLOT=N stages slot 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 the save-state menu after netplay starts)\n", slot); } else if (savestate_request_load(slot)) { std::fprintf(stdout, "psxrecomp: PSX_LOAD_SLOT=%d staged\n", slot); } } } } /* Netplay: refresh RB bios/entry after savestate_configure (start() ran * earlier with zeros); guest also sandboxes .pst/.mcd under saves/netplay/. */ psx_netplay_bind_guest_saves(); /* Let memory subsystem see SR for cache-isolation checks. */ memory_set_sr_ptr(&cpu.cop0[12]); /* Wire the CAUSE.IP2 mirror. IP2 is combinational on real hardware and has * to track (I_STAT & I_MASK) through raises, acks and mask writes; this * hands interrupts.c the one location it is allowed to maintain. Also * performs the power-on recompute. */ psx_irq_set_cause_ptr(&cpu.cop0[13]); /* Wire debug server to CPU state for register queries. */ debug_server_set_cpu(&cpu); /* Master digests / FRAME_COMMIT for netplay hash_confirm watermark. */ psx_netplay_bind_cpu(&cpu); /* Solo rollback resim self-check (PSX_RB_SELFCHECK=1, offline only). */ psx_selfcheck_init(&cpu, memory_get_bios_checksum(), game_entry_pc); /* 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()) { SDL_PumpEvents(); SDL_FlushEvent(SDL_QUIT); 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(); /* Idle-skip advances guest cycles without CD/device aging in lockstep — * force off so both peers share the same CD FSM (dig_c folds CD). */ { extern int g_idle_skip_enabled; if (g_idle_skip_enabled != 0) { g_idle_skip_enabled = 0; std::printf("psxrecomp: netplay — idle_skip forced off\n"); std::fflush(stdout); } } g_auto_skip_fmv = 0; std::printf("psxrecomp: netplay lockstep armed (sim_tick=%u, vsync off)\n", (unsigned)psx_netplay_sim_tick()); std::fflush(stdout); /* Rematch: dump sticky guards once before guest entry (soft-exit can * leave device_service / call_unit elevated if a path skipped the * longjmp fixup). */ if (rematch_session) { extern int psx_in_device_service; extern int g_call_unit_depth; char stall[96]; uint32_t sim = 0; int lead = 0; stall[0] = '\0'; psx_netplay_admit_wait_info(stall, sizeof(stall), &sim, &lead); std::fprintf(stderr, "psxrecomp: rematch enter-guest sim=%u stall=%s lead=%d " "device_svc=%d call_unit=%d cyc=%llu\n", (unsigned)sim, stall[0] ? stall : "-", lead, psx_in_device_service, g_call_unit_depth, (unsigned long long)psx_get_cycle_count()); std::fflush(stderr); } } /* A match whose start failed has no session and must not run the guest. * Go back to the room; do not boot the game offline. */ if (psx_return_to_lobby_requested() && g_netplay_from_lobby) goto soft_return_lobby; 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_DestroyWindow(s_headless_gl_window); s_headless_gl_window = nullptr; SDL_Quit(); return 0; soft_return_lobby: /* Netplay soft-exit: tear down the match, keep the lobby seat, and reopen * the launcher on the LOBBY room so every peer can rematch. */ teardown_game_session_keep_lobby(); PSXRecompV4::mod_runtime_end_netplay(); #if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT) ae_np_prepare_lobby_rematch(); /* Say why the match ended on the launcher's status line; the * room and browser views both show last_error. Cleared when the player * ended the match themselves. */ psx_lobby_set_last_error(g_netplay_exit_reason_text); g_netplay_exit_reason_text = nullptr; { std::string assets_dir_str = exe_dir_from_argv(argv[0]).string(); std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) + " - Launcher"; std::string rui_initial_disc = disc_path_str; /* A human-hosted room survives the match and is where a rematch * happens; an automatch room is the server's and cannot. Leave it * here, and reopen the launcher on the browser instead. */ const int rui_resume_room = ae_np_leave_automatch_room_after_match() ? 0 : 1; ae_rui_set_sidecar_paths(argv[0]); g_lnch_expected_serial = game_id; g_lnch_expected_crc = game_disc_crc; g_lnch_has_crc = game_has_disc_crc; g_lnch_argv0 = argv[0]; g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name; psx_lobby_set_max_slots(game_players); RecompLauncherCSettings ls{}; ls.output_method = 2; ls.window_scale = std::max(1, std::min(4, g_video_win_w / 320)); ls.disc_index = selected_disc_index; ls.fullscreen = g_fullscreen; ls.ignore_aspect = 0; ls.linear_filter = (g_video_texfilter != 0) ? 1 : 0; ls.widescreen = (g_video_aspect_num == 16 && g_video_aspect_den == 9) ? 1 : 0; ls.widescreen_hud = ls.widescreen; ls.enable_audio = 1; ls.audio_freq = g_audio_freq; ls.volume = host_volume_get(); ls.window_width = g_video_win_w; ls.renderer = g_video_renderer; if (ls.renderer < 0 || ls.renderer > (vulkan_offered ? 2 : 1)) ls.renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; /* Internal resolution round trip, as at first boot. */ const int ir_preset_seeded = g_video_internal_res; const int ir_ss_seeded = psx_ir_launcher_seed_supersampling( kLauncherHasInternalResolution, g_video_internal_res, g_video_scale, g_video_ref_lines, psx_sdl_display_pixel_height(nullptr)); ls.supersampling = ir_ss_seeded; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) ls.internal_resolution = internal_resolution_for_launcher(); #endif #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) ls.dynamic_resolution = dynres_requested() != 0; ls.dynamic_resolution_min = dynres_min_value(); #endif ls.antialiasing = g_video_aa ? 1 : 0; ls.texture_filter = g_video_texfilter; ls.fmv_filter = cfg_fmv_filter_to_launcher(g_video_fmv_filter); ls.geometry_correction = g_video_geometry_correction ? 1 : 0; ls.perspective_texturing = g_video_perspective_texturing ? 1 : 0; ls.screen_kind = g_video_screen; #if defined(RECOMP_LAUNCHER_HAS_SCANLINES) ls.scanlines = g_video_scanlines ? 1 : 0; ls.scanline_strength_pct = (int)(g_video_scanline_strength * 100.0f + 0.5f); #endif ls.frame_interp = g_frame_interpolation ? 1 : 0; ls.frame_interp_fps = g_frame_interpolation_fps; ls.spu_hq = g_audio_spu_hq ? 1 : 0; ls.auto_skip_fmv = (skip_fmv_offered && g_auto_skip_fmv) ? 1 : 0; ls.turbo_loads = (turbo_loads_offered && g_turbo_loads_enabled) ? 1 : 0; ls.rewind_enabled = g_rewind_enabled; ls.rewind_depth = g_rewind_depth; ls.rewind_interval = g_rewind_interval; ls.assist_pad_bind[PSX_ASSIST_BIND_REWIND] = normalize_hotkey_pad_binding( g_hotkey_pad_rewind, PSX_HOTKEY_PAD_SELECT_R3); ls.assist_pad_bind[PSX_ASSIST_BIND_SAVE_STATE_MENU] = normalize_hotkey_pad_binding( g_hotkey_pad_save_state_menu, PSX_HOTKEY_PAD_SELECT_R1); ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD] = normalize_hotkey_pad_binding( g_hotkey_pad_fast_forward, PSX_HOTKEY_PAD_SELECT_L1); ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE] = normalize_hotkey_pad_binding(g_hotkey_pad_fast_forward_toggle, 0); ls.aspect_index = (g_video_aspect_num * 9 == g_video_aspect_den * 21) ? 2 : (g_video_aspect_num == 16 && g_video_aspect_den == 9) ? 1 : 0; 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::snprintf(ls.netplay_player_name, sizeof(ls.netplay_player_name), "%s", psx_lobby_display_name()); /* Soft-return shows the durable preference (bios.cfg), not the match * session BIOS that may have temporarily overridden boot. */ { std::filesystem::path preferred = read_cached_path(argv[0], "bios.cfg"); ls.bios_path[0] = '\0'; if (!preferred.empty()) { std::filesystem::path resolved = resolve_bios_path(preferred.string().c_str(), argv[0]); std::error_code ec; if (!resolved.empty() && std::filesystem::exists(resolved, ec)) { auto abs = std::filesystem::weakly_canonical(resolved, ec); if (ec) abs = PSXRecompV4::host_absolute(resolved, ec); std::snprintf(ls.bios_path, sizeof(ls.bios_path), "%s", abs.string().c_str()); } else { std::snprintf(ls.bios_path, sizeof(ls.bios_path), "%s", preferred.string().c_str()); } } } /* Memory-card slots: the same PERSONAL cards the first-boot launcher * shows — an explicit settings.toml path, else the / * cardN.mcd default the runtime derives. psx_netplay_shutdown has * already unbound the match-time netplay sandbox (guest mirror / * host guest_card2.mcd), so those files are never what the player's * launcher inspects. Left empty, the panel had nothing to inspect * and fell back to a placeholder block pattern that read as foreign * save data after a match; left 0, both slots re-armed as enabled. */ { std::string mc1 = memcard1_path.empty() ? (memcard_dir / "card1.mcd").string() : memcard1_path.string(); std::string mc2 = memcard2_path.empty() ? (memcard_dir / "card2.mcd").string() : memcard2_path.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] = memcard1_enabled ? 1 : -1; ls.memcard_enabled[1] = memcard2_enabled ? 1 : -1; #if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ENABLED) ls.multitap_enabled = multitap_enabled ? 1 : 0; #endif #if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) ls.multitap_analog = multitap_analog ? 1 : 0; g_lnch_multitap_analog = multitap_analog ? 1 : 0; #endif /* Preserve input devices across soft-return. A zeroed ls left every * player_src at None, so the rematch UI (and a subsequent writeback) * looked like netplay had wiped the pad assignment. */ { const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); for (int i = 0; i < n; ++i) { const std::string& d = player_device[i]; ls.player_src[i] = PSXRecompV4::launcher_source_from_device(d); ls.deadzone[i] = (player_deadzone[i] * 100 + 32767 / 2) / 32767; /* Verbatim, as in the first launcher entry above. */ ls.pad_mode[i] = player_mode[i]; ls.player_gamepad_guid[i][0] = '\0'; if (ls.player_src[i] == 2 && !d.empty() && d != "auto" && d != "gamepad" && d != "controller") { std::snprintf(ls.player_gamepad_guid[i], sizeof(ls.player_gamepad_guid[i]), "%s", d.c_str()); } } } const std::string rui_region = !game_region.empty() ? game_region : region_label_from_serial(game_id); std::vector 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()); /* Same borrowed-roster contract as the first-open launcher above. */ std::vector rui_disc_paths; std::vector rui_discs; if (game_discs.size() > 1) { rui_disc_paths.reserve(game_discs.size()); for (const auto& d : game_discs) rui_disc_paths.push_back( normalize_disc_path_for_launch(d).string()); rui_discs.reserve(rui_disc_paths.size()); for (size_t i = 0; i < rui_disc_paths.size(); ++i) rui_discs.push_back(RecompLauncherCDisc{ (int)i + 1, nullptr, rui_disc_paths[i].c_str()}); } RecompLauncherCGameInfo gi{}; ae_fill_psx_launcher_game_info( &gi, game_name.empty() ? nullptr : game_name.c_str(), rui_region.empty() ? nullptr : rui_region.c_str(), game_players, ws_offered, ws_ultrawide_offered, skip_fmv_offered, turbo_loads_offered, vulkan_offered, ctrl_lock_mode, ctrl_lock_device, ctrl_locked_mode[0], rui_lang_labels.empty() ? nullptr : rui_lang_labels.data(), (int)rui_lang_labels.size(), /*resume_netplay_room=*/rui_resume_room); gi.discs = rui_discs.empty() ? nullptr : rui_discs.data(); gi.num_discs = (int)rui_discs.size(); #if defined(PSX_HAS_SETUP_WIZARD) && defined(PSX_HAS_CODEGEN_SETUP_HOST) psx_game_codegen_setup_apply(&gi); g_lnch_can_regen = gi.prepare_with_progress != nullptr || psx_bios_registry_count == 0; { const bool module_ok = psx_bios_module_supported( exe_dir_from_argv(argv[0]).string().c_str()) != 0; gi.has_bios = (psx_bios_has_selectable() || g_lnch_can_regen || module_ok) ? 1 : 0; if (module_ok && !gi.prepare_with_progress) { gi.bios_prepare_with_progress = ae_bios_prepare; gi.bios_prepare_title = "Prepare this BIOS"; gi.bios_prepare_note = "This BIOS is not part of the build yet. Preparing it compiles " "a backend from your own image on this machine, once (about a " "minute); no disc or rebuild is needed. Or use OpenBIOS to play now."; gi.bios_prepare_button = "Prepare BIOS"; gi.bios_prepare_busy_status = "Compiling your BIOS for this build…"; gi.bios_prepare_success_status = "BIOS ready — continue to the launcher."; } } #endif char rui_out_disc[1024] = {0}; recomp_launcher_set_preserve_sdl(1); const 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)); if (rui_rc == 1) { /* User closed the launcher — leave the lobby and exit. */ if (g_lnch_netplay_callbacks.leave) (void)g_lnch_netplay_callbacks.leave(g_lnch_netplay_callbacks.ctx); else (void)psx_lobby_leave(); psx_lobby_disconnect(); SDL_Quit(); return 0; } #if defined(PSX_HAS_CODEGEN_SETUP_HOST) if (rui_rc == RECOMP_LAUNCHER_RESULT_RELAUNCH) { const char* disc_for_relaunch = rui_out_disc[0] ? rui_out_disc : (rui_initial_disc.empty() ? "" : rui_initial_disc.c_str()); std::fprintf(stdout, "psxrecomp: relaunch after generate/rebuild\n"); psx_game_codegen_relaunch_or_exit(disc_for_relaunch); SDL_Quit(); return 1; } #endif if (rui_rc == 0) { /* The rematch launcher can rebind keys just like the first-boot * one; the first-boot path re-reads keybinds.ini after the * launcher returns, and so must this one. */ psx_keybinds_init(argv[0]); host_volume_set(ls.volume); if (rui_out_disc[0]) { resolved_disc = normalize_disc_path_for_launch(rui_out_disc); disc_path_str = resolved_disc.string(); } if (ls.netplay_launch.enabled) { { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) { multitap_analog = caps->multitap_analog != 0; g_lnch_multitap_analog = multitap_analog ? 1 : 0; sio_set_multitap_analog(multitap_analog ? 1 : 0); } else { multitap_analog = ls.multitap_analog != 0; g_lnch_multitap_analog = multitap_analog ? 1 : 0; sio_set_multitap_analog(multitap_analog ? 1 : 0); } } net_cfg = {}; 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; net_cfg.input_prediction = ls.netplay_launch.input_prediction; net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; #if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY) net_cfg.transport_host = ls.netplay_launch.transport_host ? 1 : 0; #endif net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0; net_cfg.rollback = ls.netplay_launch.rollback ? 1 : 0; /* Same fold as the first-boot path: rematch must not lose * the seat rules the lobby settled. */ net_cfg.spectator = ls.netplay_launch.is_spectator ? 1 : 0; net_cfg.spectator_wire_slot = ls.netplay_launch.spectator_wire_slot; net_cfg.guest_memcard = ls.netplay_launch.guest_memcard ? 1 : 0; net_cfg.host_spectates = ls.netplay_launch.host_spectates ? 1 : 0; net_cfg.port_map_valid = ls.netplay_launch.slot_port_valid ? 1 : 0; for (int i = 0; i < 9; ++i) net_cfg.port_of_slot[i] = (net_cfg.port_map_valid && i <= RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS) ? ls.netplay_launch.slot_port[i] : -1; net_cfg.player_count = ls.netplay_launch.player_count; net_cfg.slot_count = ae_np_session_slot_count( ls.netplay_launch.player_count, ls.netplay_launch.max_slots, ls.netplay_launch.local_slot, game_players, PSX_MAX_PLAYERS + (net_cfg.host_spectates ? 1 : 0)); if (net_cfg.player_count <= 0) net_cfg.player_count = net_cfg.slot_count; net_cfg.occupied_mask = ls.netplay_launch.occupied_mask; 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; } else { net_cfg = {}; g_netplay_from_lobby = 0; } /* Same player_src → player_device writeback as the first launcher * exit path, so rematch honors (or keeps) the card selections. */ { const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); for (int i = 0; i < n; ++i) { if (ls.player_src[i] == 1) { player_device[i] = "keyboard"; } else if (ls.player_src[i] == 0) { player_device[i] = "none"; } else if (ls.player_gamepad_guid[i][0]) { player_device[i] = ls.player_gamepad_guid[i]; } else if (PSXRecompV4::launcher_source_from_device( player_device[i]) <= 1) { player_device[i] = "gamepad"; } /* Same resolution as the first launcher-exit path. The * override here is the previous session's; the rematch's * start_mod_session() below clears it, re-runs activation * and applies the new session's override over this. */ player_mode[i] = PSXRecompV4::resolve_player_mode_after_launcher( ls.pad_mode[i], ctrl_lock_mode, ctrl_locked_mode[i], g_mod_controller_mode_override[i]); player_deadzone[i] = ls.deadzone[i] * 32767 / 100; } } int ir_row_result = PSX_IR_UNSET; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) ir_row_result = ls.internal_resolution; #endif const PsxIrAdopted ir = psx_ir_adopt_launcher( kLauncherHasInternalResolution, ir_preset_seeded, ir_ss_seeded, ls.supersampling, ir_row_result, g_video_ref_lines, psx_sdl_display_pixel_height(nullptr), ls.renderer == 1 ? GL_MAX_INTERNAL_SCALE : SW_MAX_INTERNAL_SCALE); /* Persist controller (and rematch video) choices without wiping * the rest of settings.toml — merge into the on-disk file. */ { const auto settings_path = exe_dir_from_argv(argv[0]) / "settings.toml"; PSXRecompV4::UserSettings us = PSXRecompV4::load_user_settings(settings_path); const int un = std::min(PSX_MAX_PLAYERS, PSXRecompV4::UserSettings::kMaxControllerPlayers); for (int i = 0; i < un; ++i) { us.p_device[i] = player_device[i]; us.has_p_device[i] = true; us.p_mode[i] = player_mode[i]; us.has_p_mode[i] = true; us.p_deadzone[i] = player_deadzone[i]; us.has_p_deadzone[i] = true; } us.deadzone = player_deadzone[0]; us.has_deadzone = true; if (game_discs.size() > 1 && ls.disc_index > 0) { selected_disc_index = ls.disc_index; us.disc_index = ls.disc_index; us.has_disc_index = true; } #if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ENABLED) multitap_enabled = ls.multitap_enabled != 0; us.multitap_enabled = multitap_enabled; us.has_multitap_enabled = true; apply_offline_pad_count(game_players, multitap_enabled); #endif #if defined(RECOMP_LAUNCHER_HAS_MULTITAP_ANALOG) multitap_analog = ls.multitap_analog != 0; us.multitap_analog = multitap_analog; us.has_multitap_analog = true; g_lnch_multitap_analog = multitap_analog ? 1 : 0; sio_set_multitap_analog(multitap_analog ? 1 : 0); if (game_config_path && game_config_path[0]) { (void)PSXRecompV4::upsert_game_toml_controller_bool( game_config_path, "multitap_analog", multitap_analog); } #endif /* Memory-card slots: same fold as the first-boot exit path, so * a card toggled or browsed in from the rematch launcher * reaches memcard_init_slots at session_reboot and * settings.toml — instead of the rematch silently replaying * the pre-match slot config. A --memcard-dir fleet override * keeps winning over paths, as it does at first boot. */ memcard1_enabled = ls.memcard_enabled[0] > 0; memcard2_enabled = ls.memcard_enabled[1] > 0; us.memcard1_enabled = memcard1_enabled; us.has_memcard1_enabled = true; us.memcard2_enabled = memcard2_enabled; us.has_memcard2_enabled = true; if (!cli_memcard_dir) { if (ls.memcard_path[0][0]) { memcard1_path = ls.memcard_path[0]; us.memcard1_path = memcard1_path; us.has_memcard1_path = true; } if (ls.memcard_path[1][0]) { memcard2_path = ls.memcard_path[1]; us.memcard2_path = memcard2_path; us.has_memcard2_path = true; } } us.renderer = ls.renderer; us.has_renderer = true; us.supersampling = ir.save_ss; us.has_supersampling = true; us.internal_resolution = ir.save_ir; us.has_internal_resolution = ir.save_ir != PSX_IR_UNSET; #if defined(RECOMP_LAUNCHER_HAS_DYNAMIC_RESOLUTION) us.dynamic_resolution = ls.dynamic_resolution != 0; us.has_dynamic_resolution = true; us.dynamic_resolution_min = ls.dynamic_resolution_min; us.has_dynamic_resolution_min = true; g_video_dynres = us.dynamic_resolution ? 1 : 0; g_video_dynres_min = us.dynamic_resolution_min; #endif us.antialiasing = ls.antialiasing != 0; us.has_antialiasing = true; us.texture_filter = ls.texture_filter; us.has_texture_filter = true; us.fmv_filter = launcher_fmv_filter_to_cfg(ls.fmv_filter); us.has_fmv_filter = true; us.geometry_correction = ls.geometry_correction != 0; us.has_geometry_correction = true; us.perspective_texturing = ls.perspective_texturing != 0; us.has_perspective_texturing = true; us.screen_kind = ls.screen_kind; us.has_screen_kind = true; #if defined(RECOMP_LAUNCHER_HAS_SCANLINES) us.scanlines = ls.scanlines != 0; us.has_scanlines = true; if (ls.scanline_strength_pct >= 0) { us.scanline_strength = ls.scanline_strength_pct / 100.0; us.has_scanline_strength = true; } #endif us.frame_interpolation = ls.frame_interp != 0; us.has_frame_interpolation = true; us.frame_interpolation_fps = ls.frame_interp_fps; us.has_frame_interpolation_fps = true; us.audio_freq = ls.audio_freq; us.has_audio_freq = true; us.spu_hq = ls.spu_hq != 0; us.has_spu_hq = true; us.rewind = ls.rewind_enabled != 0; us.has_rewind = true; us.rewind_depth = ls.rewind_depth > 0 ? ls.rewind_depth : 50; us.has_rewind_depth = true; us.rewind_interval = ls.rewind_interval > 0 ? ls.rewind_interval : 15; us.has_rewind_interval = true; us.hotkey_pad_rewind = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_REWIND], PSX_HOTKEY_PAD_SELECT_R3); us.has_hotkey_pad_rewind = true; us.hotkey_pad_save_state_menu = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_SAVE_STATE_MENU], PSX_HOTKEY_PAD_SELECT_R1); us.has_hotkey_pad_save_state_menu = true; us.hotkey_pad_fast_forward = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD], PSX_HOTKEY_PAD_SELECT_L1); us.has_hotkey_pad_fast_forward = true; us.hotkey_pad_fast_forward_toggle = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE], 0); us.has_hotkey_pad_fast_forward_toggle = true; us.auto_skip_fmv = ls.auto_skip_fmv != 0; us.has_auto_skip_fmv = skip_fmv_offered; us.turbo_loads = ls.turbo_loads != 0; us.has_turbo_loads = turbo_loads_offered; us.fullscreen = ls.fullscreen; us.has_fullscreen = true; us.window_width = ls.window_width > 0 ? ls.window_width : g_video_win_w; us.has_window_width = true; switch (ls.aspect_index) { case 2: us.aspect_num = 21; us.aspect_den = 9; break; case 1: us.aspect_num = 16; us.aspect_den = 9; break; default: us.aspect_num = 4; us.aspect_den = 3; break; } us.has_aspect_ratio = true; if (ls.bios_path[0]) { us.bios_path = ls.bios_path; us.has_bios_path = true; write_cached_path(argv[0], "bios.cfg", us.bios_path); } else { us.bios_path.clear(); us.has_bios_path = false; write_cached_path(argv[0], "bios.cfg", {}); } (void)PSXRecompV4::save_user_settings(settings_path, us); } g_video_renderer = ls.renderer; g_video_internal_res = ir.preset; g_video_scale = ir.scale; g_video_aa = ls.antialiasing; g_video_texfilter = ls.texture_filter; g_video_fmv_filter = launcher_fmv_filter_to_cfg(ls.fmv_filter); g_video_geometry_correction = ls.geometry_correction ? 1 : 0; g_video_perspective_texturing = ls.perspective_texturing ? 1 : 0; g_video_screen = ls.screen_kind; #if defined(RECOMP_LAUNCHER_HAS_SCANLINES) g_video_scanlines = ls.scanlines != 0; if (ls.scanline_strength_pct >= 0) g_video_scanline_strength = ls.scanline_strength_pct / 100.0f; gl_renderer_set_scanlines(g_video_scanlines ? 1 : 0, g_video_scanline_strength); #endif /* Load acceleration and FMV skipping are mod-owned on PSX, and the * launcher struct these come from was snapshotted BEFORE * mod_runtime_activate_plugins() ran. Applying them here would * clobber whatever the Fast Loading / CD Speed / Tweaks plugins * decided with a stale pre-activation value — turning a player's * enabled mod silently back off on the first in-game Apply. The * offered flags are false for both, so leave both globals alone; * the rematch's start_mod_session() below resets and re-decides * both. */ if (skip_fmv_offered) g_auto_skip_fmv = ls.auto_skip_fmv ? 1 : 0; if (turbo_loads_offered) g_turbo_loads_enabled = ls.turbo_loads ? 1 : 0; g_fullscreen = ls.fullscreen; g_frame_interpolation = ls.frame_interp ? 1 : 0; g_frame_interpolation_fps = ls.frame_interp_fps; g_audio_freq = ls.audio_freq; g_audio_spu_hq = ls.spu_hq != 0; if (ls.rewind_depth > 0) { g_rewind_depth = ls.rewind_depth; psx_rewind_set_depth((uint32_t)g_rewind_depth); } if (ls.rewind_interval > 0) { g_rewind_interval = ls.rewind_interval; psx_rewind_set_interval((uint32_t)g_rewind_interval); } /* Applied live so turning rewind off frees the ring now rather * than next launch — reclaiming it is the point of the setting. * shutdown() also closes the overlay and drops a pending load. */ if ((ls.rewind_enabled ? 1 : 0) != g_rewind_enabled) { g_rewind_enabled = ls.rewind_enabled ? 1 : 0; psx_rewind_set_enabled(g_rewind_enabled); if (g_rewind_enabled) psx_rewind_configure(memory_get_bios_checksum(), game_entry_pc); else psx_rewind_shutdown(); } g_hotkey_pad_rewind = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_REWIND], PSX_HOTKEY_PAD_SELECT_R3); g_hotkey_pad_save_state_menu = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_SAVE_STATE_MENU], PSX_HOTKEY_PAD_SELECT_R1); g_hotkey_pad_fast_forward = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD], PSX_HOTKEY_PAD_SELECT_L1); g_hotkey_pad_fast_forward_toggle = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE], 0); switch (ls.aspect_index) { case 2: g_video_aspect_num = 21; g_video_aspect_den = 9; break; case 1: g_video_aspect_num = 16; g_video_aspect_den = 9; break; default: g_video_aspect_num = 4; g_video_aspect_den = 3; break; } g_video_win_w = ls.window_width > 0 ? ls.window_width : g_video_win_w; /* Preference for persistence; session settle may override boot path. */ if (ls.bios_path[0]) bios_path_str = ls.bios_path; else { std::filesystem::path ob = resolve_bios_path(PSX_BUNDLED_BIOS_PATH, argv[0]); bios_path_str = ob.empty() ? PSX_BUNDLED_BIOS_PATH : ob.string(); } if (net_cfg.enabled) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid && caps->session_bios[0]) ae_np_set_session_bios_token(caps->session_bios, caps->session_bios_crc); std::filesystem::path session_path; const char* session_bios_id = nullptr; if (g_lnch_session_bios[0] && resolve_match_session_bios_path( g_lnch_session_bios, g_lnch_session_bios_crc, ls.bios_path[0] ? std::filesystem::path(ls.bios_path) : std::filesystem::path{}, ls.bios_path, argv[0], &session_path, &session_bios_id)) { if (std::strcmp(g_lnch_session_bios, "scph1001") == 0 && session_path.empty()) { std::fprintf(stderr, "psxrecomp: rematch session BIOS scph1001 (crc %08x) " "required but no validated dump — aborting\n", (unsigned)g_lnch_session_bios_crc); SDL_Quit(); return 1; } if (session_path.empty()) { std::filesystem::path ob = resolve_bios_path(PSX_BUNDLED_BIOS_PATH, argv[0]); bios_path_str = ob.empty() ? PSX_BUNDLED_BIOS_PATH : ob.string(); std::fprintf(stdout, "psxrecomp: rematch session BIOS = OpenBIOS " "(preference unchanged)\n"); } else { bios_path_str = session_path.string(); std::fprintf(stdout, "psxrecomp: rematch session BIOS = %s (%s; " "preference unchanged)\n", session_bios_id ? session_bios_id : "retail", bios_path_str.c_str()); } } } { PSXRecompV4::HostLaunchTimingScope timing(HOST_LAUNCH_RUNTIME_COMMIT); std::string mod_error; if (net_cfg.enabled) { if (!netplay_commit_mods(resolved_disc, &mod_error)) { std::fprintf(stderr, "psxrecomp: cannot apply netplay mods " "for rematch: %s\n", mod_error.c_str()); SDL_Quit(); return 1; } } else if (!PSXRecompV4::mod_runtime_commit(resolved_disc, &mod_error)) { std::fprintf(stderr, "psxrecomp: cannot relaunch with selected " "mods: %s\n", mod_error.c_str()); SDL_Quit(); return 1; } timing.success(); } /* `goto session_reboot` re-enters below the first-boot session * block, so run the same session start here, after the * commit/clear above: the controller, load and disc-speed resets, * the mod-owned presentation reset, activation (which rebuilds the * function-entry hook table the commit/clear emptied), then what * activation chose. A netplay rematch has no plan, so it stays * vanilla. Only a netplay match returns here, so the reachable * leak the reset closes is the Fit and fixed aspect its local * viewport set. The launcher round-trips the previous session's * aspect through ls.aspect_index; widescreen is mod-owned on PSX, * so re-apply the Settings 4:3 clamp from startup first, where a * plugin's activation or the netplay local viewport can still * replace it. */ if (!ws_offered) { g_video_aspect_num = 4; g_video_aspect_den = 3; } start_mod_session(net_cfg.enabled); disc_path_str = session_disc_path(resolved_disc); std::printf("psxrecomp: rematch from lobby (netplay=%d)\n", net_cfg.enabled ? 1 : 0); std::fflush(stdout); goto session_reboot; } } #endif psx_lobby_disconnect(); SDL_Quit(); return 0; }